Compare commits

...

75 Commits

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

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

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

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

* Remove extra blank line in backend/filename.go
2025-12-12 17:27:09 +07:00
afkarxyz 8f10094e40 v6.7 2025-12-08 19:33:43 +07:00
afkarxyz 2fb544d1f8 v6.6 2025-12-05 05:26:33 +07:00
afkarxyz d16eaa324a v6.6 2025-12-05 05:25:50 +07:00
afkarxyz cc3f7640c6 v6.5 2025-11-30 05:38:44 +07:00
Lukas 2653586eea Download Queue & Progress UI (#123)
* Add download queue tracking and UI integration

Introduces backend support for a download queue with item tracking, status updates, and session statistics. Adds frontend components and hooks for displaying and managing the download queue, including a dialog and toast indicator. Updates download logic to pre-add items to the queue, track progress, and handle completion, skipping, and failure states. Integrates @radix-ui/react-scroll-area for improved UI scrolling.

* Add session stats to DownloadQueue dialog

Introduces session statistics (downloaded amount, speed, and duration) to the DownloadQueue dialog for improved user feedback. Also adjusts dialog sizing for better display and removes the sm:max-w-lg restriction in dialog.tsx for more flexible width.
2025-11-29 17:36:58 +07:00
afkarxyz 0c92385c56 v6.4 2025-11-27 14:14:19 +07:00
afkarxyz 957fb83dbc v6.4 2025-11-27 06:49:33 +07:00
afkarxyz 90f1871488 UPX 2025-11-27 04:58:53 +07:00
afkarxyz 2d0e5055f8 UPX 2025-11-27 04:52:16 +07:00
afkarxyz 08d02be4f2 v6.3 2025-11-26 10:55:08 +07:00
afkarxyz 5b542dcf29 v6.3 2025-11-26 10:48:26 +07:00
afkarxyz 48f9584027 v6.3 2025-11-26 10:47:02 +07:00
afkarxyz 4241a591aa Fallback Tidal with search and ISRC matching 2025-11-26 07:32:26 +07:00
afkarxyz f346fbb6ba Refactor README.md for badge updates and formatting 2025-11-26 04:51:34 +07:00
Lukas 2172981110 Add FLAC audio quality analysis and spectrum visualization (#110)
Introduces backend support for analyzing FLAC audio files, including technical metrics and frequency spectrum extraction. Adds frontend components and hooks for file selection, analysis, and visualization, integrating a new Audio Quality Analyzer dialog into the UI. Updates types and dependencies to support audio analysis features.
2025-11-26 04:05:12 +07:00
afkarxyz ddf1844237 v6.2 2025-11-25 13:25:29 +07:00
afkarxyz 3a5aea4c91 v6.2 2025-11-25 13:19:11 +07:00
afkarxyz 6b4ad16882 v6.2 2025-11-25 13:15:43 +07:00
afkarxyz 4dbd88e689 v6.1 2025-11-24 15:19:25 +07:00
afkarxyz d0665fdcc5 v6.1 2025-11-24 15:15:35 +07:00
afkarxyz 6ee3c2f653 v6.1 2025-11-24 14:52:47 +07:00
afkarxyz 73d8205f6f v6.0 2025-11-24 05:29:06 +07:00
114 changed files with 18627 additions and 7099 deletions
+29 -11
View File
@@ -2,18 +2,13 @@ name: Build Multi-Platform
on:
push:
branches:
- main
tags:
- 'v*'
pull_request:
branches:
- main
workflow_dispatch:
env:
GO_VERSION: '1.25.4'
NODE_VERSION: '20'
GO_VERSION: '1.25.5'
NODE_VERSION: '24'
jobs:
build-windows:
@@ -72,9 +67,17 @@ jobs:
pnpm install
pnpm run generate-icon
- name: Install UPX
run: |
choco install upx -y
- name: Build application
run: wails build -platform windows/amd64
- name: Compress with UPX
run: |
upx --best --lzma "build\bin\SpotiFLAC.exe"
- name: Prepare artifacts
run: |
mkdir -p dist
@@ -219,7 +222,7 @@ jobs:
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libfuse2 imagemagick upx-ucl
# Create symlink for webkit2gtk-4.0 -> webkit2gtk-4.1 (Ubuntu 24.04 compatibility)
sudo ln -sf /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.1.pc /usr/lib/x86_64-linux-gnu/pkgconfig/webkit2gtk-4.0.pc
@@ -236,10 +239,25 @@ jobs:
- name: Build application
run: wails build -platform linux/amd64
- name: Download appimagetool
- name: Compress with UPX
run: |
wget -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
chmod +x appimagetool
upx --best --lzma build/bin/SpotiFLAC
- name: Cache appimagetool
id: cache-appimagetool
uses: actions/cache@v4
with:
path: appimagetool
key: appimagetool-x86_64-v1
- name: Download appimagetool
if: steps.cache-appimagetool.outputs.cache-hit != 'true'
run: |
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage || \
wget --timeout=30 --tries=5 --retry-connrefused --waitretry=5 -O appimagetool https://github.com/AppImage/AppImageKit/releases/download/13/appimagetool-x86_64.AppImage
- name: Make appimagetool executable
run: chmod +x appimagetool
- name: Create AppImage
run: |
+4 -1
View File
@@ -56,6 +56,9 @@ temp/
*.bak
*.old
# Test files
test
# Build notes (optional - uncomment if you don't want to commit)
# BUILD_NOTES.md
build.txt
push.bat
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 afkarxyz
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+34 -18
View File
@@ -1,39 +1,55 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/afkarxyz/SpotiFLAC/total?style=for-the-badge)](https://github.com/afkarxyz/SpotiFLAC/releases)
<!-- ![Maintenance](https://maintenance.afkarxyz.fun?v=3) -->
![Image](https://github.com/user-attachments/assets/a6e92fdd-2944-45c1-83e8-e23a26c827af)
<div align="center">
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no account required.
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music — no account required.
<br><br>
![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=windows&logoColor=white)
![Windows](https://img.shields.io/badge/Windows-10%2B-0078D6?style=for-the-badge&logo=data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI1MTIiIGhlaWdodD0iNTEyIiB2aWV3Qm94PSIwIDAgMjAgMjAiPjxwYXRoIGZpbGw9IiNmZmZmZmYiIGZpbGwtcnVsZT0iZXZlbm9kZCIgZD0iTTIwIDEwLjg3M1YyMEw4LjQ3OSAxOC41MzdsLjAwMS03LjY2NEgyMFptLTEzLjEyIDBsLS4wMDEgNy40NjFMMCAxNy40NjF2LTYuNTg4aDYuODhaTTIwIDkuMjczSDguNDhsLS4wMDEtNy44MUwyMCAwdjkuMjczWk02Ljg3OSAxLjY2NmwuMDAxIDcuNjA3SDBWMi41MzlsNi44NzktLjg3M1oiLz48L3N2Zz4=)
![macOS](https://img.shields.io/badge/macOS-10.13%2B-000000?style=for-the-badge&logo=apple&logoColor=white)
![Linux](https://img.shields.io/badge/Linux-Any-FCC624?style=for-the-badge&logo=linux&logoColor=white)
<a href="https://trendshift.io/repositories/15737" target="_blank"><img src="https://trendshift.io/api/badge/repositories/15737" alt="afkarxyz%2FSpotiFLAC | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</div>
### [Download](https://github.com/afkarxyz/SpotiFLAC/releases)
## Screenshot
![Image](https://github.com/user-attachments/assets/e021e0fc-3b71-4fff-9455-ac0145d5fe9a)
## Lossless Audio Check
A simple utility for verifying the authenticity of FLAC files (lossless/lossy detection).
#### [Download](https://github.com/afkarxyz/SpotiFLAC/releases/download/v0/FLAC-Checker.zip) - Windows only
#
![image](https://github.com/user-attachments/assets/d63b422d-0ea3-4307-850f-96c99d7eaa9a)
![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05)
![Image](https://github.com/user-attachments/assets/4bc2d45a-8afc-4c91-9d57-afdbd2b9c225)
## Other projects
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API.
Get Spotify tracks in MP3 and FLAC via the spotidownloader.com API
### [SpotubeDL](https://spotubedl.com)
Download Spotify Tracks, Albums, Playlists as MP3/OGG/Opus with High Quality.
### [SpotiFLAC (Mobile)](https://github.com/zarzet/SpotiFLAC-Mobile)
SpotiFLAC for Android & iOS — maintained by [@zarzet](https://github.com/zarzet)
[![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/afkarxyz)
> Every coffee helps me keep going
## Disclaimer
This project is for **educational and private use only**. The developer does not condone or encourage copyright infringement.
**SpotiFLAC** is a third-party tool and is not affiliated with, endorsed by, or connected to Spotify, Tidal, Qobuz, Amazon Music, or any other streaming service.
You are solely responsible for:
1. Ensuring your use of this software complies with your local laws.
2. Reading and adhering to the Terms of Service of the respective platforms.
3. Any legal consequences resulting from the misuse of this tool.
The software is provided "as is", without warranty of any kind. The author assumes no liability for any bans, damages, or legal issues arising from its use.
> [!TIP]
>
> **Star Us**, You will receive all release notifications from GitHub without any delay ~
+880 -63
View File
File diff suppressed because it is too large Load Diff
+133 -44
View File
@@ -10,6 +10,7 @@ import (
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
@@ -62,16 +63,14 @@ func (a *AmazonDownloader) getRandomUserAgent() string {
}
func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (string, error) {
// Rate limiting: max 10 requests per minute (song.link API limit)
// Reset counter every minute
now := time.Now()
if now.Sub(a.apiCallResetTime) >= time.Minute {
a.apiCallCount = 0
a.apiCallResetTime = now
}
// If we've hit the limit, wait until the next minute
if a.apiCallCount >= 9 { // Use 9 to be safe (limit is 10)
if a.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(a.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
@@ -81,10 +80,9 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
}
}
// Add delay between requests (6 seconds = 10 requests per minute)
if !a.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(a.lastAPICallTime)
minDelay := 7 * time.Second // 7 seconds to be safe
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
@@ -92,7 +90,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
}
}
// Decode base64 API URL
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
@@ -108,7 +105,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
fmt.Println("Getting Amazon URL...")
// Retry logic for rate limit errors
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
@@ -117,11 +113,10 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
return "", fmt.Errorf("failed to get Amazon URL: %w", err)
}
// Update rate limit tracking
a.lastAPICallTime = time.Now()
a.apiCallCount++
if resp.StatusCode == 429 { // Too Many Requests
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
@@ -141,9 +136,23 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return "", fmt.Errorf("API returned empty response")
}
var songLinkResp SongLinkResponse
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
if err := json.Unmarshal(body, &songLinkResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]
@@ -153,7 +162,6 @@ func (a *AmazonDownloader) GetAmazonURLFromSpotify(spotifyTrackID string) (strin
amazonURL := amazonLink.URL
// Convert album URL to track URL if needed
if strings.Contains(amazonURL, "trackAsin=") {
parts := strings.Split(amazonURL, "trackAsin=")
if len(parts) > 1 {
@@ -172,12 +180,11 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
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)
@@ -218,7 +225,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
downloadID := submitResp.ID
fmt.Printf("Download ID: %s\n", downloadID)
// Step 2: Poll for completion
statusURL := fmt.Sprintf("%s/dl/%s", baseURL, downloadID)
fmt.Println("Waiting for download to complete...")
@@ -260,7 +266,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
if status.Status == "done" {
fmt.Println("\nDownload ready!")
// Build download URL
fileURL := status.URL
if strings.HasPrefix(fileURL, "./") {
fileURL = fmt.Sprintf("%s/%s", baseURL, fileURL[2:])
@@ -273,7 +278,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
fmt.Printf("Downloading: %s - %s\n", artist, trackName)
// Download file
downloadReq, err := http.NewRequest("GET", fileURL, nil)
if err != nil {
lastError = fmt.Errorf("failed to create download request: %w", err)
@@ -294,7 +298,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
break
}
// Generate filename
fileName := fmt.Sprintf("%s - %s.flac", artist, trackName)
for _, char := range `<>:"/\|?*` {
fileName = strings.ReplaceAll(fileName, string(char), "")
@@ -303,7 +306,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
filePath := filepath.Join(outputDir, fileName)
// Save file
out, err := os.Create(filePath)
if err != nil {
lastError = fmt.Errorf("failed to create file: %w", err)
@@ -312,7 +314,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
defer out.Close()
fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, fileResp.Body)
if err != nil {
@@ -320,7 +322,6 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
return "", fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
fmt.Println("Download complete!")
return filePath, nil
@@ -333,7 +334,7 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
lastError = fmt.Errorf("processing failed: %s", errorMsg)
break
} else {
// Still processing
friendlyStatus := status.FriendlyStatus
if friendlyStatus == "" {
friendlyStatus = status.Status
@@ -356,51 +357,85 @@ func (a *AmazonDownloader) DownloadFromService(amazonURL, outputDir string) (str
return "", fmt.Errorf("all regions failed. Last error: %v", lastError)
}
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
// Create output directory if needed
func (a *AmazonDownloader) DownloadByURL(amazonURL, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
// Get Amazon URL from Spotify track ID
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
if spotifyTrackName != "" && spotifyArtistName != "" {
expectedFilename := BuildExpectedFilename(spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, filenameFormat, includeTrackNumber, position, spotifyDiscNumber, false)
expectedPath := filepath.Join(outputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", expectedPath, float64(fileInfo.Size())/(1024*1024))
return "EXISTS:" + expectedPath, nil
}
}
// Download from service
fmt.Printf("Using Amazon URL: %s\n", amazonURL)
filePath, err := a.DownloadFromService(amazonURL, outputDir)
if err != nil {
return "", err
}
// File already has embedded metadata, just rename if needed
if spotifyTrackName != "" && spotifyArtistName != "" {
safeArtist := sanitizeFilename(spotifyArtistName)
safeTitle := sanitizeFilename(spotifyTrackName)
safeAlbum := sanitizeFilename(spotifyAlbumName)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Build filename based on format settings
var newFilename string
switch filenameFormat {
case "artist-title":
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
newFilename = safeTitle
default: // "title-artist"
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
year := ""
if len(spotifyReleaseDate) >= 4 {
year = spotifyReleaseDate[:4]
}
// Add track number prefix if enabled
if includeTrackNumber && position > 0 {
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
var newFilename string
if strings.Contains(filenameFormat, "{") {
newFilename = filenameFormat
newFilename = strings.ReplaceAll(newFilename, "{title}", safeTitle)
newFilename = strings.ReplaceAll(newFilename, "{artist}", safeArtist)
newFilename = strings.ReplaceAll(newFilename, "{album}", safeAlbum)
newFilename = strings.ReplaceAll(newFilename, "{album_artist}", safeAlbumArtist)
newFilename = strings.ReplaceAll(newFilename, "{year}", year)
if spotifyDiscNumber > 0 {
newFilename = strings.ReplaceAll(newFilename, "{disc}", fmt.Sprintf("%d", spotifyDiscNumber))
} else {
newFilename = strings.ReplaceAll(newFilename, "{disc}", "")
}
if position > 0 {
newFilename = strings.ReplaceAll(newFilename, "{track}", fmt.Sprintf("%02d", position))
} else {
newFilename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(newFilename, "")
newFilename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(newFilename, "")
}
} else {
switch filenameFormat {
case "artist-title":
newFilename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
newFilename = safeTitle
default:
newFilename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
if includeTrackNumber && position > 0 {
newFilename = fmt.Sprintf("%02d. %s", position, newFilename)
}
}
newFilename = newFilename + ".flac"
newFilePath := filepath.Join(outputDir, newFilename)
// Rename file
if err := os.Rename(filePath, newFilePath); err != nil {
fmt.Printf("Warning: Failed to rename file: %v\n", err)
} else {
@@ -409,6 +444,60 @@ func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filena
}
}
fmt.Println("Embedding Spotify metadata...")
coverPath := ""
if spotifyCoverURL != "" {
coverPath = filePath + ".cover.jpg"
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: spotifyTrackName,
Artist: spotifyArtistName,
Album: spotifyAlbumName,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
}
if err := EmbedMetadata(filePath, metadata, coverPath); err != nil {
fmt.Printf("Warning: Failed to embed metadata: %v\n", err)
} else {
fmt.Println("Metadata embedded successfully")
}
fmt.Println("Done")
fmt.Println("✓ Downloaded successfully from Amazon Music")
return filePath, nil
}
func (a *AmazonDownloader) DownloadBySpotifyID(spotifyTrackID, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL string, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, embedMaxQualityCover bool, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
amazonURL, err := a.GetAmazonURLFromSpotify(spotifyTrackID)
if err != nil {
return "", err
}
return a.DownloadByURL(amazonURL, outputDir, filenameFormat, includeTrackNumber, position, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate, spotifyCoverURL, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks, embedMaxQualityCover, spotifyTotalDiscs, spotifyCopyright, spotifyPublisher, spotifyURL)
}
+164
View File
@@ -0,0 +1,164 @@
package backend
import (
"fmt"
"math"
"os"
"github.com/go-flac/go-flac"
mewflac "github.com/mewkiz/flac"
)
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"`
}
func AnalyzeTrack(filepath string) (*AnalysisResult, error) {
if !fileExists(filepath) {
return nil, fmt.Errorf("file does not exist: %s", filepath)
}
fileInfo, err := os.Stat(filepath)
if err != nil {
return nil, fmt.Errorf("failed to get file info: %w", err)
}
f, err := flac.ParseFile(filepath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
result := &AnalysisResult{
FilePath: filepath,
FileSize: fileInfo.Size(),
}
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
data := streamInfo.Data
if len(data) >= 18 {
result.SampleRate = uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
result.Channels = ((data[12] >> 1) & 0x07) + 1
result.BitsPerSample = ((data[12]&0x01)<<4 | data[13]>>4) + 1
result.TotalSamples = uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
if result.SampleRate > 0 {
result.Duration = float64(result.TotalSamples) / float64(result.SampleRate)
}
}
}
}
spectrum, err := AnalyzeSpectrum(filepath)
if err != nil {
fmt.Printf("Warning: failed to analyze spectrum: %v\n", err)
} else {
result.Spectrum = spectrum
calculateRealAudioMetrics(result, filepath)
}
result.BitDepth = fmt.Sprintf("%d-bit", result.BitsPerSample)
return result, nil
}
func calculateRealAudioMetrics(result *AnalysisResult, filepath string) {
samples, err := decodeFLACForMetrics(filepath)
if err != nil {
return
}
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
}
peakDB := 20.0 * math.Log10(peak)
result.PeakAmplitude = peakDB
rms := math.Sqrt(sumSquares / float64(len(samples)))
rmsDB := 20.0 * math.Log10(rms)
result.RMSLevel = rmsDB
result.DynamicRange = peakDB - rmsDB
}
func decodeFLACForMetrics(filepath string) ([]float64, error) {
stream, err := mewflac.ParseFile(filepath)
if err != nil {
return nil, err
}
defer stream.Close()
maxSamples := 10000000
samples := make([]float64, 0, maxSamples)
for {
frame, err := stream.ParseNext()
if err != nil {
break
}
var channelSamples []int32
if len(frame.Subframes) > 0 {
channelSamples = frame.Subframes[0].Samples
}
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 {
break
}
}
return samples, nil
}
func GetFileSize(filepath string) (int64, error) {
info, err := os.Stat(filepath)
if err != nil {
return 0, err
}
return info.Size(), nil
}
+2 -3
View File
@@ -6,13 +6,12 @@ import (
)
func GetDefaultMusicPath() string {
// Get user's home directory
homeDir, err := os.UserHomeDir()
if err != nil {
// Fallback to Public Music if can't get home dir
return "C:\\Users\\Public\\Music"
}
// Return path to user's Music folder
return filepath.Join(homeDir, "Music")
}
+525
View File
@@ -0,0 +1,525 @@
package backend
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
const (
spotifySize300 = "ab67616d00001e02"
spotifySize640 = "ab67616d0000b273"
spotifySizeMax = "ab67616d000082c1"
)
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"`
}
type CoverDownloadResponse 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 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
}
func NewCoverClient() *CoverClient {
return &CoverClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
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
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
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:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d - %s", position, filename)
}
}
return filename + ".cover.jpg"
}
func convertSmallToMedium(imageURL string) string {
if strings.Contains(imageURL, spotifySize300) {
return strings.Replace(imageURL, spotifySize300, spotifySize640, 1)
}
return imageURL
}
func (c *CoverClient) getMaxResolutionURL(imageURL string) string {
mediumURL := convertSmallToMedium(imageURL)
if strings.Contains(mediumURL, spotifySize640) {
return strings.Replace(mediumURL, spotifySize640, spotifySizeMax, 1)
}
return mediumURL
}
func (c *CoverClient) DownloadCoverToPath(coverURL, outputPath string, embedMaxQualityCover bool) error {
if coverURL == "" {
return fmt.Errorf("cover URL is required")
}
downloadURL := convertSmallToMedium(coverURL)
if embedMaxQualityCover {
downloadURL = c.getMaxResolutionURL(downloadURL)
}
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return fmt.Errorf("failed to download cover: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download cover: HTTP %d", resp.StatusCode)
}
file, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("failed to create file: %v", err)
}
defer file.Close()
_, err = io.Copy(file, resp.Body)
if err != nil {
return fmt.Errorf("failed to write cover file: %v", err)
}
return nil
}
func (c *CoverClient) DownloadCover(req CoverDownloadRequest) (*CoverDownloadResponse, error) {
if req.CoverURL == "" {
return &CoverDownloadResponse{
Success: false,
Error: "Cover URL is required",
}, fmt.Errorf("cover URL is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create output directory: %v", err),
}, err
}
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist"
}
filename := buildCoverFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &CoverDownloadResponse{
Success: true,
Message: "Cover file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
downloadURL := c.getMaxResolutionURL(req.CoverURL)
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download cover: %v", err),
}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to download cover: HTTP %d", resp.StatusCode),
}, fmt.Errorf("HTTP %d", resp.StatusCode)
}
file, err := os.Create(filePath)
if err != nil {
return &CoverDownloadResponse{
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 &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write cover file: %v", err),
}, err
}
return &CoverDownloadResponse{
Success: true,
Message: "Cover downloaded successfully",
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
}
-291
View File
@@ -1,291 +0,0 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
type DeezerDownloader struct {
client *http.Client
}
type DeezerTrack struct {
ID int64 `json:"id"`
Title string `json:"title"`
TitleShort string `json:"title_short"`
Duration int `json:"duration"`
TrackPos int `json:"track_position"`
DiskNumber int `json:"disk_number"`
ISRC string `json:"isrc"`
ReleaseDate string `json:"release_date"`
Artist struct {
Name string `json:"name"`
ID int64 `json:"id"`
} `json:"artist"`
Album struct {
Title string `json:"title"`
ID int64 `json:"id"`
CoverXL string `json:"cover_xl"`
CoverBig string `json:"cover_big"`
} `json:"album"`
Contributors []struct {
Name string `json:"name"`
Role string `json:"role"`
} `json:"contributors"`
}
type DeezMateResponse struct {
Success bool `json:"success"`
Links struct {
FLAC string `json:"flac"`
} `json:"links"`
}
func NewDeezerDownloader() *DeezerDownloader {
return &DeezerDownloader{
client: &http.Client{
Timeout: 60 * time.Second,
},
}
}
func (d *DeezerDownloader) GetTrackByISRC(isrc string) (*DeezerTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlemVyLmNvbS8yLjAvdHJhY2svaXNyYzo=")
url := fmt.Sprintf("%s%s", string(apiBase), isrc)
resp, err := d.client.Get(url)
if err != nil {
return nil, fmt.Errorf("failed to fetch track: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
var track DeezerTrack
if err := json.NewDecoder(resp.Body).Decode(&track); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if track.ID == 0 {
return nil, fmt.Errorf("track not found for ISRC: %s", isrc)
}
return &track, nil
}
func (d *DeezerDownloader) GetDownloadURL(trackID int64) (string, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuZGVlem1hdGUuY29tL2RsLw==")
url := fmt.Sprintf("%s%d", string(apiBase), trackID)
resp, err := d.client.Get(url)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
defer resp.Body.Close()
var apiResp DeezMateResponse
if err := json.NewDecoder(resp.Body).Decode(&apiResp); err != nil {
return "", fmt.Errorf("failed to decode API response: %w", err)
}
if !apiResp.Success || apiResp.Links.FLAC == "" {
return "", fmt.Errorf("no FLAC download link available")
}
return apiResp.Links.FLAC, nil
}
func (d *DeezerDownloader) DownloadFile(url, filepath string) error {
resp, err := d.client.Get(url)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("download failed with status %d", resp.StatusCode)
}
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create file: %w", err)
}
defer out.Close()
fmt.Println("Downloading...")
// Use progress writer to track download
pw := NewProgressWriter(out)
_, err = io.Copy(pw, resp.Body)
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
// Print final size
fmt.Printf("\rDownloaded: %.2f MB (Complete)\n", float64(pw.GetTotal())/(1024*1024))
return nil
}
func (d *DeezerDownloader) DownloadCoverArt(coverURL, filepath string) error {
if coverURL == "" {
return fmt.Errorf("no cover URL provided")
}
resp, err := d.client.Get(coverURL)
if err != nil {
return fmt.Errorf("failed to download cover: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("cover download failed with status %d", resp.StatusCode)
}
out, err := os.Create(filepath)
if err != nil {
return fmt.Errorf("failed to create cover file: %w", err)
}
defer out.Close()
_, err = io.Copy(out, resp.Body)
return err
}
func buildFilename(title, artist string, trackNumber int, format string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
var filename string
// Build base filename based on format
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
}
// Add track number prefix if enabled
if includeTrackNumber && position > 0 {
// Use album track number if in album folder structure, otherwise use playlist position
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
return filename + ".flac"
}
func (d *DeezerDownloader) DownloadByISRC(isrc, outputDir, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
track, err := d.GetTrackByISRC(isrc)
if err != nil {
return "", err
}
// Use Spotify metadata if provided, otherwise fallback to Deezer metadata
artists := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
if artists == "" {
artists = track.Artist.Name
if len(track.Contributors) > 0 {
var mainArtists []string
for _, contrib := range track.Contributors {
if contrib.Role == "Main" {
mainArtists = append(mainArtists, contrib.Name)
}
}
if len(mainArtists) > 0 {
artists = strings.Join(mainArtists, ", ")
}
}
}
if trackTitle == "" {
trackTitle = track.Title
}
if albumTitle == "" {
albumTitle = track.Album.Title
}
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
fmt.Printf("Album: %s\n", albumTitle)
downloadURL, err := d.GetDownloadURL(track.ID)
if err != nil {
return "", err
}
safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle)
// Build filename based on format settings
filename := buildFilename(safeTitle, safeArtist, track.TrackPos, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
fmt.Printf("File already exists: %s (%.2f MB)\n", filepath, float64(fileInfo.Size())/(1024*1024))
return "EXISTS:" + filepath, nil
}
fmt.Println("Downloading FLAC file...")
if err := d.DownloadFile(downloadURL, filepath); err != nil {
return "", err
}
fmt.Printf("Downloaded: %s\n", filepath)
coverPath := ""
if track.Album.CoverXL != "" {
coverPath = filepath + ".cover.jpg"
fmt.Println("Downloading cover art...")
if err := d.DownloadCoverArt(track.Album.CoverXL, coverPath); err != nil {
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
} else {
defer os.Remove(coverPath)
}
}
fmt.Println("Embedding metadata and cover art...")
// Use album track number if in album folder structure, otherwise use playlist position
trackNumberToEmbed := 0
if position > 0 {
if useAlbumTrackNumber && track.TrackPos > 0 {
trackNumberToEmbed = track.TrackPos
} else {
trackNumberToEmbed = position
}
}
metadata := Metadata{
Title: trackTitle,
Artist: artists,
Album: albumTitle,
Date: track.ReleaseDate,
TrackNumber: trackNumberToEmbed,
DiscNumber: track.DiskNumber,
ISRC: track.ISRC,
}
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
return "", fmt.Errorf("failed to embed metadata: %w", err)
}
fmt.Println("Metadata embedded successfully!")
return filepath, nil
}
+680
View File
@@ -0,0 +1,680 @@
package backend
import (
"archive/tar"
"archive/zip"
"encoding/base64"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"time"
"github.com/ulikunitz/xz"
)
func decodeBase64(encoded string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", err
}
return string(decoded), nil
}
func ValidateExecutable(path string) error {
cleanedPath := filepath.Clean(path)
if cleanedPath == "" {
return fmt.Errorf("empty path")
}
if !filepath.IsAbs(cleanedPath) {
return fmt.Errorf("path must be absolute: %s", path)
}
info, err := os.Stat(cleanedPath)
if err != nil {
return fmt.Errorf("failed to stat file: %w", err)
}
if info.IsDir() {
return fmt.Errorf("path is a directory: %s", path)
}
if runtime.GOOS != "windows" {
if info.Mode()&0111 == 0 {
return fmt.Errorf("file is not executable: %s", path)
}
}
base := filepath.Base(cleanedPath)
validNames := map[string]bool{
"ffmpeg": true,
"ffmpeg.exe": true,
"ffprobe": true,
"ffprobe.exe": true,
}
if !validNames[base] {
return fmt.Errorf("invalid executable name: %s", base)
}
return nil
}
const (
ffmpegWindowsURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3Qtd2luNjQtZ3BsLnppcA=="
ffmpegLinuxURL = "aHR0cHM6Ly9naXRodWIuY29tL0J0Yk4vRkZtcGVnLUJ1aWxkcy9yZWxlYXNlcy9kb3dubG9hZC9sYXRlc3QvZmZtcGVnLW1hc3Rlci1sYXRlc3QtbGludXg2NC1ncGwudGFyLnh6"
ffmpegMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS96aXA="
ffprobeMacOSURL = "aHR0cHM6Ly9ldmVybWVldC5jeC9mZm1wZWcvZ2V0cmVsZWFzZS9mZnByb2JlL3ppcA=="
)
func GetFFmpegDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(homeDir, ".spotiflac"), nil
}
func GetFFmpegPath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
}
ffmpegName := "ffmpeg"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
}
return filepath.Join(ffmpegDir, ffmpegName), nil
}
func GetFFprobePath() (string, error) {
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return "", err
}
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffprobeName = "ffprobe.exe"
}
ffprobePath := filepath.Join(ffmpegDir, ffprobeName)
if _, err := os.Stat(ffprobePath); err == nil {
return ffprobePath, nil
}
return "", fmt.Errorf("ffprobe not found in app directory")
}
func IsFFprobeInstalled() (bool, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return false, nil
}
if err := ValidateExecutable(ffprobePath); err != nil {
return false, nil
}
cmd := exec.Command(ffprobePath, "-version")
setHideWindow(cmd)
err = cmd.Run()
return err == nil, nil
}
func IsFFmpegInstalled() (bool, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return false, err
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return false, nil
}
cmd := exec.Command(ffmpegPath, "-version")
setHideWindow(cmd)
err = cmd.Run()
return err == nil, nil
}
func DownloadFFmpeg(progressCallback func(int)) error {
SetDownloadProgress(0)
SetDownloadSpeed(0)
SetDownloading(true)
defer SetDownloading(false)
ffmpegDir, err := GetFFmpegDir()
if err != nil {
return err
}
if err := os.MkdirAll(ffmpegDir, 0755); err != nil {
return fmt.Errorf("failed to create ffmpeg directory: %w", err)
}
if runtime.GOOS == "darwin" {
ffmpegInstalled, _ := IsFFmpegInstalled()
ffprobeInstalled, _ := IsFFprobeInstalled()
if !ffmpegInstalled && !ffprobeInstalled {
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 50); err != nil {
return err
}
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 50, 100); err != nil {
return fmt.Errorf("failed to download ffprobe: %w", err)
}
} else if !ffmpegInstalled {
ffmpegURL, _ := decodeBase64(ffmpegMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffmpeg from: %s\n", ffmpegURL)
if err := downloadAndExtract(ffmpegURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
} else if !ffprobeInstalled {
ffprobeURL, _ := decodeBase64(ffprobeMacOSURL)
fmt.Printf("[FFmpeg] Downloading ffprobe from: %s\n", ffprobeURL)
if err := downloadAndExtract(ffprobeURL, ffmpegDir, progressCallback, 0, 100); err != nil {
return fmt.Errorf("failed to download ffprobe: %w", err)
}
}
return nil
}
var encodedURL string
switch runtime.GOOS {
case "windows":
encodedURL = ffmpegWindowsURL
case "linux":
encodedURL = ffmpegLinuxURL
default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
url, err := decodeBase64(encodedURL)
if err != nil {
return fmt.Errorf("failed to decode ffmpeg URL: %w", err)
}
fmt.Printf("[FFmpeg] Downloading from: %s\n", url)
if err := downloadAndExtract(url, ffmpegDir, progressCallback, 0, 100); err != nil {
return err
}
return nil
}
func downloadAndExtract(url, destDir string, progressCallback func(int), progressStart, progressEnd int) error {
tmpFile, err := os.CreateTemp("", "ffmpeg-*")
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("failed to download: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to download: HTTP %d", resp.StatusCode)
}
totalSize := resp.ContentLength
var downloaded int64
lastTime := time.Now()
var lastBytes int64
if totalSize > 0 {
totalSizeMB := float64(totalSize) / (1024 * 1024)
fmt.Printf("[FFmpeg] Total size: %.2f MB\n", totalSizeMB)
} else {
fmt.Printf("[FFmpeg] Downloading... (size unknown)\n")
}
buf := make([]byte, 32*1024)
for {
n, err := resp.Body.Read(buf)
if n > 0 {
_, writeErr := tmpFile.Write(buf[:n])
if writeErr != nil {
return fmt.Errorf("failed to write to temp file: %w", writeErr)
}
downloaded += int64(n)
mbDownloaded := float64(downloaded) / (1024 * 1024)
now := time.Now()
timeDiff := now.Sub(lastTime).Seconds()
var speedMBps float64
if timeDiff > 0.1 {
bytesDiff := float64(downloaded - lastBytes)
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
lastTime = now
lastBytes = downloaded
}
SetDownloadProgress(mbDownloaded)
if speedMBps > 0 {
SetDownloadSpeed(speedMBps)
}
if totalSize > 0 && progressCallback != nil {
rawProgress := float64(downloaded) / float64(totalSize)
scaledProgress := progressStart + int(rawProgress*float64(progressEnd-progressStart))
progressCallback(scaledProgress)
}
if totalSize > 0 {
percent := float64(downloaded) * 100 / float64(totalSize)
if speedMBps > 0 {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%) - %.2f MB/s",
mbDownloaded, float64(totalSize)/(1024*1024), percent, speedMBps)
} else {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB / %.2f MB (%.1f%%)",
mbDownloaded, float64(totalSize)/(1024*1024), percent)
}
} else {
if speedMBps > 0 {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB - %.2f MB/s", mbDownloaded, speedMBps)
} else {
fmt.Printf("\r[FFmpeg] Downloading: %.2f MB", mbDownloaded)
}
}
}
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read response: %w", err)
}
}
tmpFile.Close()
if totalSize > 0 {
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB / %.2f MB (100%%) \n",
float64(downloaded)/(1024*1024), float64(totalSize)/(1024*1024))
} else {
fmt.Printf("\r[FFmpeg] Download complete: %.2f MB \n", float64(downloaded)/(1024*1024))
}
fmt.Printf("[FFmpeg] Extracting...\n")
if strings.HasSuffix(url, ".tar.xz") || runtime.GOOS == "linux" {
return extractTarXz(tmpFile.Name(), destDir)
}
return extractZip(tmpFile.Name(), destDir)
}
func extractZip(zipPath, destDir string) error {
r, err := zip.OpenReader(zipPath)
if err != nil {
return fmt.Errorf("failed to open zip: %w", err)
}
defer r.Close()
ffmpegName := "ffmpeg"
ffprobeName := "ffprobe"
if runtime.GOOS == "windows" {
ffmpegName = "ffmpeg.exe"
ffprobeName = "ffprobe.exe"
}
foundFFmpeg := false
foundFFprobe := false
for _, f := range r.File {
baseName := filepath.Base(f.Name)
if f.FileInfo().IsDir() {
continue
}
var destPath string
if baseName == ffmpegName {
destPath = filepath.Join(destDir, ffmpegName)
foundFFmpeg = true
} else if baseName == ffprobeName {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
continue
}
fmt.Printf("[FFmpeg] Found: %s\n", f.Name)
rc, err := f.Open()
if err != nil {
return fmt.Errorf("failed to open file in zip: %w", err)
}
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
rc.Close()
return fmt.Errorf("failed to create output file: %w", err)
}
_, err = io.Copy(outFile, rc)
rc.Close()
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
if foundFFmpeg {
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
}
if foundFFprobe {
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
}
return nil
}
func extractTarXz(tarXzPath, destDir string) error {
file, err := os.Open(tarXzPath)
if err != nil {
return fmt.Errorf("failed to open tar.xz: %w", err)
}
defer file.Close()
xzReader, err := xz.NewReader(file)
if err != nil {
return fmt.Errorf("failed to create xz reader: %w", err)
}
tarReader := tar.NewReader(xzReader)
ffmpegName := "ffmpeg"
ffprobeName := "ffprobe"
foundFFmpeg := false
foundFFprobe := false
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return fmt.Errorf("failed to read tar: %w", err)
}
if header.Typeflag != tar.TypeReg {
continue
}
baseName := filepath.Base(header.Name)
var destPath string
if baseName == ffmpegName {
destPath = filepath.Join(destDir, ffmpegName)
foundFFmpeg = true
} else if baseName == ffprobeName {
destPath = filepath.Join(destDir, ffprobeName)
foundFFprobe = true
} else {
continue
}
fmt.Printf("[FFmpeg] Found: %s\n", header.Name)
outFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755)
if err != nil {
return fmt.Errorf("failed to create output file: %w", err)
}
_, err = io.Copy(outFile, tarReader)
outFile.Close()
if err != nil {
return fmt.Errorf("failed to extract file: %w", err)
}
fmt.Printf("[FFmpeg] Extracted to: %s\n", destPath)
}
if !foundFFmpeg && !foundFFprobe {
return fmt.Errorf("neither ffmpeg nor ffprobe found in archive")
}
if foundFFmpeg {
fmt.Printf("[FFmpeg] ffmpeg extracted successfully\n")
}
if foundFFprobe {
fmt.Printf("[FFmpeg] ffprobe extracted successfully\n")
}
return nil
}
type ConvertAudioRequest struct {
InputFiles []string `json:"input_files"`
OutputFormat string `json:"output_format"`
Bitrate string `json:"bitrate"`
Codec string `json:"codec"`
}
type ConvertAudioResult struct {
InputFile string `json:"input_file"`
OutputFile string `json:"output_file"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func ConvertAudio(req ConvertAudioRequest) ([]ConvertAudioResult, error) {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return nil, fmt.Errorf("failed to get ffmpeg path: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return nil, fmt.Errorf("invalid ffmpeg executable: %w", err)
}
installed, err := IsFFmpegInstalled()
if err != nil || !installed {
return nil, fmt.Errorf("ffmpeg is not installed")
}
results := make([]ConvertAudioResult, len(req.InputFiles))
var wg sync.WaitGroup
var mu sync.Mutex
for i, inputFile := range req.InputFiles {
wg.Add(1)
go func(idx int, inputFile string) {
defer wg.Done()
result := ConvertAudioResult{
InputFile: inputFile,
}
inputExt := strings.ToLower(filepath.Ext(inputFile))
baseName := strings.TrimSuffix(filepath.Base(inputFile), inputExt)
inputDir := filepath.Dir(inputFile)
outputFormatUpper := strings.ToUpper(req.OutputFormat)
outputDir := filepath.Join(inputDir, outputFormatUpper)
if err := os.MkdirAll(outputDir, 0755); err != nil {
result.Error = fmt.Sprintf("failed to create output directory: %v", err)
result.Success = false
mu.Lock()
results[idx] = result
mu.Unlock()
return
}
outputExt := "." + strings.ToLower(req.OutputFormat)
outputFile := filepath.Join(outputDir, baseName+outputExt)
if inputExt == outputExt {
result.Error = "Input and output formats are the same"
result.Success = false
mu.Lock()
results[idx] = result
mu.Unlock()
return
}
result.OutputFile = outputFile
var coverArtPath string
var lyrics string
var inputMetadata Metadata
inputMetadata, err = ExtractFullMetadataFromFile(inputFile)
if err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to extract metadata from %s: %v\n", inputFile, err)
}
coverArtPath, _ = ExtractCoverArt(inputFile)
lyrics, err = ExtractLyrics(inputFile)
if err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to extract lyrics from %s: %v\n", inputFile, err)
} else if lyrics != "" {
fmt.Printf("[FFmpeg] Lyrics extracted from %s: %d characters\n", inputFile, len(lyrics))
} else {
fmt.Printf("[FFmpeg] No lyrics found in %s\n", inputFile)
}
inputMetadata.Lyrics = lyrics
args := []string{
"-i", inputFile,
"-y",
}
switch req.OutputFormat {
case "mp3":
args = append(args,
"-codec:a", "libmp3lame",
"-b:a", req.Bitrate,
"-map", "0:a",
"-id3v2_version", "3",
)
case "m4a":
codec := req.Codec
if codec == "" {
codec = "aac"
}
if codec == "alac" {
args = append(args,
"-codec:a", "alac",
"-map", "0:a",
)
} else {
args = append(args,
"-codec:a", "aac",
"-b:a", req.Bitrate,
"-map", "0:a",
)
}
}
args = append(args, outputFile)
fmt.Printf("[FFmpeg] Converting: %s -> %s\n", inputFile, outputFile)
cmd := exec.Command(ffmpegPath, args...)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
result.Error = fmt.Sprintf("conversion failed: %s - %s", err.Error(), string(output))
result.Success = false
mu.Lock()
results[idx] = result
mu.Unlock()
if coverArtPath != "" {
os.Remove(coverArtPath)
}
return
}
if err := EmbedMetadataToConvertedFile(outputFile, inputMetadata, coverArtPath); err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to embed metadata: %v\n", err)
} else {
fmt.Printf("[FFmpeg] Metadata embedded successfully\n")
}
if lyrics != "" {
if err := EmbedLyricsOnlyUniversal(outputFile, lyrics); err != nil {
fmt.Printf("[FFmpeg] Warning: Failed to embed lyrics: %v\n", err)
} else {
fmt.Printf("[FFmpeg] Lyrics embedded successfully\n")
}
}
if coverArtPath != "" {
os.Remove(coverArtPath)
}
result.Success = true
fmt.Printf("[FFmpeg] Successfully converted: %s\n", outputFile)
mu.Lock()
results[idx] = result
mu.Unlock()
}(i, inputFile)
}
wg.Wait()
return results, nil
}
type AudioFileInfo struct {
Path string `json:"path"`
Filename string `json:"filename"`
Format string `json:"format"`
Size int64 `json:"size"`
}
func GetAudioFileInfo(filePath string) (*AudioFileInfo, error) {
info, err := os.Stat(filePath)
if err != nil {
return nil, err
}
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(filePath), "."))
return &AudioFileInfo{
Path: filePath,
Filename: filepath.Base(filePath),
Format: ext,
Size: info.Size(),
}, nil
}
+12
View File
@@ -0,0 +1,12 @@
//go:build !windows
// +build !windows
package backend
import (
"os/exec"
)
func setHideWindow(cmd *exec.Cmd) {
}
+15
View File
@@ -0,0 +1,15 @@
//go:build windows
// +build windows
package backend
import (
"os/exec"
"syscall"
)
func setHideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
}
}
+49
View File
@@ -0,0 +1,49 @@
package backend
import (
"context"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
func SelectMultipleFiles(ctx context.Context) ([]string, error) {
files, err := runtime.OpenMultipleFilesDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Audio Files",
Filters: []runtime.FileFilter{
{
DisplayName: "Audio Files (*.mp3, *.m4a, *.flac)",
Pattern: "*.mp3;*.m4a;*.flac",
},
{
DisplayName: "MP3 Files (*.mp3)",
Pattern: "*.mp3",
},
{
DisplayName: "M4A Files (*.m4a)",
Pattern: "*.m4a",
},
{
DisplayName: "FLAC Files (*.flac)",
Pattern: "*.flac",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
})
if err != nil {
return nil, err
}
return files, nil
}
func SelectOutputDirectory(ctx context.Context) (string, error) {
dir, err := runtime.OpenDirectoryDialog(ctx, runtime.OpenDialogOptions{
Title: "Select Output Directory",
})
if err != nil {
return "", err
}
return dir, nil
}
+467
View File
@@ -0,0 +1,467 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
)
type FileInfo struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
Size int64 `json:"size"`
Children []FileInfo `json:"children,omitempty"`
}
type AudioMetadata struct {
Title string `json:"title"`
Artist string `json:"artist"`
Album string `json:"album"`
AlbumArtist string `json:"album_artist"`
TrackNumber int `json:"track_number"`
DiscNumber int `json:"disc_number"`
Year string `json:"year"`
}
type RenamePreview struct {
OldPath string `json:"old_path"`
OldName string `json:"old_name"`
NewName string `json:"new_name"`
NewPath string `json:"new_path"`
Error string `json:"error,omitempty"`
Metadata AudioMetadata `json:"metadata"`
}
type RenameResult struct {
OldPath string `json:"old_path"`
NewPath string `json:"new_path"`
Success bool `json:"success"`
Error string `json:"error,omitempty"`
}
func ListDirectory(dirPath string) ([]FileInfo, error) {
entries, err := os.ReadDir(dirPath)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
var result []FileInfo
for _, entry := range entries {
info, err := entry.Info()
if err != nil {
continue
}
fileInfo := FileInfo{
Name: entry.Name(),
Path: filepath.Join(dirPath, entry.Name()),
IsDir: entry.IsDir(),
Size: info.Size(),
}
if entry.IsDir() {
children, err := ListDirectory(fileInfo.Path)
if err == nil {
fileInfo.Children = children
}
}
result = append(result, fileInfo)
}
return result, nil
}
func ListAudioFiles(dirPath string) ([]FileInfo, error) {
var result []FileInfo
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return nil
}
if info.IsDir() {
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext == ".flac" || ext == ".mp3" || ext == ".m4a" {
result = append(result, FileInfo{
Name: info.Name(),
Path: path,
IsDir: false,
Size: info.Size(),
})
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to walk directory: %w", err)
}
return result, nil
}
func ReadAudioMetadata(filePath string) (*AudioMetadata, error) {
if !fileExists(filePath) {
return nil, fmt.Errorf("file does not exist")
}
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".flac":
return readFlacMetadata(filePath)
case ".mp3":
return readMp3Metadata(filePath)
case ".m4a":
return readM4aMetadata(filePath)
default:
return nil, fmt.Errorf("unsupported file format: %s", ext)
}
}
func readFlacMetadata(filePath string) (*AudioMetadata, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return nil, fmt.Errorf("failed to parse FLAC file: %w", err)
}
metadata := &AudioMetadata{}
for _, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) != 2 {
continue
}
fieldName := strings.ToUpper(parts[0])
value := parts[1]
switch fieldName {
case "TITLE":
metadata.Title = value
case "ARTIST":
metadata.Artist = value
case "ALBUM":
metadata.Album = value
case "ALBUMARTIST":
metadata.AlbumArtist = value
case "TRACKNUMBER":
if num, err := strconv.Atoi(value); err == nil {
metadata.TrackNumber = num
}
case "DISCNUMBER":
if num, err := strconv.Atoi(value); err == nil {
metadata.DiscNumber = num
}
case "DATE", "YEAR":
metadata.Year = value
}
}
}
}
return metadata, nil
}
func readMp3Metadata(filePath string) (*AudioMetadata, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return nil, fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
metadata := &AudioMetadata{
Title: tag.Title(),
Artist: tag.Artist(),
Album: tag.Album(),
Year: tag.Year(),
}
if frames := tag.GetFrames("TPE2"); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
metadata.AlbumArtist = textFrame.Text
}
}
if frames := tag.GetFrames(tag.CommonID("Track number/Position in set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
trackStr := strings.Split(textFrame.Text, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num
}
}
}
if frames := tag.GetFrames(tag.CommonID("Part of a set")); len(frames) > 0 {
if textFrame, ok := frames[0].(id3v2.TextFrame); ok {
discStr := strings.Split(textFrame.Text, "/")[0]
if num, err := strconv.Atoi(discStr); err == nil {
metadata.DiscNumber = num
}
}
}
return metadata, nil
}
func readMetadataWithFFprobe(filePath string) (*AudioMetadata, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return nil, err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return nil, fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
filePath,
)
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return nil, err
}
var result struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &result); err != nil {
return nil, err
}
metadata := &AudioMetadata{}
allTags := make(map[string]string)
for _, stream := range result.Streams {
for key, value := range stream.Tags {
allTags[strings.ToLower(key)] = value
}
}
for key, value := range result.Format.Tags {
allTags[strings.ToLower(key)] = value
}
for key, value := range allTags {
switch key {
case "title":
metadata.Title = value
case "artist":
metadata.Artist = value
case "album":
metadata.Album = value
case "album_artist", "albumartist":
metadata.AlbumArtist = value
case "track":
trackStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(trackStr); err == nil {
metadata.TrackNumber = num
}
case "disc":
discStr := strings.Split(value, "/")[0]
if num, err := strconv.Atoi(discStr); err == nil {
metadata.DiscNumber = num
}
case "date", "year":
if metadata.Year == "" || len(value) > len(metadata.Year) {
metadata.Year = value
}
}
}
return metadata, nil
}
func readM4aMetadata(filePath string) (*AudioMetadata, error) {
metadata, err := readMetadataWithFFprobe(filePath)
if err != nil {
return &AudioMetadata{}, nil
}
return metadata, nil
}
func GenerateFilename(metadata *AudioMetadata, format string, ext string) string {
if metadata == nil {
return ""
}
result := format
year := metadata.Year
if len(year) >= 4 {
year = year[:4]
}
result = strings.ReplaceAll(result, "{title}", sanitizeFilenameForRename(metadata.Title))
result = strings.ReplaceAll(result, "{artist}", sanitizeFilenameForRename(metadata.Artist))
result = strings.ReplaceAll(result, "{album}", sanitizeFilenameForRename(metadata.Album))
result = strings.ReplaceAll(result, "{album_artist}", sanitizeFilenameForRename(metadata.AlbumArtist))
result = strings.ReplaceAll(result, "{year}", sanitizeFilenameForRename(year))
if metadata.TrackNumber > 0 {
result = strings.ReplaceAll(result, "{track}", fmt.Sprintf("%02d", metadata.TrackNumber))
} else {
result = strings.ReplaceAll(result, "{track}", "")
}
if metadata.DiscNumber > 0 {
result = strings.ReplaceAll(result, "{disc}", fmt.Sprintf("%d", metadata.DiscNumber))
} else {
result = strings.ReplaceAll(result, "{disc}", "")
}
result = strings.TrimSpace(result)
result = strings.Join(strings.Fields(result), " ")
result = strings.Trim(result, " -._")
if result == "" {
return ""
}
return result + ext
}
func sanitizeFilenameForRename(name string) string {
invalid := []string{"<", ">", ":", "\"", "/", "\\", "|", "?", "*"}
result := name
for _, char := range invalid {
result = strings.ReplaceAll(result, char, "")
}
return strings.TrimSpace(result)
}
func PreviewRename(files []string, format string) []RenamePreview {
var previews []RenamePreview
for _, filePath := range files {
preview := RenamePreview{
OldPath: filePath,
OldName: filepath.Base(filePath),
}
metadata, err := ReadAudioMetadata(filePath)
if err != nil {
preview.Error = err.Error()
previews = append(previews, preview)
continue
}
preview.Metadata = *metadata
ext := filepath.Ext(filePath)
newName := GenerateFilename(metadata, format, ext)
if newName == "" {
preview.Error = "Could not generate filename (missing metadata)"
previews = append(previews, preview)
continue
}
preview.NewName = newName
preview.NewPath = filepath.Join(filepath.Dir(filePath), newName)
previews = append(previews, preview)
}
return previews
}
func GetFileSizes(files []string) map[string]int64 {
result := make(map[string]int64)
for _, filePath := range files {
info, err := os.Stat(filePath)
if err == nil {
result[filePath] = info.Size()
}
}
return result
}
func RenameFiles(files []string, format string) []RenameResult {
var results []RenameResult
for _, filePath := range files {
result := RenameResult{
OldPath: filePath,
}
metadata, err := ReadAudioMetadata(filePath)
if err != nil {
result.Error = err.Error()
result.Success = false
results = append(results, result)
continue
}
ext := filepath.Ext(filePath)
newName := GenerateFilename(metadata, format, ext)
if newName == "" {
result.Error = "Could not generate filename (missing metadata)"
result.Success = false
results = append(results, result)
continue
}
newPath := filepath.Join(filepath.Dir(filePath), newName)
result.NewPath = newPath
if newPath != filePath {
if _, err := os.Stat(newPath); err == nil {
result.Error = "File already exists"
result.Success = false
results = append(results, result)
continue
}
}
if err := os.Rename(filePath, newPath); err != nil {
result.Error = err.Error()
result.Success = false
results = append(results, result)
continue
}
result.Success = true
results = append(results, result)
}
return results
}
+128 -20
View File
@@ -2,45 +2,153 @@ package backend
import (
"fmt"
"path/filepath"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// BuildExpectedFilename builds the expected filename based on track metadata and settings
func BuildExpectedFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int, useAlbumTrackNumber bool) string {
// Sanitize track name and artist name
func BuildExpectedFilename(trackName, artistName, albumName, albumArtist, releaseDate, filenameFormat string, includeTrackNumber bool, position, discNumber int, useAlbumTrackNumber bool) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
safeAlbum := sanitizeFilename(albumName)
safeAlbumArtist := sanitizeFilename(albumArtist)
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
var filename string
// Build base filename based on format
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
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)
// Add track number prefix if enabled
// Note: We can't determine the exact track number without fetching from API
// So we only add it if position > 0 (bulk download)
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
}
return filename + ".flac"
}
// sanitizeFilename removes invalid characters from filename
func sanitizeFilename(name string) string {
re := regexp.MustCompile(`[<>:"/\\|?*]`)
sanitized := re.ReplaceAllString(name, "_")
sanitized := strings.ReplaceAll(name, "/", " ")
re := regexp.MustCompile(`[<>:"\\|?*]`)
sanitized = re.ReplaceAllString(sanitized, " ")
var result strings.Builder
for _, r := range sanitized {
if r < 0x20 && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
if r == 0x7F {
continue
}
if unicode.IsControl(r) && r != 0x09 && r != 0x0A && r != 0x0D {
continue
}
result.WriteRune(r)
}
sanitized = result.String()
sanitized = strings.TrimSpace(sanitized)
sanitized = strings.Trim(sanitized, ". ")
re = regexp.MustCompile(`\s+`)
sanitized = re.ReplaceAllString(sanitized, " ")
re = regexp.MustCompile(`_+`)
sanitized = re.ReplaceAllString(sanitized, "_")
sanitized = strings.Trim(sanitized, "_ ")
if sanitized == "" {
return "Unknown"
}
if !utf8.ValidString(sanitized) {
sanitized = strings.ToValidUTF8(sanitized, "_")
}
return sanitized
}
func NormalizePath(folderPath string) string {
return strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
}
func SanitizeFolderPath(folderPath string) string {
normalizedPath := strings.ReplaceAll(folderPath, "/", string(filepath.Separator))
sep := string(filepath.Separator)
parts := strings.Split(normalizedPath, sep)
sanitizedParts := make([]string, 0, len(parts))
for i, part := range parts {
if i == 0 && len(part) == 2 && part[1] == ':' {
sanitizedParts = append(sanitizedParts, part)
continue
}
if i == 0 && part == "" {
sanitizedParts = append(sanitizedParts, part)
continue
}
sanitized := sanitizeFolderName(part)
if sanitized != "" {
sanitizedParts = append(sanitizedParts, sanitized)
}
}
return strings.Join(sanitizedParts, sep)
}
func sanitizeFolderName(name string) string {
return sanitizeFilename(name)
}
+29 -3
View File
@@ -14,7 +14,7 @@ func OpenFolderInExplorer(path string) error {
switch runtime.GOOS {
case "windows":
cmd = exec.Command("explorer", path)
case "darwin": // macOS
case "darwin":
cmd = exec.Command("open", path)
case "linux":
cmd = exec.Command("xdg-open", path)
@@ -26,7 +26,7 @@ func OpenFolderInExplorer(path string) error {
}
func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error) {
// If defaultPath is empty, use default music path
if defaultPath == "" {
defaultPath = GetDefaultMusicPath()
}
@@ -41,10 +41,36 @@ func SelectFolderDialog(ctx context.Context, defaultPath string) (string, error)
return "", err
}
// If user cancelled, selectedPath will be empty
if selectedPath == "" {
return "", nil
}
return selectedPath, nil
}
func SelectFileDialog(ctx context.Context) (string, error) {
options := wailsRuntime.OpenDialogOptions{
Title: "Select FLAC File for Analysis",
Filters: []wailsRuntime.FileFilter{
{
DisplayName: "FLAC Audio Files (*.flac)",
Pattern: "*.flac",
},
{
DisplayName: "All Files (*.*)",
Pattern: "*.*",
},
},
}
selectedFile, err := wailsRuntime.OpenFileDialog(ctx, options)
if err != nil {
return "", err
}
if selectedFile == "" {
return "", nil
}
return selectedFile, nil
}
+461
View File
@@ -0,0 +1,461 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
type LRCLibResponse struct {
ID int `json:"id"`
Name string `json:"name"`
TrackName string `json:"trackName"`
ArtistName string `json:"artistName"`
AlbumName string `json:"albumName"`
Duration float64 `json:"duration"`
Instrumental bool `json:"instrumental"`
PlainLyrics string `json:"plainLyrics"`
SyncedLyrics string `json:"syncedLyrics"`
}
type LyricsLine struct {
StartTimeMs string `json:"startTimeMs"`
Words string `json:"words"`
EndTimeMs string `json:"endTimeMs"`
}
type LyricsResponse struct {
Error bool `json:"error"`
SyncType string `json:"syncType"`
Lines []LyricsLine `json:"lines"`
}
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"`
}
type LyricsDownloadResponse 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 LyricsClient struct {
httpClient *http.Client
}
func NewLyricsClient() *LyricsClient {
return &LyricsClient{
httpClient: &http.Client{Timeout: 15 * time.Second},
}
}
func (c *LyricsClient) FetchLyricsWithMetadata(trackName, artistName string, duration int) (*LyricsResponse, error) {
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9nZXQ/YXJ0aXN0X25hbWU9")
apiURL := fmt.Sprintf("%s%s&track_name=%s",
string(apiBase),
url.QueryEscape(artistName),
url.QueryEscape(trackName))
if duration > 0 {
apiURL = fmt.Sprintf("%s&duration=%d", apiURL, duration)
}
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("failed to fetch from LRCLIB: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("LRCLIB returned status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read LRCLIB response: %v", err)
}
var lrcLibResp LRCLibResponse
if err := json.Unmarshal(body, &lrcLibResp); err != nil {
return nil, fmt.Errorf("failed to parse LRCLIB response: %v", err)
}
return c.convertLRCLibToLyricsResponse(&lrcLibResp), nil
}
func (c *LyricsClient) convertLRCLibToLyricsResponse(lrcLib *LRCLibResponse) *LyricsResponse {
resp := &LyricsResponse{
Error: false,
SyncType: "LINE_SYNCED",
Lines: []LyricsLine{},
}
lyricsText := lrcLib.SyncedLyrics
if lyricsText == "" {
lyricsText = lrcLib.PlainLyrics
resp.SyncType = "UNSYNCED"
}
if lyricsText == "" {
resp.Error = true
return resp
}
lines := strings.Split(lyricsText, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "[") && len(line) > 10 {
closeBracket := strings.Index(line, "]")
if closeBracket > 0 {
timestamp := line[1:closeBracket]
words := strings.TrimSpace(line[closeBracket+1:])
ms := lrcTimestampToMs(timestamp)
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: fmt.Sprintf("%d", ms),
Words: words,
})
continue
}
}
resp.Lines = append(resp.Lines, LyricsLine{
StartTimeMs: "",
Words: line,
})
}
return resp
}
func lrcTimestampToMs(timestamp string) int64 {
var minutes, seconds, centiseconds int64
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, &centiseconds)
if n >= 2 {
return minutes*60*1000 + seconds*1000 + centiseconds*10
}
return 0
}
func (c *LyricsClient) FetchLyricsFromLRCLibSearch(trackName, artistName string) (*LyricsResponse, error) {
query := fmt.Sprintf("%s %s", artistName, trackName)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9scmNsaWIubmV0L2FwaS9zZWFyY2g/cT0=")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(query))
resp, err := c.httpClient.Get(apiURL)
if err != nil {
return nil, fmt.Errorf("request failed: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("status %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read failed: %v", err)
}
var results []LRCLibResponse
if err := json.Unmarshal(body, &results); err != nil {
return nil, fmt.Errorf("parse failed: %v", err)
}
if len(results) == 0 {
return nil, fmt.Errorf("no results found")
}
var best *LRCLibResponse
for i := range results {
if results[i].SyncedLyrics != "" {
best = &results[i]
break
}
if best == nil && results[i].PlainLyrics != "" {
best = &results[i]
}
}
if best == nil {
best = &results[0]
}
return c.convertLRCLibToLyricsResponse(best), nil
}
func simplifyTrackName(name string) string {
if idx := strings.Index(name, "("); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
if idx := strings.Index(name, " - "); idx > 0 {
name = strings.TrimSpace(name[:idx])
}
return name
}
func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName string, duration int) (*LyricsResponse, string, error) {
resp, err := c.FetchLyricsWithMetadata(trackName, artistName, duration)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB", nil
}
fmt.Printf(" LRCLIB exact: %v\n", err)
resp, err = c.FetchLyricsFromLRCLibSearch(trackName, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB Search", nil
}
fmt.Printf(" LRCLIB search: %v\n", err)
simplifiedTrack := simplifyTrackName(trackName)
if simplifiedTrack != trackName {
fmt.Printf(" Trying simplified name: %s\n", simplifiedTrack)
resp, err = c.FetchLyricsWithMetadata(simplifiedTrack, artistName, duration)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB (simplified)", nil
}
resp, err = c.FetchLyricsFromLRCLibSearch(simplifiedTrack, artistName)
if err == nil && resp != nil && !resp.Error && len(resp.Lines) > 0 {
return resp, "LRCLIB Search (simplified)", nil
}
}
return nil, "", fmt.Errorf("lyrics not found in any source")
}
func (c *LyricsClient) ConvertToLRC(lyrics *LyricsResponse, trackName, artistName string) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("[ti:%s]\n", trackName))
sb.WriteString(fmt.Sprintf("[ar:%s]\n", artistName))
sb.WriteString("[by:SpotiFlac]\n")
sb.WriteString("\n")
for _, line := range lyrics.Lines {
if line.Words == "" {
continue
}
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()
}
func msToLRCTimestamp(msStr string) string {
var ms int64
fmt.Sscanf(msStr, "%d", &ms)
totalSeconds := ms / 1000
minutes := totalSeconds / 60
seconds := totalSeconds % 60
centiseconds := (ms % 1000) / 10
return fmt.Sprintf("[%02d:%02d.%02d]", minutes, seconds, centiseconds)
}
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
if strings.Contains(filenameFormat, "{") {
filename = filenameFormat
filename = strings.ReplaceAll(filename, "{title}", safeTitle)
filename = strings.ReplaceAll(filename, "{artist}", safeArtist)
filename = strings.ReplaceAll(filename, "{album}", safeAlbum)
filename = strings.ReplaceAll(filename, "{album_artist}", safeAlbumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
if position > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", position))
} else {
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
switch filenameFormat {
case "artist-title":
filename = fmt.Sprintf("%s - %s", safeArtist, safeTitle)
case "title":
filename = safeTitle
default:
filename = fmt.Sprintf("%s - %s", safeTitle, safeArtist)
}
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
}
return filename + ".lrc"
}
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{
Success: false,
Error: "Spotify ID is required",
}, fmt.Errorf("spotify ID is required")
}
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
} else {
outputDir = NormalizePath(outputDir)
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return &LyricsDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create output directory: %v", err),
}, err
}
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist"
}
filename := buildLyricsFilename(req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist, req.ReleaseDate, filenameFormat, req.TrackNumber, req.Position, req.DiscNumber)
filePath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &LyricsDownloadResponse{
Success: true,
Message: "Lyrics file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
audioDuration := 0
audioFile := findAudioFileForLyrics(outputDir, req.TrackName, req.ArtistName)
if audioFile != "" {
duration, err := GetAudioDuration(audioFile)
if err == nil && duration > 0 {
audioDuration = int(duration)
fmt.Printf("[DownloadLyrics] Found audio file, duration: %d seconds\n", audioDuration)
}
}
lyrics, _, err := c.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName, audioDuration)
if err != nil {
return &LyricsDownloadResponse{
Success: false,
Error: err.Error(),
}, err
}
lrcContent := c.ConvertToLRC(lyrics, req.TrackName, req.ArtistName)
if err := os.WriteFile(filePath, []byte(lrcContent), 0644); err != nil {
return &LyricsDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to write LRC file: %v", err),
}, err
}
return &LyricsDownloadResponse{
Success: true,
Message: "Lyrics downloaded successfully",
File: filePath,
}, nil
}
+857 -3
View File
@@ -1,10 +1,15 @@
package backend
import (
"encoding/json"
"fmt"
"os"
"os/exec"
pathfilepath "path/filepath"
"strconv"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"github.com/go-flac/flacpicture"
"github.com/go-flac/flacvorbis"
"github.com/go-flac/go-flac"
@@ -14,10 +19,18 @@ type Metadata struct {
Title string
Artist string
Album string
AlbumArtist string
Date string
ReleaseDate string
TrackNumber int
TotalTracks int
DiscNumber int
ISRC string
TotalDiscs int
URL string
Copyright string
Publisher string
Lyrics string
Description string
}
func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
@@ -45,17 +58,36 @@ func EmbedMetadata(filepath string, metadata Metadata, coverPath string) error {
if metadata.Album != "" {
_ = cmt.Add(flacvorbis.FIELD_ALBUM, metadata.Album)
}
if metadata.AlbumArtist != "" {
_ = cmt.Add("ALBUMARTIST", metadata.AlbumArtist)
}
if metadata.Date != "" {
_ = cmt.Add(flacvorbis.FIELD_DATE, metadata.Date)
}
if metadata.TrackNumber > 0 {
_ = cmt.Add(flacvorbis.FIELD_TRACKNUMBER, strconv.Itoa(metadata.TrackNumber))
}
if metadata.TotalTracks > 0 {
_ = cmt.Add("TOTALTRACKS", strconv.Itoa(metadata.TotalTracks))
}
if metadata.DiscNumber > 0 {
_ = cmt.Add("DISCNUMBER", strconv.Itoa(metadata.DiscNumber))
}
if metadata.ISRC != "" {
_ = cmt.Add(flacvorbis.FIELD_ISRC, metadata.ISRC)
if metadata.TotalDiscs > 0 {
_ = cmt.Add("TOTALDISCS", strconv.Itoa(metadata.TotalDiscs))
}
if metadata.Copyright != "" {
_ = cmt.Add("COPYRIGHT", metadata.Copyright)
}
if metadata.Publisher != "" {
_ = cmt.Add("PUBLISHER", metadata.Publisher)
}
if metadata.Description != "" {
_ = cmt.Add("DESCRIPTION", metadata.Description)
}
if metadata.Lyrics != "" {
_ = cmt.Add("LYRICS", metadata.Lyrics)
}
cmtBlock := cmt.Marshal()
@@ -111,3 +143,825 @@ func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
func extractYear(releaseDate string) string {
if releaseDate == "" {
return ""
}
if len(releaseDate) >= 4 {
return releaseDate[:4]
}
return releaseDate
}
func EmbedLyricsOnly(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
f, err := flac.ParseFile(filepath)
if err != nil {
return fmt.Errorf("failed to parse FLAC file: %w", err)
}
var cmtIdx = -1
var existingCmt *flacvorbis.MetaDataBlockVorbisComment
for idx, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmtIdx = idx
existingCmt, err = flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
existingCmt = nil
}
break
}
}
cmt := flacvorbis.New()
if existingCmt != nil {
for _, comment := range existingCmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) == 2 {
fieldName := strings.ToUpper(parts[0])
if fieldName != "LYRICS" && fieldName != "UNSYNCEDLYRICS" && fieldName != "SYNCEDLYRICS" {
_ = cmt.Add(parts[0], parts[1])
}
}
}
}
_ = cmt.Add("LYRICS", lyrics)
cmtBlock := cmt.Marshal()
if cmtIdx < 0 {
f.Meta = append(f.Meta, &cmtBlock)
} else {
f.Meta[cmtIdx] = &cmtBlock
}
if err := f.Save(filepath); err != nil {
return fmt.Errorf("failed to save FLAC file: %w", err)
}
return nil
}
func ExtractCoverArt(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".mp3":
return extractCoverFromMp3(filePath)
case ".m4a", ".flac":
return extractCoverFromM4AOrFlac(filePath)
default:
return "", fmt.Errorf("unsupported file format: %s", ext)
}
}
func extractCoverFromMp3(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return "", fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
pictures := tag.GetFrames(tag.CommonID("Attached picture"))
if len(pictures) == 0 {
return "", fmt.Errorf("no cover art found")
}
pic, ok := pictures[0].(id3v2.PictureFrame)
if !ok {
return "", fmt.Errorf("invalid picture frame")
}
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
if _, err := tmpFile.Write(pic.Picture); err != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("failed to write cover art: %w", err)
}
return tmpFile.Name(), nil
}
func extractCoverFromM4AOrFlac(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
if ext == ".flac" {
f, err := flac.ParseFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, block := range f.Meta {
if block.Type == flac.Picture {
pic, err := flacpicture.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
tmpFile, err := os.CreateTemp("", "cover-*.jpg")
if err != nil {
return "", fmt.Errorf("failed to create temp file: %w", err)
}
defer tmpFile.Close()
if _, err := tmpFile.Write(pic.ImageData); err != nil {
os.Remove(tmpFile.Name())
return "", fmt.Errorf("failed to write cover art: %w", err)
}
return tmpFile.Name(), nil
}
}
return "", fmt.Errorf("no cover art found")
}
return "", nil
}
func ExtractLyrics(filePath string) (string, error) {
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".mp3":
return extractLyricsFromMp3(filePath)
case ".flac":
return extractLyricsFromFlac(filePath)
case ".m4a":
return "", nil
default:
return "", fmt.Errorf("unsupported file format: %s", ext)
}
}
func extractLyricsFromMp3(filePath string) (string, error) {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return "", fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
usltFrames := tag.GetFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
if len(usltFrames) == 0 {
fmt.Printf("[ExtractLyrics] No USLT frames found in MP3: %s\n", filePath)
return "", nil
}
uslt, ok := usltFrames[0].(id3v2.UnsynchronisedLyricsFrame)
if !ok {
fmt.Printf("[ExtractLyrics] USLT frame type assertion failed in MP3: %s\n", filePath)
return "", nil
}
if uslt.Lyrics == "" {
fmt.Printf("[ExtractLyrics] USLT frame has empty lyrics in MP3: %s\n", filePath)
return "", nil
}
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from MP3: %s (%d characters)\n", filePath, len(uslt.Lyrics))
return uslt.Lyrics, nil
}
func extractLyricsFromFlac(filePath string) (string, error) {
f, err := flac.ParseFile(filePath)
if err != nil {
return "", fmt.Errorf("failed to parse FLAC file: %w", err)
}
for _, block := range f.Meta {
if block.Type == flac.VorbisComment {
cmt, err := flacvorbis.ParseFromMetaDataBlock(*block)
if err != nil {
continue
}
for _, comment := range cmt.Comments {
parts := strings.SplitN(comment, "=", 2)
if len(parts) == 2 {
fieldName := strings.ToUpper(parts[0])
if fieldName == "LYRICS" || fieldName == "UNSYNCEDLYRICS" {
lyrics := parts[1]
fmt.Printf("[ExtractLyrics] Successfully extracted lyrics from FLAC: %s (%d characters)\n", filePath, len(lyrics))
return lyrics, nil
}
}
}
}
}
fmt.Printf("[ExtractLyrics] No lyrics found in FLAC: %s\n", filePath)
return "", nil
}
func EmbedCoverArtOnly(filePath string, coverPath string) error {
if coverPath == "" || !fileExists(coverPath) {
return nil
}
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".mp3":
return embedCoverToMp3(filePath, coverPath)
case ".m4a":
return nil
default:
return fmt.Errorf("unsupported file format: %s", ext)
}
}
func embedCoverToMp3(filePath string, coverPath string) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
tag.DeleteFrames(tag.CommonID("Attached picture"))
artwork, err := os.ReadFile(coverPath)
if err != nil {
return fmt.Errorf("failed to read cover art: %w", err)
}
pic := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Front cover",
Picture: artwork,
}
tag.AddAttachedPicture(pic)
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
return nil
}
func EmbedLyricsOnlyMP3(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
if err != nil {
fmt.Printf("[EmbedLyricsOnlyMP3] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
validatedLyrics = lyrics
}
lyrics = validatedLyrics
tag, err := id3v2.Open(filepath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
tag.DeleteFrames(tag.CommonID("Unsynchronised lyrics/text transcription"))
usltFrame := id3v2.UnsynchronisedLyricsFrame{
Encoding: id3v2.EncodingUTF8,
Language: "eng",
ContentDescriptor: "",
Lyrics: lyrics,
}
tag.AddUnsynchronisedLyricsFrame(usltFrame)
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
return nil
}
func embedLyricsToM4A(filepath string, lyrics string) error {
validatedLyrics, err := validateLyricsDuration(lyrics, filepath)
if err != nil {
fmt.Printf("[embedLyricsToM4A] Warning: Failed to validate lyrics duration: %v, using original lyrics\n", err)
validatedLyrics = lyrics
}
lyrics = validatedLyrics
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return fmt.Errorf("ffmpeg not found: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return fmt.Errorf("invalid ffmpeg executable: %w", err)
}
tmpOutputFile := strings.TrimSuffix(filepath, pathfilepath.Ext(filepath)) + ".tmp" + pathfilepath.Ext(filepath)
defer func() {
if _, err := os.Stat(tmpOutputFile); err == nil {
os.Remove(tmpOutputFile)
}
}()
cmd := exec.Command(ffmpegPath,
"-i", filepath,
"-map", "0",
"-map_metadata", "0",
"-metadata", "lyrics-eng="+lyrics,
"-metadata", "lyrics="+lyrics,
"-codec", "copy",
"-f", "ipod",
"-y",
tmpOutputFile,
)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
fmt.Printf("[FFmpeg] Error embedding lyrics to M4A: %s\n", string(output))
return fmt.Errorf("ffmpeg failed to embed lyrics: %s - %w", string(output), err)
}
if err := os.Rename(tmpOutputFile, filepath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
fmt.Printf("[FFmpeg] Lyrics embedded to M4A successfully: %d characters\n", len(lyrics))
return nil
}
func EmbedLyricsOnlyUniversal(filepath string, lyrics string) error {
if lyrics == "" {
return nil
}
ext := strings.ToLower(pathfilepath.Ext(filepath))
switch ext {
case ".mp3":
return EmbedLyricsOnlyMP3(filepath, lyrics)
case ".flac":
return EmbedLyricsOnly(filepath, lyrics)
case ".m4a":
return embedLyricsToM4A(filepath, lyrics)
default:
return fmt.Errorf("unsupported file format for lyrics embedding: %s", ext)
}
}
func GetAudioDuration(filepath string) (float64, error) {
ext := strings.ToLower(pathfilepath.Ext(filepath))
if ext == ".flac" {
duration, err := getFlacDuration(filepath)
if err == nil && duration > 0 {
return duration, nil
}
}
return getDurationWithFFprobe(filepath)
}
func getFlacDuration(filepath string) (float64, error) {
f, err := flac.ParseFile(filepath)
if err != nil {
return 0, err
}
if len(f.Meta) > 0 {
streamInfo := f.Meta[0]
if streamInfo.Type == flac.StreamInfo {
data := streamInfo.Data
if len(data) >= 18 {
sampleRate := uint32(data[10])<<12 | uint32(data[11])<<4 | uint32(data[12])>>4
totalSamples := uint64(data[13]&0x0F)<<32 |
uint64(data[14])<<24 |
uint64(data[15])<<16 |
uint64(data[16])<<8 |
uint64(data[17])
if sampleRate > 0 {
return float64(totalSamples) / float64(sampleRate), nil
}
}
}
}
return 0, fmt.Errorf("could not extract duration from FLAC file")
}
func getDurationWithFFprobe(filepath string) (float64, error) {
ffprobePath, err := GetFFprobePath()
if err != nil {
return 0, err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return 0, fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
filepath,
)
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return 0, err
}
var result struct {
Format struct {
Duration string `json:"duration"`
} `json:"format"`
}
if err := json.Unmarshal(output, &result); err != nil {
return 0, err
}
if result.Format.Duration == "" {
return 0, fmt.Errorf("duration not found in ffprobe output")
}
duration, err := strconv.ParseFloat(result.Format.Duration, 64)
if err != nil {
return 0, err
}
return duration, nil
}
func validateLyricsDuration(lyrics string, filepath string) (string, error) {
duration, err := GetAudioDuration(filepath)
if err != nil {
fmt.Printf("[ValidateLyrics] Warning: Could not get audio duration: %v, skipping validation\n", err)
return lyrics, nil
}
if duration <= 0 {
fmt.Printf("[ValidateLyrics] Warning: Invalid duration (%f seconds), skipping validation\n", duration)
return lyrics, nil
}
durationMs := int64(duration * 1000)
lines := strings.Split(lyrics, "\n")
var validLines []string
for _, line := range lines {
trimmedLine := strings.TrimSpace(line)
if trimmedLine == "" {
validLines = append(validLines, line)
continue
}
if strings.HasPrefix(trimmedLine, "[") {
if strings.Index(trimmedLine, ":") > 0 {
validLines = append(validLines, line)
continue
}
closeBracket := strings.Index(trimmedLine, "]")
if closeBracket > 0 {
timestampStr := trimmedLine[1:closeBracket]
ms := parseLRCTimestamp(timestampStr)
if ms >= 0 && ms <= durationMs {
validLines = append(validLines, line)
} else {
fmt.Printf("[ValidateLyrics] Filtered out line with timestamp %s (exceeds duration %d ms): %s\n", timestampStr, durationMs, trimmedLine)
}
} else {
validLines = append(validLines, line)
}
} else {
validLines = append(validLines, line)
}
}
return strings.Join(validLines, "\n"), nil
}
func parseLRCTimestamp(timestamp string) int64 {
var minutes, seconds, centiseconds int64
n, _ := fmt.Sscanf(timestamp, "%d:%d.%d", &minutes, &seconds, &centiseconds)
if n >= 2 {
return minutes*60*1000 + seconds*1000 + centiseconds*10
}
return -1
}
func ExtractFullMetadataFromFile(filePath string) (Metadata, error) {
var metadata Metadata
ffprobePath, err := GetFFprobePath()
if err != nil {
return metadata, err
}
if err := ValidateExecutable(ffprobePath); err != nil {
return metadata, fmt.Errorf("invalid ffprobe executable: %w", err)
}
cmd := exec.Command(ffprobePath,
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
filePath,
)
setHideWindow(cmd)
output, err := cmd.Output()
if err != nil {
return metadata, err
}
var result struct {
Format struct {
Tags map[string]string `json:"tags"`
} `json:"format"`
Streams []struct {
Tags map[string]string `json:"tags"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &result); err != nil {
return metadata, err
}
allTags := make(map[string]string)
for _, stream := range result.Streams {
for key, value := range stream.Tags {
allTags[strings.ToLower(key)] = value
}
}
for key, value := range result.Format.Tags {
allTags[strings.ToLower(key)] = value
}
for key, value := range allTags {
switch key {
case "title":
metadata.Title = value
case "artist":
metadata.Artist = value
case "album":
metadata.Album = value
case "album_artist", "albumartist":
metadata.AlbumArtist = value
case "date", "year":
if metadata.Date == "" || len(value) > len(metadata.Date) {
metadata.Date = value
}
case "track":
parts := strings.Split(value, "/")
if len(parts) > 0 {
if num, err := strconv.Atoi(parts[0]); err == nil {
metadata.TrackNumber = num
}
}
if len(parts) > 1 {
if num, err := strconv.Atoi(parts[1]); err == nil {
metadata.TotalTracks = num
}
}
case "disc":
parts := strings.Split(value, "/")
if len(parts) > 0 {
if num, err := strconv.Atoi(parts[0]); err == nil {
metadata.DiscNumber = num
}
}
if len(parts) > 1 {
if num, err := strconv.Atoi(parts[1]); err == nil {
metadata.TotalDiscs = num
}
}
case "copyright", "tcop":
metadata.Copyright = value
case "publisher", "tpub", "label":
metadata.Publisher = value
case "url":
metadata.URL = value
case "description", "comment":
if metadata.Description == "" {
metadata.Description = value
}
}
}
return metadata, nil
}
func EmbedMetadataToConvertedFile(filePath string, metadata Metadata, coverPath string) error {
ext := strings.ToLower(pathfilepath.Ext(filePath))
switch ext {
case ".flac":
return EmbedMetadata(filePath, metadata, coverPath)
case ".mp3":
return embedMetadataToMP3(filePath, metadata, coverPath)
case ".m4a":
return embedMetadataToM4A(filePath, metadata, coverPath)
default:
return fmt.Errorf("unsupported file format: %s", ext)
}
}
func embedMetadataToMP3(filePath string, metadata Metadata, coverPath string) error {
tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true})
if err != nil {
return fmt.Errorf("failed to open MP3 file: %w", err)
}
defer tag.Close()
tag.DeleteFrames("TXXX")
if metadata.Title != "" {
tag.SetTitle(metadata.Title)
}
if metadata.Artist != "" {
tag.SetArtist(metadata.Artist)
}
if metadata.Album != "" {
tag.SetAlbum(metadata.Album)
}
if metadata.Date != "" {
year := metadata.Date
if len(year) >= 4 {
year = year[:4]
}
tag.SetYear(year)
}
if metadata.AlbumArtist != "" {
tag.DeleteFrames("TPE2")
tag.AddTextFrame("TPE2", id3v2.EncodingUTF8, metadata.AlbumArtist)
}
if metadata.TrackNumber > 0 {
tag.DeleteFrames(tag.CommonID("Track number/Position in set"))
trackStr := strconv.Itoa(metadata.TrackNumber)
if metadata.TotalTracks > 0 {
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
}
tag.AddTextFrame(tag.CommonID("Track number/Position in set"), id3v2.EncodingUTF8, trackStr)
}
if metadata.DiscNumber > 0 {
tag.DeleteFrames(tag.CommonID("Part of a set"))
discStr := strconv.Itoa(metadata.DiscNumber)
if metadata.TotalDiscs > 0 {
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
}
tag.AddTextFrame(tag.CommonID("Part of a set"), id3v2.EncodingUTF8, discStr)
}
if metadata.Copyright != "" {
tag.DeleteFrames("TCOP")
tag.AddTextFrame("TCOP", id3v2.EncodingUTF8, metadata.Copyright)
}
if metadata.Publisher != "" {
tag.DeleteFrames("TPUB")
tag.AddTextFrame("TPUB", id3v2.EncodingUTF8, metadata.Publisher)
}
if coverPath != "" && fileExists(coverPath) {
tag.DeleteFrames(tag.CommonID("Attached picture"))
artwork, err := os.ReadFile(coverPath)
if err == nil {
pic := id3v2.PictureFrame{
Encoding: id3v2.EncodingUTF8,
MimeType: "image/jpeg",
PictureType: id3v2.PTFrontCover,
Description: "Cover",
Picture: artwork,
}
tag.AddAttachedPicture(pic)
} else {
fmt.Printf("[EmbedMetadataToMP3] Warning: Failed to read cover art file: %v\n", err)
}
}
if err := tag.Save(); err != nil {
return fmt.Errorf("failed to save MP3 tags: %w", err)
}
return nil
}
func embedMetadataToM4A(filePath string, metadata Metadata, coverPath string) error {
ffmpegPath, err := GetFFmpegPath()
if err != nil {
return fmt.Errorf("ffmpeg not found: %w", err)
}
if err := ValidateExecutable(ffmpegPath); err != nil {
return fmt.Errorf("invalid ffmpeg executable: %w", err)
}
args := []string{
"-i", filePath,
"-y",
}
if coverPath != "" && fileExists(coverPath) {
args = append(args, "-i", coverPath)
args = append(args, "-map", "0:a", "-map", "1", "-c:a", "copy", "-c:v", "copy", "-disposition:v:0", "attached_pic")
} else {
args = append(args, "-map", "0", "-codec", "copy")
}
if metadata.Title != "" {
args = append(args, "-metadata", "title="+metadata.Title)
}
if metadata.Artist != "" {
args = append(args, "-metadata", "artist="+metadata.Artist)
}
if metadata.Album != "" {
args = append(args, "-metadata", "album="+metadata.Album)
}
if metadata.AlbumArtist != "" {
args = append(args, "-metadata", "album_artist="+metadata.AlbumArtist)
}
if metadata.Date != "" {
args = append(args, "-metadata", "date="+metadata.Date)
}
if metadata.TrackNumber > 0 {
trackStr := strconv.Itoa(metadata.TrackNumber)
if metadata.TotalTracks > 0 {
trackStr = fmt.Sprintf("%d/%d", metadata.TrackNumber, metadata.TotalTracks)
}
args = append(args, "-metadata", "track="+trackStr)
}
if metadata.DiscNumber > 0 {
discStr := strconv.Itoa(metadata.DiscNumber)
if metadata.TotalDiscs > 0 {
discStr = fmt.Sprintf("%d/%d", metadata.DiscNumber, metadata.TotalDiscs)
}
args = append(args, "-metadata", "disk="+discStr)
}
if metadata.Copyright != "" {
args = append(args, "-metadata", "copyright="+metadata.Copyright)
}
if metadata.Publisher != "" {
args = append(args, "-metadata", "publisher="+metadata.Publisher)
}
tmpOutputFile := strings.TrimSuffix(filePath, pathfilepath.Ext(filePath)) + ".tmp" + pathfilepath.Ext(filePath)
defer func() {
if _, err := os.Stat(tmpOutputFile); err == nil {
os.Remove(tmpOutputFile)
}
}()
args = append(args, "-f", "ipod", tmpOutputFile)
cmd := exec.Command(ffmpegPath, args...)
setHideWindow(cmd)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("ffmpeg failed to embed metadata: %s - %w", string(output), err)
}
if err := os.Rename(tmpOutputFile, filePath); err != nil {
return fmt.Errorf("failed to replace original file: %w", err)
}
return nil
}
+297 -13
View File
@@ -7,7 +7,32 @@ import (
"time"
)
// Global progress tracker
type DownloadStatus string
const (
StatusQueued DownloadStatus = "queued"
StatusDownloading DownloadStatus = "downloading"
StatusCompleted DownloadStatus = "completed"
StatusFailed DownloadStatus = "failed"
StatusSkipped DownloadStatus = "skipped"
)
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"`
Status DownloadStatus `json:"status"`
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"`
}
var (
currentProgress float64
currentProgressLock sync.RWMutex
@@ -15,16 +40,35 @@ var (
downloadingLock sync.RWMutex
currentSpeed float64
speedLock sync.RWMutex
downloadQueue []DownloadItem
downloadQueueLock sync.RWMutex
currentItemID string
currentItemLock sync.RWMutex
totalDownloaded float64
totalDownloadedLock sync.RWMutex
sessionStartTime int64
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"`
}
// GetDownloadProgress returns current download progress
type DownloadQueueInfo struct {
IsDownloading bool `json:"is_downloading"`
Queue []DownloadItem `json:"queue"`
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"`
}
func GetDownloadProgress() ProgressInfo {
downloadingLock.RLock()
downloading := isDownloading
@@ -45,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
@@ -80,6 +120,7 @@ type ProgressWriter struct {
startTime int64
lastTime int64
lastBytes int64
itemID string
}
func NewProgressWriter(writer io.Writer) *ProgressWriter {
@@ -91,9 +132,16 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter {
startTime: now,
lastTime: now,
lastBytes: 0,
itemID: "",
}
}
func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
pw := NewProgressWriter(writer)
pw.itemID = itemID
return pw
}
func getCurrentTimeMillis() int64 {
return time.Now().UnixMilli()
}
@@ -102,26 +150,28 @@ 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
if timeDiff > 0 {
speedMBps := (bytesDiff / (1024 * 1024)) / timeDiff
speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
SetDownloadSpeed(speedMBps)
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
} else {
fmt.Printf("\rDownloaded: %.2f MB", mbDownloaded)
}
// Update global progress
SetDownloadProgress(mbDownloaded)
if pw.itemID != "" {
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
}
pw.lastPrinted = pw.total
pw.lastTime = now
pw.lastBytes = pw.total
@@ -133,3 +183,237 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
func AddToQueue(id, trackName, artistName, albumName, isrc string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
item := DownloadItem{
ID: id,
TrackName: trackName,
ArtistName: artistName,
AlbumName: albumName,
ISRC: isrc,
Status: StatusQueued,
Progress: 0,
TotalSize: 0,
Speed: 0,
StartTime: 0,
EndTime: 0,
}
downloadQueue = append(downloadQueue, item)
sessionStartLock.Lock()
if sessionStartTime == 0 {
sessionStartTime = time.Now().Unix()
}
sessionStartLock.Unlock()
}
func StartDownloadItem(id string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Status = StatusDownloading
downloadQueue[i].StartTime = time.Now().Unix()
downloadQueue[i].Progress = 0
break
}
}
currentItemLock.Lock()
currentItemID = id
currentItemLock.Unlock()
}
func UpdateItemProgress(id string, progress, speed float64) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Progress = progress
downloadQueue[i].Speed = speed
break
}
}
}
func GetCurrentItemID() string {
currentItemLock.RLock()
defer currentItemLock.RUnlock()
return currentItemID
}
func CompleteDownloadItem(id, filePath string, finalSize float64) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Status = StatusCompleted
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].FilePath = filePath
downloadQueue[i].Progress = finalSize
downloadQueue[i].TotalSize = finalSize
totalDownloadedLock.Lock()
totalDownloaded += finalSize
totalDownloadedLock.Unlock()
break
}
}
}
func FailDownloadItem(id, errorMsg string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Status = StatusFailed
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].ErrorMessage = errorMsg
break
}
}
}
func SkipDownloadItem(id, filePath string) {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].ID == id {
downloadQueue[i].Status = StatusSkipped
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].FilePath = filePath
break
}
}
}
func GetDownloadQueue() DownloadQueueInfo {
ResetSessionIfComplete()
downloadQueueLock.RLock()
defer downloadQueueLock.RUnlock()
downloadingLock.RLock()
downloading := isDownloading
downloadingLock.RUnlock()
speedLock.RLock()
speed := currentSpeed
speedLock.RUnlock()
totalDownloadedLock.RLock()
total := totalDownloaded
totalDownloadedLock.RUnlock()
sessionStartLock.RLock()
sessionStart := sessionStartTime
sessionStartLock.RUnlock()
var queued, completed, failed, skipped int
for _, item := range downloadQueue {
switch item.Status {
case StatusQueued:
queued++
case StatusCompleted:
completed++
case StatusFailed:
failed++
case StatusSkipped:
skipped++
}
}
queueCopy := make([]DownloadItem, len(downloadQueue))
copy(queueCopy, downloadQueue)
return DownloadQueueInfo{
IsDownloading: downloading,
Queue: queueCopy,
CurrentSpeed: speed,
TotalDownloaded: total,
SessionStartTime: sessionStart,
QueuedCount: queued,
CompletedCount: completed,
FailedCount: failed,
SkippedCount: skipped,
}
}
func ClearDownloadQueue() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
newQueue := make([]DownloadItem, 0)
for _, item := range downloadQueue {
if item.Status == StatusQueued || item.Status == StatusDownloading {
newQueue = append(newQueue, item)
}
}
downloadQueue = newQueue
}
func ClearAllDownloads() {
downloadQueueLock.Lock()
downloadQueue = []DownloadItem{}
downloadQueueLock.Unlock()
totalDownloadedLock.Lock()
totalDownloaded = 0
totalDownloadedLock.Unlock()
sessionStartLock.Lock()
sessionStartTime = 0
sessionStartLock.Unlock()
currentItemLock.Lock()
currentItemID = ""
currentItemLock.Unlock()
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
func CancelAllQueuedItems() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].Status == StatusQueued {
downloadQueue[i].Status = StatusSkipped
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].ErrorMessage = "Cancelled"
}
}
}
func ResetSessionIfComplete() {
downloadQueueLock.RLock()
hasActiveOrQueued := false
for _, item := range downloadQueue {
if item.Status == StatusQueued || item.Status == StatusDownloading {
hasActiveOrQueued = true
break
}
}
downloadQueueLock.RUnlock()
if !hasActiveOrQueued {
sessionStartLock.Lock()
sessionStartTime = 0
sessionStartLock.Unlock()
totalDownloadedLock.Lock()
totalDownloaded = 0
totalDownloadedLock.Unlock()
}
}
+119 -77
View File
@@ -8,6 +8,8 @@ import (
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
@@ -76,7 +78,7 @@ func NewQobuzDownloader() *QobuzDownloader {
}
func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
// Decode base64 API URL
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
url := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, q.appID)
@@ -91,8 +93,23 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
}
var searchResp QobuzSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &searchResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
if len(searchResp.Tracks.Items) == 0 {
@@ -103,17 +120,19 @@ func (q *QobuzDownloader) SearchByISRC(isrc string) (*QobuzTrack, error) {
}
func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, error) {
// Map quality to Qobuz quality code
// Qobuz uses: 5 (MP3 320), 6 (FLAC 16-bit), 7 (FLAC 24-bit), 27 (Hi-Res)
qualityCode := "27" // Default to Hi-Res
fmt.Printf("Getting download URL for track ID: %d\n", trackID)
qualityCode := quality
if qualityCode == "" {
qualityCode = "6"
}
fmt.Printf("Getting download URL for track ID: %d with requested quality: %s\n", trackID, qualityCode)
fmt.Printf("Quality codes: 6=FLAC 16-bit, 7=FLAC 24-bit, 27=Hi-Res\n")
// 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 {
@@ -132,7 +151,6 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
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)
@@ -149,12 +167,25 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return "", fmt.Errorf("API returned empty response")
}
fmt.Printf("Fallback API response: %s\n", string(body))
var streamResp QobuzStreamResponse
if err := json.Unmarshal(body, &streamResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return "", fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
if streamResp.URL == "" {
@@ -167,7 +198,12 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string,
func (q *QobuzDownloader) DownloadFile(url, filepath string) error {
fmt.Println("Starting file download...")
resp, err := q.client.Get(url)
downloadClient := &http.Client{
Timeout: 5 * time.Minute,
}
resp, err := downloadClient.Get(url)
if err != nil {
return fmt.Errorf("failed to download file: %w", err)
}
@@ -185,14 +221,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
}
@@ -222,70 +257,78 @@ 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
// Build base filename based on format
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default: // "title-artist"
filename = fmt.Sprintf("%s - %s", title, artist)
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
}
// Add track number prefix if enabled
if includeTrackNumber && position > 0 {
// Use album track number if in album folder structure, otherwise use playlist position
numberToUse := position
if useAlbumTrackNumber && trackNumber > 0 {
numberToUse = trackNumber
year := ""
if len(releaseDate) >= 4 {
year = releaseDate[:4]
}
if strings.Contains(format, "{") {
filename = format
filename = strings.ReplaceAll(filename, "{title}", title)
filename = strings.ReplaceAll(filename, "{artist}", artist)
filename = strings.ReplaceAll(filename, "{album}", album)
filename = strings.ReplaceAll(filename, "{album_artist}", albumArtist)
filename = strings.ReplaceAll(filename, "{year}", year)
if discNumber > 0 {
filename = strings.ReplaceAll(filename, "{disc}", fmt.Sprintf("%d", discNumber))
} else {
filename = strings.ReplaceAll(filename, "{disc}", "")
}
if numberToUse > 0 {
filename = strings.ReplaceAll(filename, "{track}", fmt.Sprintf("%02d", numberToUse))
} else {
filename = regexp.MustCompile(`\{track\}\.\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*-\s*`).ReplaceAllString(filename, "")
filename = regexp.MustCompile(`\{track\}\s*`).ReplaceAllString(filename, "")
}
} else {
switch format {
case "artist-title":
filename = fmt.Sprintf("%s - %s", artist, title)
case "title":
filename = title
default:
filename = fmt.Sprintf("%s - %s", title, artist)
}
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
filename = fmt.Sprintf("%02d. %s", numberToUse, filename)
}
return filename + ".flac"
}
func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName string, useAlbumTrackNumber bool) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", isrc)
func (q *QobuzDownloader) DownloadByISRC(deezerISRC, outputDir, quality, filenameFormat string, includeTrackNumber bool, position int, spotifyTrackName, spotifyArtistName, spotifyAlbumName, spotifyAlbumArtist, spotifyReleaseDate string, useAlbumTrackNumber bool, spotifyCoverURL string, embedMaxQualityCover bool, spotifyTrackNumber, spotifyDiscNumber, spotifyTotalTracks int, spotifyTotalDiscs int, spotifyCopyright, spotifyPublisher, spotifyURL string) (string, error) {
fmt.Printf("Fetching track info for ISRC: %s\n", deezerISRC)
// Create output directory if it doesn't exist
if outputDir != "." {
if err := os.MkdirAll(outputDir, 0755); err != nil {
return "", fmt.Errorf("failed to create output directory: %w", err)
}
}
track, err := q.SearchByISRC(isrc)
track, err := q.SearchByISRC(deezerISRC)
if err != nil {
return "", err
}
// Use Spotify metadata if provided, otherwise fallback to Qobuz metadata
artists := spotifyArtistName
trackTitle := spotifyTrackName
albumTitle := spotifyAlbumName
if artists == "" {
artists = track.Performer.Name
if track.Album.Artist.Name != "" {
artists = track.Album.Artist.Name
}
}
if trackTitle == "" {
trackTitle = track.Title
if track.Version != "" && track.Version != "null" {
trackTitle = fmt.Sprintf("%s (%s)", track.Title, track.Version)
}
}
if albumTitle == "" {
albumTitle = track.Album.Title
}
fmt.Printf("Found track: %s - %s\n", artists, trackTitle)
fmt.Printf("Album: %s\n", albumTitle)
@@ -305,7 +348,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] + "..."
@@ -314,9 +356,10 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
safeArtist := sanitizeFilename(artists)
safeTitle := sanitizeFilename(trackTitle)
safeAlbum := sanitizeFilename(albumTitle)
safeAlbumArtist := sanitizeFilename(spotifyAlbumArtist)
// Build filename based on format settings
filename := buildQobuzFilename(safeTitle, safeArtist, track.TrackNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filename := buildQobuzFilename(safeTitle, safeArtist, safeAlbum, safeAlbumArtist, spotifyReleaseDate, spotifyTrackNumber, spotifyDiscNumber, filenameFormat, includeTrackNumber, position, useAlbumTrackNumber)
filepath := filepath.Join(outputDir, filename)
if fileInfo, err := os.Stat(filepath); err == nil && fileInfo.Size() > 0 {
@@ -332,41 +375,40 @@ func (q *QobuzDownloader) DownloadByISRC(isrc, outputDir, quality, filenameForma
fmt.Printf("Downloaded: %s\n", filepath)
coverPath := ""
if track.Album.Image.Large != "" {
if spotifyCoverURL != "" {
coverPath = filepath + ".cover.jpg"
fmt.Println("Downloading cover art...")
if err := q.DownloadCoverArt(track.Album.Image.Large, coverPath); err != nil {
fmt.Printf("Warning: Failed to download cover art: %v\n", err)
coverClient := NewCoverClient()
if err := coverClient.DownloadCoverToPath(spotifyCoverURL, coverPath, embedMaxQualityCover); err != nil {
fmt.Printf("Warning: Failed to download Spotify cover: %v\n", err)
coverPath = ""
} else {
defer os.Remove(coverPath)
fmt.Println("Spotify cover downloaded")
}
}
fmt.Println("Embedding metadata and cover art...")
releaseYear := ""
if len(track.ReleaseDateOriginal) >= 4 {
releaseYear = track.ReleaseDateOriginal[:4]
}
// Use album track number if in album folder structure, otherwise use playlist position
trackNumberToEmbed := 0
if position > 0 {
if useAlbumTrackNumber && track.TrackNumber > 0 {
trackNumberToEmbed = track.TrackNumber
} else {
trackNumberToEmbed = position
}
trackNumberToEmbed := spotifyTrackNumber
if trackNumberToEmbed == 0 {
trackNumberToEmbed = 1
}
metadata := Metadata{
Title: trackTitle,
Artist: artists,
Album: albumTitle,
Date: releaseYear,
AlbumArtist: spotifyAlbumArtist,
Date: spotifyReleaseDate,
TrackNumber: trackNumberToEmbed,
DiscNumber: track.MediaNumber,
ISRC: track.ISRC,
TotalTracks: spotifyTotalTracks,
DiscNumber: spotifyDiscNumber,
TotalDiscs: spotifyTotalDiscs,
URL: spotifyURL,
Copyright: spotifyCopyright,
Publisher: spotifyPublisher,
Description: "https://github.com/afkarxyz/SpotiFLAC",
}
if err := EmbedMetadata(filepath, metadata, coverPath); err != nil {
+450
View File
@@ -0,0 +1,450 @@
package backend
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type SongLinkClient struct {
client *http.Client
lastAPICallTime time.Time
apiCallCount int
apiCallResetTime time.Time
}
type SongLinkURLs struct {
TidalURL string `json:"tidal_url"`
AmazonURL string `json:"amazon_url"`
}
type TrackAvailability struct {
SpotifyID string `json:"spotify_id"`
Tidal bool `json:"tidal"`
Amazon bool `json:"amazon"`
Qobuz bool `json:"qobuz"`
TidalURL string `json:"tidal_url,omitempty"`
AmazonURL string `json:"amazon_url,omitempty"`
QobuzURL string `json:"qobuz_url,omitempty"`
}
func NewSongLinkClient() *SongLinkClient {
return &SongLinkClient{
client: &http.Client{
Timeout: 30 * time.Second,
},
apiCallResetTime: time.Now(),
}
}
func (s *SongLinkClient) GetAllURLsFromSpotify(spotifyTrackID string) (*SongLinkURLs, error) {
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
s.apiCallCount = 0
s.apiCallResetTime = time.Now()
}
}
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting streaming URLs from song.link...")
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get URLs: %w", err)
}
s.lastAPICallTime = time.Now()
s.apiCallCount++
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return nil, fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
urls := &SongLinkURLs{}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
urls.TidalURL = tidalLink.URL
fmt.Printf("✓ Tidal URL found\n")
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
amazonURL := amazonLink.URL
if len(amazonURL) > 0 {
urls.AmazonURL = amazonURL
fmt.Printf("✓ Amazon URL found\n")
}
}
if urls.TidalURL == "" && urls.AmazonURL == "" {
return nil, fmt.Errorf("no streaming URLs found")
}
return urls, nil
}
func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc string) (*TrackAvailability, error) {
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
s.apiCallCount = 0
s.apiCallResetTime = time.Now()
}
}
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
fmt.Printf("Checking availability for track: %s\n", spotifyTrackID)
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to check availability: %w", err)
}
s.lastAPICallTime = time.Now()
s.apiCallCount++
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return nil, fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return nil, fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response body: %w", err)
}
if len(body) == 0 {
return nil, fmt.Errorf("API returned empty response")
}
if err := json.Unmarshal(body, &songLinkResp); err != nil {
bodyStr := string(body)
if len(bodyStr) > 200 {
bodyStr = bodyStr[:200] + "..."
}
return nil, fmt.Errorf("failed to decode response: %w (response: %s)", err, bodyStr)
}
availability := &TrackAvailability{
SpotifyID: spotifyTrackID,
}
if tidalLink, ok := songLinkResp.LinksByPlatform["tidal"]; ok && tidalLink.URL != "" {
availability.Tidal = true
availability.TidalURL = tidalLink.URL
}
if amazonLink, ok := songLinkResp.LinksByPlatform["amazonMusic"]; ok && amazonLink.URL != "" {
availability.Amazon = true
availability.AmazonURL = amazonLink.URL
}
if deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]; ok && deezerLink.URL != "" {
deezerURL := deezerLink.URL
deezerISRC, err := GetDeezerISRC(deezerURL)
if err == nil && deezerISRC != "" {
qobuzAvailable := checkQobuzAvailability(deezerISRC)
availability.Qobuz = qobuzAvailable
}
}
return availability, nil
}
func checkQobuzAvailability(isrc string) bool {
client := &http.Client{Timeout: 10 * time.Second}
appID := "798273057"
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly93d3cucW9idXouY29tL2FwaS5qc29uLzAuMi90cmFjay9zZWFyY2g/cXVlcnk9")
searchURL := fmt.Sprintf("%s%s&limit=1&app_id=%s", string(apiBase), isrc, appID)
resp, err := client.Get(searchURL)
if err != nil {
return false
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return false
}
var searchResp struct {
Tracks struct {
Total int `json:"total"`
} `json:"tracks"`
}
if err := json.NewDecoder(resp.Body).Decode(&searchResp); err != nil {
return false
}
return searchResp.Tracks.Total > 0
}
func (s *SongLinkClient) GetDeezerURLFromSpotify(spotifyTrackID string) (string, error) {
now := time.Now()
if now.Sub(s.apiCallResetTime) >= time.Minute {
s.apiCallCount = 0
s.apiCallResetTime = now
}
if s.apiCallCount >= 9 {
waitTime := time.Minute - now.Sub(s.apiCallResetTime)
if waitTime > 0 {
fmt.Printf("Rate limit reached, waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
s.apiCallCount = 0
s.apiCallResetTime = time.Now()
}
}
if !s.lastAPICallTime.IsZero() {
timeSinceLastCall := now.Sub(s.lastAPICallTime)
minDelay := 7 * time.Second
if timeSinceLastCall < minDelay {
waitTime := minDelay - timeSinceLastCall
fmt.Printf("Rate limiting: waiting %v...\n", waitTime.Round(time.Second))
time.Sleep(waitTime)
}
}
spotifyBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9vcGVuLnNwb3RpZnkuY29tL3RyYWNrLw==")
spotifyURL := fmt.Sprintf("%s%s", string(spotifyBase), spotifyTrackID)
apiBase, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hcGkuc29uZy5saW5rL3YxLWFscGhhLjEvbGlua3M/dXJsPQ==")
apiURL := fmt.Sprintf("%s%s", string(apiBase), url.QueryEscape(spotifyURL))
req, err := http.NewRequest("GET", apiURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
fmt.Println("Getting Deezer URL from song.link...")
maxRetries := 3
var resp *http.Response
for i := 0; i < maxRetries; i++ {
resp, err = s.client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get Deezer URL: %w", err)
}
s.lastAPICallTime = time.Now()
s.apiCallCount++
if resp.StatusCode == 429 {
resp.Body.Close()
if i < maxRetries-1 {
waitTime := 15 * time.Second
fmt.Printf("Rate limited by API, waiting %v before retry...\n", waitTime)
time.Sleep(waitTime)
continue
}
return "", fmt.Errorf("API rate limit exceeded after %d retries", maxRetries)
}
if resp.StatusCode != 200 {
resp.Body.Close()
return "", fmt.Errorf("API returned status %d", resp.StatusCode)
}
break
}
defer resp.Body.Close()
var songLinkResp struct {
LinksByPlatform map[string]struct {
URL string `json:"url"`
} `json:"linksByPlatform"`
}
if err := json.NewDecoder(resp.Body).Decode(&songLinkResp); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
deezerLink, ok := songLinkResp.LinksByPlatform["deezer"]
if !ok || deezerLink.URL == "" {
return "", fmt.Errorf("deezer link not found")
}
deezerURL := deezerLink.URL
fmt.Printf("Found Deezer URL: %s\n", deezerURL)
return deezerURL, nil
}
func GetDeezerISRC(deezerURL string) (string, error) {
var trackID string
if strings.Contains(deezerURL, "/track/") {
parts := strings.Split(deezerURL, "/track/")
if len(parts) > 1 {
trackID = strings.Split(parts[1], "?")[0]
trackID = strings.TrimSpace(trackID)
}
}
if trackID == "" {
return "", fmt.Errorf("could not extract track ID from Deezer URL: %s", deezerURL)
}
apiURL := fmt.Sprintf("https://api.deezer.com/track/%s", trackID)
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(apiURL)
if err != nil {
return "", fmt.Errorf("failed to call Deezer API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("Deezer API returned status %d", resp.StatusCode)
}
var deezerTrack struct {
ID int64 `json:"id"`
ISRC string `json:"isrc"`
Title string `json:"title"`
}
if err := json.NewDecoder(resp.Body).Decode(&deezerTrack); err != nil {
return "", fmt.Errorf("failed to decode Deezer API response: %w", err)
}
if deezerTrack.ISRC == "" {
return "", fmt.Errorf("ISRC not found in Deezer API response for track %s", trackID)
}
fmt.Printf("Found ISRC from Deezer: %s (track: %s)\n", deezerTrack.ISRC, deezerTrack.Title)
return deezerTrack.ISRC, nil
}
+181
View File
@@ -0,0 +1,181 @@
package backend
import (
"fmt"
"math"
"math/cmplx"
"github.com/mewkiz/flac"
)
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"`
}
type TimeSlice struct {
Time float64 `json:"time"`
Magnitudes []float64 `json:"magnitudes"`
}
func AnalyzeSpectrum(filepath string) (*SpectrumData, error) {
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)
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")
}
return calculateSpectrum(samples, sampleRate), nil
}
func readSamples(stream *flac.Stream, channels int) ([]float64, error) {
var allSamples []float64
maxSamples := 10 * 1024 * 1024
for {
frame, err := stream.ParseNext()
if err != nil {
break
}
for i := 0; i < frame.Subframes[0].NSamples; i++ {
var sample float64
for ch := 0; ch < channels; ch++ {
sample += float64(frame.Subframes[ch].Samples[i])
}
sample /= float64(channels)
allSamples = append(allSamples, sample)
if len(allSamples) >= maxSamples {
return allSamples, nil
}
}
}
return allSamples, nil
}
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,
}
}
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
}
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)
}
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
}
+1700
View File
File diff suppressed because it is too large Load Diff
+1048 -815
View File
File diff suppressed because it is too large Load Diff
+686 -211
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -18,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
"registries": {
"@lucide-animated": "https://lucide-animated.com/r/{name}.json"
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
<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=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@300..800&family=Figtree:wght@300..900&family=Geist:wght@100..900&family=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&family=Inter:wght@300..800&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Manrope:wght@300..800&family=Noto+Sans:wght@100..900&family=Nunito+Sans:opsz,wght@6..12,200..1000&family=Outfit:wght@100..900&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Public+Sans:ital,wght@0,100..900;1,100..900&family=Raleway:wght@100..900&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<title>SpotiFLAC</title>
</head>
<body>
+19 -16
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"postinstall": "node scripts/generate-icon.js",
"lint": "eslint .",
"preview": "vite preview",
"generate-icon": "node scripts/generate-icon.js"
@@ -16,36 +17,38 @@
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17",
"@tailwindcss/vite": "^4.1.18",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.554.0",
"lucide-react": "^0.562.0",
"motion": "^12.25.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.17"
"tailwindcss": "^4.1.18"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.6",
"@eslint/js": "^9.39.2",
"@types/node": "^25.0.6",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"@vitejs/plugin-react": "^5.1.2",
"eslint": "^9.39.2",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"eslint-plugin-react-refresh": "^0.4.26",
"globals": "^17.0.0",
"sharp": "^0.34.5",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.47.0",
"vite": "^7.2.4"
"typescript-eslint": "^8.52.0",
"vite": "^7.3.1"
}
}
+1 -1
View File
@@ -1 +1 @@
72728e016fdcbb66d395ba3a681b8945
6f2a6dc27f7d8d215283f6d07b4eaa54
+928 -945
View File
File diff suppressed because it is too large Load Diff
-42
View File
@@ -1,42 +0,0 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+435 -322
View File
@@ -1,358 +1,471 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Search } from "lucide-react";
import { Search, X, ArrowUp } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, applyThemeMode } from "@/lib/settings";
import { getSettings, getSettingsWithDefaults, saveSettings, applyThemeMode, applyFont } from "@/lib/settings";
import { applyTheme } from "@/lib/themes";
import { OpenFolder } from "../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
// Components
import { TitleBar } from "@/components/TitleBar";
import { Sidebar, type PageType } from "@/components/Sidebar";
import { Header } from "@/components/Header";
import { SearchBar } from "@/components/SearchBar";
import { TrackInfo } from "@/components/TrackInfo";
import { AlbumInfo } from "@/components/AlbumInfo";
import { PlaylistInfo } from "@/components/PlaylistInfo";
import { ArtistInfo } from "@/components/ArtistInfo";
import { DownloadQueue } from "@/components/DownloadQueue";
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
// Hooks
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
import { AudioConverterPage } from "@/components/AudioConverterPage";
import { FileManagerPage } from "@/components/FileManagerPage";
import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import type { HistoryItem } from "@/components/FetchHistory";
import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover";
import { useAvailability } from "@/hooks/useAvailability";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
const HISTORY_KEY = "spotiflac_fetch_history";
const MAX_HISTORY = 5;
function App() {
const [spotifyUrl, setSpotifyUrl] = useState("");
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<string>("default");
const [currentPage, setCurrentPage] = useState(1);
const [hasUpdate, setHasUpdate] = useState(false);
const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "6.0";
const download = useDownload();
const metadata = useMetadata();
useEffect(() => {
const settings = getSettings();
applyThemeMode(settings.themeMode);
applyTheme(settings.theme);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
const currentSettings = getSettings();
if (currentSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(currentSettings.theme);
}
const [currentPage, setCurrentPage] = useState<PageType>("main");
const [spotifyUrl, setSpotifyUrl] = useState("");
const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<string>("default");
const [currentListPage, setCurrentListPage] = useState(1);
const [hasUpdate, setHasUpdate] = useState(false);
const [releaseDate, setReleaseDate] = useState<string | null>(null);
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const [isSearchMode, setIsSearchMode] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false);
const [hasUnsavedSettings, setHasUnsavedSettings] = useState(false);
const [pendingPageChange, setPendingPageChange] = useState<PageType | null>(null);
const [showUnsavedChangesDialog, setShowUnsavedChangesDialog] = useState(false);
const [resetSettingsFn, setResetSettingsFn] = useState<(() => void) | null>(null);
const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "7.0.4";
const download = useDownload();
const metadata = useMetadata();
const lyrics = useLyrics();
const cover = useCover();
const availability = useAvailability();
const downloadQueue = useDownloadQueueDialog();
useEffect(() => {
const initSettings = async () => {
const settings = getSettings();
applyThemeMode(settings.themeMode);
applyTheme(settings.theme);
applyFont(settings.fontFamily);
if (!settings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
saveSettings(settingsWithDefaults);
}
};
initSettings();
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
const currentSettings = getSettings();
if (currentSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(currentSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
checkForUpdates();
loadHistory();
const handleScroll = () => {
setShowScrollTop(window.scrollY > 300);
};
window.addEventListener("scroll", handleScroll);
return () => {
mediaQuery.removeEventListener("change", handleChange);
window.removeEventListener("scroll", handleScroll);
};
}, []);
const scrollToTop = useCallback(() => {
window.scrollTo({ top: 0, behavior: "smooth" });
}, []);
useEffect(() => {
setSelectedTracks([]);
setSearchQuery("");
download.resetDownloadedTracks();
lyrics.resetLyricsState();
cover.resetCoverState();
availability.clearAvailability();
setSortBy("default");
setCurrentListPage(1);
}, [metadata.metadata]);
const checkForUpdates = async () => {
try {
const response = await fetch("https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest");
const data = await response.json();
const latestVersion = data.tag_name?.replace(/^v/, "") || "";
if (data.published_at) {
setReleaseDate(data.published_at);
}
if (latestVersion && latestVersion > CURRENT_VERSION) {
setHasUpdate(true);
}
}
catch (err) {
console.error("Failed to check for updates:", err);
}
};
mediaQuery.addEventListener("change", handleChange);
checkForUpdates();
return () => {
mediaQuery.removeEventListener("change", handleChange);
const loadHistory = () => {
try {
const saved = localStorage.getItem(HISTORY_KEY);
if (saved) {
setFetchHistory(JSON.parse(saved));
}
}
catch (err) {
console.error("Failed to load history:", err);
}
};
}, []);
const saveHistory = (history: HistoryItem[]) => {
try {
localStorage.setItem(HISTORY_KEY, JSON.stringify(history));
}
catch (err) {
console.error("Failed to save history:", err);
}
};
const addToHistory = (item: Omit<HistoryItem, "id" | "timestamp">) => {
setFetchHistory((prev) => {
const filtered = prev.filter((h) => h.url !== item.url);
const newItem: HistoryItem = {
...item,
id: crypto.randomUUID(),
timestamp: Date.now(),
};
const updated = [newItem, ...filtered].slice(0, MAX_HISTORY);
saveHistory(updated);
return updated;
});
};
const removeFromHistory = (id: string) => {
setFetchHistory((prev) => {
const updated = prev.filter((h) => h.id !== id);
saveHistory(updated);
return updated;
});
};
const handleHistorySelect = async (item: HistoryItem) => {
setSpotifyUrl(item.url);
const updatedUrl = await metadata.handleFetchMetadata(item.url);
if (updatedUrl) {
setSpotifyUrl(updatedUrl);
}
};
const handleFetchMetadata = async () => {
const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl);
if (updatedUrl) {
setSpotifyUrl(updatedUrl);
}
};
useEffect(() => {
if (!metadata.metadata || !spotifyUrl)
return;
let historyItem: Omit<HistoryItem, "id" | "timestamp"> | null = null;
if ("track" in metadata.metadata) {
const { track } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "track",
name: track.name,
artist: track.artists,
image: track.images,
};
}
else if ("album_info" in metadata.metadata) {
const { album_info } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "album",
name: album_info.name,
artist: `${album_info.total_tracks} tracks`,
image: album_info.images,
};
}
else if ("playlist_info" in metadata.metadata) {
const { playlist_info } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "playlist",
name: playlist_info.owner.name,
artist: `${playlist_info.tracks.total} tracks`,
image: playlist_info.cover || playlist_info.owner.images || "",
};
}
else if ("artist_info" in metadata.metadata) {
const { artist_info } = metadata.metadata;
historyItem = {
url: spotifyUrl,
type: "artist",
name: artist_info.name,
artist: `${artist_info.total_albums} albums`,
image: artist_info.images,
};
}
if (historyItem) {
addToHistory(historyItem);
}
}, [metadata.metadata]);
const handleSearchChange = (value: string) => {
setSearchQuery(value);
setCurrentListPage(1);
};
const toggleTrackSelection = (isrc: string) => {
setSelectedTracks((prev) => prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc]);
};
const toggleSelectAll = (tracks: any[]) => {
const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc);
if (selectedTracks.length === tracksWithIsrc.length) {
setSelectedTracks([]);
}
else {
setSelectedTracks(tracksWithIsrc);
}
};
const handleOpenFolder = async () => {
const settings = getSettings();
if (!settings.downloadPath) {
toast.error("Download path not set");
return;
}
try {
await OpenFolder(settings.downloadPath);
}
catch (error) {
console.error("Error opening folder:", error);
toast.error(`Error opening folder: ${error}`);
}
};
const renderMetadata = () => {
if (!metadata.metadata)
return null;
if ("track" in metadata.metadata) {
const { track } = metadata.metadata;
return (<TrackInfo track={track} isDownloading={download.isDownloading} downloadingTrack={download.downloadingTrack} isDownloaded={download.downloadedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)} isSkipped={download.skippedTracks.has(track.isrc)} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} checkingAvailability={availability.checkingTrackId === track.spotify_id} availability={availability.getAvailability(track.spotify_id || "")} downloadingCover={cover.downloadingCover} downloadedCover={cover.downloadedCovers.has(track.spotify_id || "")} failedCover={cover.failedCovers.has(track.spotify_id || "")} skippedCover={cover.skippedCovers.has(track.spotify_id || "")} onDownload={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, undefined, undefined, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadCover={(coverUrl, trackName, artistName, albumName, _playlistName, _position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, undefined, undefined, trackId, albumArtist, releaseDate, discNumber)} onOpenFolder={handleOpenFolder}/>);
}
if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata;
return (<AlbumInfo albumInfo={album_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, position, albumArtist, releaseDate, discNumber, true)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, position, trackId, albumArtist, releaseDate, discNumber, true)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, album_info.name, undefined, true)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, undefined, true)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, undefined, true)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
}
}} onTrackClick={async (track) => {
if (track.external_urls) {
setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls);
}
}}/>);
}
if ("playlist_info" in metadata.metadata) {
const { playlist_info, track_list } = metadata.metadata;
return (<PlaylistInfo playlistInfo={playlist_info} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, playlist_info.owner.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, playlist_info.owner.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onPageChange={setCurrentListPage} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
}
}} onTrackClick={async (track) => {
if (track.external_urls) {
setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls);
}
}}/>);
}
if ("artist_info" in metadata.metadata) {
const { artist_info, album_list, track_list } = metadata.metadata;
return (<ArtistInfo artistInfo={artist_info} albumList={album_list} trackList={track_list} searchQuery={searchQuery} sortBy={sortBy} selectedTracks={selectedTracks} downloadedTracks={download.downloadedTracks} failedTracks={download.failedTracks} skippedTracks={download.skippedTracks} downloadingTrack={download.downloadingTrack} isDownloading={download.isDownloading} bulkDownloadType={download.bulkDownloadType} downloadProgress={download.downloadProgress} currentDownloadInfo={download.currentDownloadInfo} currentPage={currentListPage} itemsPerPage={ITEMS_PER_PAGE} downloadedLyrics={lyrics.downloadedLyrics} failedLyrics={lyrics.failedLyrics} skippedLyrics={lyrics.skippedLyrics} downloadingLyricsTrack={lyrics.downloadingLyricsTrack} checkingAvailabilityTrack={availability.checkingTrackId} availabilityMap={availability.availabilityMap} downloadedCovers={cover.downloadedCovers} failedCovers={cover.failedCovers} skippedCovers={cover.skippedCovers} downloadingCoverTrack={cover.downloadingCoverTrack} isBulkDownloadingCovers={cover.isBulkDownloadingCovers} isBulkDownloadingLyrics={lyrics.isBulkDownloadingLyrics} onSearchChange={handleSearchChange} onSortChange={setSortBy} onToggleTrack={toggleTrackSelection} onToggleSelectAll={toggleSelectAll} onDownloadTrack={download.handleDownloadTrack} onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position, albumArtist, releaseDate, discNumber) => lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, position, albumArtist, releaseDate, discNumber)} onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId, albumArtist, releaseDate, discNumber) => cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, position, trackId, albumArtist, releaseDate, discNumber)} onCheckAvailability={availability.checkAvailability} onDownloadAllLyrics={() => lyrics.handleDownloadAllLyrics(track_list, artist_info.name)} onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name)} onDownloadSelected={() => download.handleDownloadSelected(selectedTracks, track_list, artist_info.name)} onStopDownload={download.handleStopDownload} onOpenFolder={handleOpenFolder} onAlbumClick={metadata.handleAlbumClick} onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) {
setSpotifyUrl(artistUrl);
}
}} onPageChange={setCurrentListPage} onTrackClick={async (track) => {
if (track.external_urls) {
setSpotifyUrl(track.external_urls);
await metadata.handleFetchMetadata(track.external_urls);
}
}}/>);
}
return null;
};
const handlePageChange = (page: PageType) => {
if (currentPage === "settings" && hasUnsavedSettings && page !== "settings") {
setPendingPageChange(page);
setShowUnsavedChangesDialog(true);
return;
}
setCurrentPage(page);
};
const handleDiscardChanges = () => {
setShowUnsavedChangesDialog(false);
if (resetSettingsFn) {
resetSettingsFn();
}
const savedSettings = getSettings();
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
applyFont(savedSettings.fontFamily);
if (pendingPageChange) {
setCurrentPage(pendingPageChange);
setPendingPageChange(null);
}
};
const handleCancelNavigation = () => {
setShowUnsavedChangesDialog(false);
setPendingPageChange(null);
};
const renderPage = () => {
switch (currentPage) {
case "settings":
return <SettingsPage onUnsavedChangesChange={setHasUnsavedSettings} onResetRequest={setResetSettingsFn}/>;
case "debug":
return <DebugLoggerPage />;
case "audio-analysis":
return <AudioAnalysisPage />;
case "audio-converter":
return <AudioConverterPage />;
case "file-manager":
return <FileManagerPage />;
default:
return (<>
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} releaseDate={releaseDate}/>
useEffect(() => {
setSelectedTracks([]);
setSearchQuery("");
download.resetDownloadedTracks();
setSortBy("default");
setCurrentPage(1);
}, [metadata.metadata]);
const checkForUpdates = async () => {
try {
const response = await fetch(
"https://cdn.jsdelivr.net/gh/afkarxyz/SpotiFLAC@refs/heads/main/version.json"
);
const data = await response.json();
const latestVersion = data.version;
if (latestVersion > CURRENT_VERSION) {
setHasUpdate(true);
}
} catch (err) {
console.error("Failed to check for updates:", err);
}
};
const handleFetchMetadata = async () => {
const updatedUrl = await metadata.handleFetchMetadata(spotifyUrl);
if (updatedUrl) {
setSpotifyUrl(updatedUrl);
}
};
const handleSearchChange = (value: string) => {
setSearchQuery(value);
setCurrentPage(1);
};
const toggleTrackSelection = (isrc: string) => {
setSelectedTracks((prev) =>
prev.includes(isrc) ? prev.filter((id) => id !== isrc) : [...prev, isrc]
);
};
const toggleSelectAll = (tracks: any[]) => {
const tracksWithIsrc = tracks.filter((track) => track.isrc).map((track) => track.isrc);
if (selectedTracks.length === tracksWithIsrc.length) {
setSelectedTracks([]);
} else {
setSelectedTracks(tracksWithIsrc);
}
};
const handleOpenFolder = async () => {
const settings = getSettings();
if (!settings.downloadPath) {
toast.error("Download path not set");
return;
}
try {
await OpenFolder(settings.downloadPath);
} catch (error) {
console.error("Error opening folder:", error);
toast.error(`Error opening folder: ${error}`);
}
};
const renderMetadata = () => {
if (!metadata.metadata) return null;
if ("track" in metadata.metadata) {
const { track } = metadata.metadata;
return (
<TrackInfo
track={track}
isDownloading={download.isDownloading}
downloadingTrack={download.downloadingTrack}
isDownloaded={download.downloadedTracks.has(track.isrc)}
onDownload={download.handleDownloadTrack}
onOpenFolder={handleOpenFolder}
/>
);
}
if ("album_info" in metadata.metadata) {
const { album_info, track_list } = metadata.metadata;
return (
<AlbumInfo
albumInfo={album_info}
trackList={track_list}
searchQuery={searchQuery}
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={download.downloadedTracks}
downloadingTrack={download.downloadingTrack}
isDownloading={download.isDownloading}
bulkDownloadType={download.bulkDownloadType}
downloadProgress={download.downloadProgress}
currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage}
itemsPerPage={ITEMS_PER_PAGE}
onSearchChange={handleSearchChange}
onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)}
onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, album_info.name)
}
onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder}
onPageChange={setCurrentPage}
/>
);
}
if ("playlist_info" in metadata.metadata) {
const { playlist_info, track_list } = metadata.metadata;
return (
<PlaylistInfo
playlistInfo={playlist_info}
trackList={track_list}
searchQuery={searchQuery}
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={download.downloadedTracks}
downloadingTrack={download.downloadingTrack}
isDownloading={download.isDownloading}
bulkDownloadType={download.bulkDownloadType}
downloadProgress={download.downloadProgress}
currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage}
itemsPerPage={ITEMS_PER_PAGE}
onSearchChange={handleSearchChange}
onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)}
onDownloadSelected={() =>
download.handleDownloadSelected(
selectedTracks,
track_list,
playlist_info.owner.name
)
}
onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder}
onPageChange={setCurrentPage}
/>
);
}
if ("artist_info" in metadata.metadata) {
const { artist_info, album_list, track_list } = metadata.metadata;
return (
<ArtistInfo
artistInfo={artist_info}
albumList={album_list}
trackList={track_list}
searchQuery={searchQuery}
sortBy={sortBy}
selectedTracks={selectedTracks}
downloadedTracks={download.downloadedTracks}
downloadingTrack={download.downloadingTrack}
isDownloading={download.isDownloading}
bulkDownloadType={download.bulkDownloadType}
downloadProgress={download.downloadProgress}
currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage}
itemsPerPage={ITEMS_PER_PAGE}
onSearchChange={handleSearchChange}
onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection}
onToggleSelectAll={toggleSelectAll}
onDownloadTrack={download.handleDownloadTrack}
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)}
onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true)
}
onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder}
onAlbumClick={metadata.handleAlbumClick}
onPageChange={setCurrentPage}
/>
);
}
return null;
};
return (
<TooltipProvider>
<div className="min-h-screen bg-background flex flex-col">
<TitleBar />
<div className="flex-1 p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} />
{/* Download Progress Toast */}
<DownloadProgressToast />
{/* Timeout Dialog */}
<Dialog
open={metadata.showTimeoutDialog}
onOpenChange={metadata.setShowTimeoutDialog}
>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Fetch Settings</DialogTitle>
<Dialog open={metadata.showTimeoutDialog} onOpenChange={metadata.setShowTimeoutDialog}>
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4">
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowTimeoutDialog(false)}>
<X className="h-4 w-4"/>
</Button>
</div>
<DialogTitle className="text-sm font-medium">Fetch Artist</DialogTitle>
<DialogDescription>
Set timeout for fetching metadata. Longer timeout is recommended for artists
with large discography.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="timeout">Timeout (seconds)</Label>
<Input
id="timeout"
type="number"
min="10"
max="600"
value={metadata.timeoutValue}
onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
minutes).
</p>
{metadata.pendingArtistName && (<div className="py-2">
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.pendingArtistName}</p>
</div>)}
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="timeout">Timeout (seconds)</Label>
<Input id="timeout" type="number" min="10" max="600" value={metadata.timeoutValue} onChange={(e) => metadata.setTimeoutValue(Number(e.target.value))}/>
<p className="text-xs text-muted-foreground">
Default: 60 seconds. For large discographies, try 300-600 seconds (5-10
minutes).
</p>
</div>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => metadata.setShowTimeoutDialog(false)}
>
Cancel
</Button>
<Button onClick={metadata.handleConfirmFetch}>
<Search className="h-4 w-4" />
Fetch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DialogFooter>
<Button variant="outline" onClick={() => metadata.setShowTimeoutDialog(false)}>
Cancel
</Button>
<Button onClick={metadata.handleConfirmFetch}>
<Search className="h-4 w-4"/>
Fetch
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Album Fetch Dialog */}
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Fetch Album</DialogTitle>
<Dialog open={metadata.showAlbumDialog} onOpenChange={metadata.setShowAlbumDialog}>
<DialogContent className="sm:max-w-[425px] p-6 [&>button]:hidden">
<div className="absolute right-4 top-4">
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-70 hover:opacity-100" onClick={() => metadata.setShowAlbumDialog(false)}>
<X className="h-4 w-4"/>
</Button>
</div>
<DialogTitle className="text-sm font-medium">Fetch Album</DialogTitle>
<DialogDescription>
Do you want to fetch metadata for this album?
</DialogDescription>
</DialogHeader>
{metadata.selectedAlbum && (
<div className="py-4">
<p className="font-medium">{metadata.selectedAlbum.name}</p>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
Cancel
</Button>
<Button onClick={metadata.handleConfirmAlbumFetch}>
<Search className="h-4 w-4" />
Fetch Album
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{metadata.selectedAlbum && (<div className="py-2">
<p className="font-medium bg-muted/50 rounded-md px-3 py-2">{metadata.selectedAlbum.name}</p>
</div>)}
<DialogFooter>
<Button variant="outline" onClick={() => metadata.setShowAlbumDialog(false)}>
Cancel
</Button>
<Button onClick={async () => {
const albumUrl = await metadata.handleConfirmAlbumFetch();
if (albumUrl) {
setSpotifyUrl(albumUrl);
}
}}>
<Search className="h-4 w-4"/>
Fetch Album
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<SearchBar
url={spotifyUrl}
loading={metadata.loading}
onUrlChange={setSpotifyUrl}
onFetch={handleFetchMetadata}
/>
<SearchBar url={spotifyUrl} loading={metadata.loading} onUrlChange={setSpotifyUrl} onFetch={handleFetchMetadata} onFetchUrl={async (url) => {
setSpotifyUrl(url);
const updatedUrl = await metadata.handleFetchMetadata(url);
if (updatedUrl) {
setSpotifyUrl(updatedUrl);
}
}} history={fetchHistory} onHistorySelect={handleHistorySelect} onHistoryRemove={removeFromHistory} hasResult={!!metadata.metadata} searchMode={isSearchMode} onSearchModeChange={setIsSearchMode}/>
{metadata.metadata && renderMetadata()}
{!isSearchMode && metadata.metadata && renderMetadata()}
</>);
}
};
return (<TooltipProvider>
<div className="min-h-screen bg-background flex flex-col">
<TitleBar />
<Sidebar currentPage={currentPage} onPageChange={handlePageChange}/>
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6">
{renderPage()}
</div>
</div>
</div>
</TooltipProvider>
);
}
<DownloadProgressToast onClick={downloadQueue.openQueue}/>
<DownloadQueue isOpen={downloadQueue.isOpen} onClose={downloadQueue.closeQueue}/>
{showScrollTop && (<Button onClick={scrollToTop} className="fixed bottom-6 right-6 z-50 h-10 w-10 rounded-full shadow-lg" size="icon">
<ArrowUp className="h-5 w-5"/>
</Button>)}
<Dialog open={showUnsavedChangesDialog} onOpenChange={setShowUnsavedChangesDialog}>
<DialogContent className="sm:max-w-[425px] [&>button]:hidden">
<DialogHeader>
<DialogTitle>Unsaved Changes</DialogTitle>
<DialogDescription>
You have unsaved changes in Settings. Are you sure you want to leave? Your changes will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={handleCancelNavigation}>
Cancel
</Button>
<Button variant="destructive" onClick={handleDiscardChanges}>
Discard Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</TooltipProvider>);
}
export default App;
+108 -129
View File
@@ -1,162 +1,141 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText } 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 } from "@/types/api";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
interface AlbumInfoProps {
albumInfo: {
name: string;
artists: string;
images: string;
release_date: string;
total_tracks: number;
};
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number;
itemsPerPage: number;
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) => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
albumInfo: {
name: string;
artists: string;
images: string;
release_date: string;
total_tracks: number;
artist_id?: string;
artist_url?: string;
};
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: {
name: string;
artists: string;
} | null;
currentPage: number;
itemsPerPage: number;
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
onArtistClick?: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onTrackClick?: (track: TrackMetadata) => void;
}
export function AlbumInfo({
albumInfo,
trackList,
searchQuery,
sortBy,
selectedTracks,
downloadedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
downloadProgress,
currentDownloadInfo,
currentPage,
itemsPerPage,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onPageChange,
}: AlbumInfoProps) {
return (
<div className="space-y-6">
export function AlbumInfo({ albumInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onArtistClick, onTrackClick, }: AlbumInfoProps) {
return (<div className="space-y-6">
<Card>
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{albumInfo.images && (
<img
src={albumInfo.images}
alt={albumInfo.name}
className="w-48 h-48 rounded-md shadow-lg object-cover"
/>
)}
{albumInfo.images && (<img src={albumInfo.images} alt={albumInfo.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">Album</p>
<h2 className="text-4xl font-bold">{albumInfo.name}</h2>
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{albumInfo.artists}</span>
{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>
<span>{albumInfo.release_date}</span>
<span></span>
<span>{albumInfo.total_tracks} songs</span>
<span>
{albumInfo.total_tracks} {albumInfo.total_tracks === 1 ? "song" : "songs"}
</span>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={onDownloadAll}
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} disabled={isDownloading}>
{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" />
)}
{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>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
</Button>)}
{onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>)}
{onDownloadAllCovers && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
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}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
itemsPerPage={itemsPerPage}
showCheckboxes={true}
hideAlbumColumn={true}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onPageChange={onPageChange}
/>
<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>);
}
+420 -184
View File
@@ -1,215 +1,451 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText, BadgeCheck } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress";
import type { TrackMetadata } from "@/types/api";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { downloadHeader, downloadGalleryImage, downloadAvatar } from "@/lib/api";
import { getSettings } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { useState } from "react";
interface ArtistInfoProps {
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>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number;
itemsPerPage: number;
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) => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onAlbumClick: (album: { id: string; name: string; external_urls: string }) => void;
onPageChange: (page: number) => void;
artistInfo: {
name: string;
images: string;
header?: string;
gallery?: string[];
followers: number;
genres: string[];
biography?: string;
verified?: boolean;
listeners?: number;
rank?: number;
};
albumList: Array<{
id: string;
name: string;
images: string;
release_date: string;
album_type: string;
external_urls: string;
}>;
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: {
name: string;
artists: string;
} | null;
currentPage: number;
itemsPerPage: number;
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onAlbumClick: (album: {
id: string;
name: string;
external_urls: string;
}) => void;
onArtistClick: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onPageChange: (page: number) => void;
onTrackClick?: (track: TrackMetadata) => void;
}
export function ArtistInfo({
artistInfo,
albumList,
trackList,
searchQuery,
sortBy,
selectedTracks,
downloadedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
downloadProgress,
currentDownloadInfo,
currentPage,
itemsPerPage,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onAlbumClick,
onPageChange,
}: 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">
<span>{artistInfo.followers.toLocaleString()} followers</span>
{artistInfo.genres.length > 0 && (
<>
<span></span>
<span>{artistInfo.genres.join(", ")}</span>
</>
)}
export function ArtistInfo({ artistInfo, albumList, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onAlbumClick, onArtistClick, onPageChange, onTrackClick, }: ArtistInfoProps) {
const [downloadingHeader, setDownloadingHeader] = useState(false);
const [downloadingAvatar, setDownloadingAvatar] = useState(false);
const [downloadingGalleryIndex, setDownloadingGalleryIndex] = useState<number | null>(null);
const [downloadingAllGallery, setDownloadingAllGallery] = useState(false);
const handleDownloadHeader = async () => {
if (!artistInfo.header)
return;
setDownloadingHeader(true);
try {
const settings = getSettings();
const response = await downloadHeader({
header_url: artistInfo.header,
artist_name: artistInfo.name,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
toast.info("Header already exists");
}
else {
toast.success("Header downloaded successfully");
}
}
else {
toast.error(response.error || "Failed to download header");
}
}
catch (error) {
toast.error(`Error downloading header: ${error}`);
}
finally {
setDownloadingHeader(false);
}
};
const handleDownloadAvatar = async () => {
if (!artistInfo.images)
return;
setDownloadingAvatar(true);
try {
const settings = getSettings();
const response = await downloadAvatar({
avatar_url: artistInfo.images,
artist_name: artistInfo.name,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
toast.info("Avatar already exists");
}
else {
toast.success("Avatar downloaded successfully");
}
}
else {
toast.error(response.error || "Failed to download avatar");
}
}
catch (error) {
toast.error(`Error downloading avatar: ${error}`);
}
finally {
setDownloadingAvatar(false);
}
};
const handleDownloadGalleryImage = async (imageUrl: string, index: number) => {
setDownloadingGalleryIndex(index);
try {
const settings = getSettings();
const response = await downloadGalleryImage({
image_url: imageUrl,
artist_name: artistInfo.name,
image_index: index,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
toast.info(`Gallery image ${index + 1} already exists`);
}
else {
toast.success(`Gallery image ${index + 1} downloaded successfully`);
}
}
else {
toast.error(response.error || `Failed to download gallery image ${index + 1}`);
}
}
catch (error) {
toast.error(`Error downloading gallery image ${index + 1}: ${error}`);
}
finally {
setDownloadingGalleryIndex(null);
}
};
const handleDownloadAllGallery = async () => {
if (!artistInfo.gallery || artistInfo.gallery.length === 0)
return;
setDownloadingAllGallery(true);
try {
const settings = getSettings();
let successCount = 0;
let existsCount = 0;
let failCount = 0;
for (let index = 0; index < artistInfo.gallery.length; index++) {
const imageUrl = artistInfo.gallery[index];
try {
const response = await downloadGalleryImage({
image_url: imageUrl,
artist_name: artistInfo.name,
image_index: index,
output_dir: settings.downloadPath,
});
if (response.success) {
if (response.already_exists) {
existsCount++;
}
else {
successCount++;
}
}
else {
failCount++;
}
}
catch (error) {
failCount++;
}
}
if (failCount === 0) {
if (existsCount > 0 && successCount > 0) {
toast.success(`${successCount} images downloaded, ${existsCount} already existed`);
}
else if (existsCount > 0) {
toast.info(`All ${existsCount} images already exist`);
}
else {
toast.success(`All ${successCount} gallery images downloaded successfully`);
}
}
else {
toast.error(`${failCount} images failed to download`);
}
}
catch (error) {
toast.error(`Error downloading gallery images: ${error}`);
}
finally {
setDownloadingAllGallery(false);
}
};
return (<div className="space-y-6">
<Card className="overflow-hidden p-0">
{artistInfo.header ? (<>
<div className="relative w-full h-64 bg-cover bg-center">
<div className="absolute inset-0 bg-cover bg-center" style={{ backgroundImage: `url(${artistInfo.header})` }}/>
<div className="absolute inset-0 bg-gradient-to-t from-black via-black/50 to-transparent"/>
<div className="absolute top-4 right-4 z-10">
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleDownloadHeader} size="sm" variant="secondary" disabled={downloadingHeader} className="bg-white/10 hover:bg-white/20 text-white border-white/20">
{downloadingHeader ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Header</p>
</TooltipContent>
</Tooltip>
</div>
<div className="relative px-6 pt-6 pb-20">
<div className="flex gap-6 items-start">
{artistInfo.images && (<div className="relative group">
<img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors rounded-full flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleDownloadAvatar} size="sm" variant="secondary" disabled={downloadingAvatar} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
{downloadingAvatar ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Avatar</p>
</TooltipContent>
</Tooltip>
</div>
</div>)}
<div className="flex-1 space-y-2">
<p className="text-sm font-medium text-white/80">Artist</p>
<div className="flex items-center gap-2">
<h2 className="text-4xl font-bold text-white">{artistInfo.name}</h2>
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-blue-400 shrink-0"/>)}
</div>
{artistInfo.biography && (<p className="text-sm text-white/90">{artistInfo.biography}</p>)}
<div className="flex items-center gap-2 text-sm flex-wrap text-white/90">
<span>{artistInfo.followers.toLocaleString()} followers</span>
{artistInfo.listeners && (<>
<span></span>
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
</>)}
{artistInfo.rank && (<>
<span></span>
<span>#{artistInfo.rank} rank</span>
</>)}
<span></span>
<span>{albumList.length} {albumList.length === 1 ? "album" : "albums"}</span>
<span></span>
<span>{trackList.length} {trackList.length === 1 ? "track" : "tracks"}</span>
{artistInfo.genres.length > 0 && (<>
<span></span>
<span>{artistInfo.genres.join(", ")}</span>
</>)}
</div>
</div>
</div>
</div>
</div>
</div>
</CardContent>
</>) : (<CardContent className="px-6 py-6">
<div className="flex gap-6 items-start">
{artistInfo.images && (<div className="relative group">
<img src={artistInfo.images} alt={artistInfo.name} className="w-48 h-48 rounded-full shadow-lg object-cover border-4 border-white"/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors rounded-full flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleDownloadAvatar} size="sm" variant="secondary" disabled={downloadingAvatar} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
{downloadingAvatar ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Avatar</p>
</TooltipContent>
</Tooltip>
</div>
</div>)}
<div className="flex-1 space-y-2">
<p className="text-sm font-medium">Artist</p>
<div className="flex items-center gap-2">
<h2 className="text-4xl font-bold">{artistInfo.name}</h2>
{artistInfo.verified && (<BadgeCheck className="h-6 w-6 text-blue-500 shrink-0"/>)}
</div>
{artistInfo.biography && (<p className="text-sm text-muted-foreground">{artistInfo.biography}</p>)}
<div className="flex items-center gap-2 text-sm flex-wrap">
<span>{artistInfo.followers.toLocaleString()} followers</span>
{artistInfo.listeners && (<>
<span></span>
<span>{artistInfo.listeners.toLocaleString()} listeners</span>
</>)}
{artistInfo.rank && (<>
<span></span>
<span>#{artistInfo.rank} rank</span>
</>)}
<span></span>
<span>{albumList.length} albums</span>
<span></span>
<span>{trackList.length} tracks</span>
{artistInfo.genres.length > 0 && (<>
<span></span>
<span>{artistInfo.genres.join(", ")}</span>
</>)}
</div>
</div>
</div>
</CardContent>)}
</Card>
{albumList.length > 0 && (
<div className="space-y-4">
{artistInfo.gallery && artistInfo.gallery.length > 0 && (<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold">Gallery ({artistInfo.gallery.length})</h3>
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={handleDownloadAllGallery} size="sm" variant="outline" disabled={downloadingAllGallery}>
{downloadingAllGallery ? <Spinner className="h-4 w-4"/> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Gallery</p>
</TooltipContent>
</Tooltip>
</div>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{artistInfo.gallery.map((imageUrl, index) => (<div key={index} className="relative group">
<div className="relative aspect-square rounded-md overflow-hidden shadow-md">
<img src={imageUrl} alt={`${artistInfo.name} gallery ${index + 1}`} className="w-full h-full object-cover"/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/50 transition-colors flex items-center justify-center">
<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => handleDownloadGalleryImage(imageUrl, index)} size="sm" variant="secondary" disabled={downloadingGalleryIndex === index} className="opacity-0 group-hover:opacity-100 transition-opacity bg-white/10 hover:bg-white/20 text-white border-white/20">
{downloadingGalleryIndex === index ? (<Spinner className="h-4 w-4"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Image {index + 1}</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>))}
</div>
</div>)}
{albumList.length > 0 && (<div className="space-y-4">
<h3 className="text-2xl font-bold">Discography</h3>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{albumList.map((album) => (
<div
key={album.id}
className="group cursor-pointer"
onClick={() =>
onAlbumClick({
{albumList.map((album) => (<div key={album.id} className="group cursor-pointer" onClick={() => onAlbumClick({
id: album.id,
name: album.name,
external_urls: album.external_urls,
})
}
>
})}>
<div className="relative mb-4">
{album.images && (
<img
src={album.images}
alt={album.name}
className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"
/>
)}
{album.images && (<img src={album.images} alt={album.name} className="w-full aspect-square object-cover rounded-md shadow-md transition-shadow group-hover:shadow-xl"/>)}
</div>
<h4 className="font-semibold truncate">{album.name}</h4>
<p className="text-sm text-muted-foreground">
{album.release_date?.split("-")[0]} {album.album_type}
{album.release_date?.split("-")[0]}
</p>
</div>
))}
</div>))}
</div>
</div>
)}
</div>)}
{trackList.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-2xl font-bold">Popular Tracks</h3>
<div className="flex gap-2">
<Button
onClick={onDownloadAll}
size="sm"
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
{trackList.length > 0 && (<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-2xl font-bold">All Tracks</h3>
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} size="sm" disabled={isDownloading}>
{isDownloading && bulkDownloadType === "all" ? (<Spinner />) : (<Download className="h-4 w-4"/>)}
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" />
)}
{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>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} size="sm" variant="outline">
<FolderOpen className="h-4 w-4" />
</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>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllCovers} size="sm" variant="outline" disabled={isBulkDownloadingCovers}>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} size="sm" variant="outline">
<FolderOpen className="h-4 w-4"/>
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}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
itemsPerPage={itemsPerPage}
showCheckboxes={true}
hideAlbumColumn={false}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onPageChange={onPageChange}
/>
</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>);
}
+125
View File
@@ -0,0 +1,125 @@
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 type { AnalysisResult } from "@/types/api";
interface AudioAnalysisProps {
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">
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>);
}
if (!result) {
return null;
}
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const formatNumber = (num: number) => {
return num.toFixed(2);
};
const formatFileSize = (bytes: number): string => {
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
};
const nyquistFreq = result.sample_rate / 2;
return (<Card className="gap-2">
<CardHeader>
{filePath && (<p className="text-sm font-mono break-all">{filePath}</p>)}
</CardHeader>
<CardContent className="space-y-2">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<div className="flex items-center gap-1">
<Radio className="h-3 w-3 text-muted-foreground"/>
<span className="text-muted-foreground">Sample Rate:</span>
<span className="font-semibold">{(result.sample_rate / 1000).toFixed(1)} kHz</span>
</div>
<div className="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>
<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>);
}
@@ -0,0 +1,113 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Upload, ArrowLeft, Trash2 } from "lucide-react";
import { AudioAnalysis } from "@/components/AudioAnalysis";
import { SpectrumVisualization } from "@/components/SpectrumVisualization";
import { useAudioAnalysis } from "@/hooks/useAudioAnalysis";
import { SelectFile } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
interface AudioAnalysisPageProps {
onBack?: () => void;
}
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();
};
}, [handleFileDrop]);
const handleAnalyzeAnother = () => {
clearResult();
};
return (<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{onBack && (<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-5 w-5"/>
</Button>)}
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
</div>
{result && (<Button onClick={handleAnalyzeAnother} variant="outline" size="sm">
<Trash2 className="h-4 w-4"/>
Clear
</Button>)}
</div>
{!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>)}
{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>)}
{result && (<div className="space-y-4">
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} filePath={selectedFilePath}/>
{spectrumLoading ? (<div className="flex flex-col items-center justify-center py-16 border rounded-lg">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mb-2"></div>
<p className="text-sm text-muted-foreground">Loading spectrum data...</p>
</div>) : (<SpectrumVisualization sampleRate={result.sample_rate} bitsPerSample={result.bits_per_sample} duration={result.duration} spectrumData={result.spectrum}/>)}
</div>)}
</div>);
}
@@ -0,0 +1,512 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import { ToggleGroup, ToggleGroupItem, } from "@/components/ui/toggle-group";
import { Upload, Download, X, CheckCircle2, AlertCircle, Trash2, FileMusic, WandSparkles, } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import { IsFFmpegInstalled, DownloadFFmpeg, ConvertAudio, SelectAudioFiles, } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { OnFileDrop, OnFileDropOff } from "../../wailsjs/runtime/runtime";
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
interface AudioFile {
path: string;
name: string;
format: string;
size: number;
status: "pending" | "converting" | "success" | "error";
error?: string;
outputPath?: string;
}
function formatFileSize(bytes: number): string {
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
const BITRATE_OPTIONS = [
{ value: "320k", label: "320k" },
{ value: "256k", label: "256k" },
{ value: "192k", label: "192k" },
{ value: "128k", label: "128k" },
];
const M4A_CODEC_OPTIONS = [
{ value: "aac", label: "AAC" },
{ value: "alac", label: "ALAC" },
];
const STORAGE_KEY = "spotiflac_audio_converter_state";
export function AudioConverterPage() {
const [ffmpegInstalled, setFfmpegInstalled] = useState<boolean>(false);
const [installingFfmpeg, setInstallingFfmpeg] = useState(false);
const downloadProgress = useDownloadProgress();
const [files, setFiles] = useState<AudioFile[]>(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.files && Array.isArray(parsed.files) && parsed.files.length > 0) {
return parsed.files;
}
}
}
catch (err) {
console.error("Failed to load saved state:", err);
}
return [];
});
const [outputFormat, setOutputFormat] = useState<"mp3" | "m4a">(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.outputFormat === "mp3" || parsed.outputFormat === "m4a") {
return parsed.outputFormat;
}
}
}
catch (err) {
}
return "mp3";
});
const [bitrate, setBitrate] = useState(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.bitrate) {
return parsed.bitrate;
}
}
}
catch (err) {
}
return "320k";
});
const [m4aCodec, setM4aCodec] = useState<"aac" | "alac">(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.m4aCodec === "aac" || parsed.m4aCodec === "alac") {
return parsed.m4aCodec;
}
}
}
catch (err) {
}
return "aac";
});
const [converting, setConverting] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const saveState = useCallback((stateToSave: {
files: AudioFile[];
outputFormat: "mp3" | "m4a";
bitrate: string;
m4aCodec: "aac" | "alac";
}) => {
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(stateToSave));
}
catch (err) {
console.error("Failed to save state:", err);
}
}, []);
useEffect(() => {
checkFfmpegInstallation();
}, []);
useEffect(() => {
saveState({ files, outputFormat, bitrate, m4aCodec });
}, [files, outputFormat, bitrate, m4aCodec, saveState]);
useEffect(() => {
if (files.length === 0)
return;
const allMP3 = files.every((f) => f.format === "mp3");
if (allMP3 && outputFormat !== "m4a") {
setOutputFormat("m4a");
}
const hasFlac = files.some((f) => f.format === "flac");
if (!hasFlac && m4aCodec === "alac") {
setM4aCodec("aac");
}
}, [files, outputFormat, m4aCodec]);
const isFormatDisabled = files.length > 0 && files.every((f) => f.format === "mp3");
const hasFlacFiles = files.some((f) => f.format === "flac");
useEffect(() => {
const checkFullscreen = () => {
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
setIsFullscreen(isMaximized);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const checkFfmpegInstallation = async () => {
try {
const installed = await IsFFmpegInstalled();
setFfmpegInstalled(installed);
}
catch (err) {
console.error("Failed to check ffmpeg:", err);
setFfmpegInstalled(false);
}
};
const handleInstallFfmpeg = async () => {
setInstallingFfmpeg(true);
try {
const result = await DownloadFFmpeg();
if (result.success) {
toast.success("FFmpeg Installed", {
description: "FFmpeg has been installed successfully",
});
setFfmpegInstalled(true);
}
else {
toast.error("Installation Failed", {
description: result.error || "Failed to install FFmpeg",
});
}
}
catch (err) {
toast.error("Installation Failed", {
description: err instanceof Error ? err.message : "Unknown error",
});
}
finally {
setInstallingFfmpeg(false);
}
};
const handleSelectFiles = async () => {
try {
const selectedFiles = await SelectAudioFiles();
if (selectedFiles && selectedFiles.length > 0) {
addFiles(selectedFiles);
}
}
catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select files",
});
}
};
const addFiles = useCallback(async (paths: string[]) => {
const validExtensions = [".mp3", ".flac"];
const m4aFiles = paths.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return ext === ".m4a";
});
if (m4aFiles.length > 0) {
toast.error("M4A files not supported", {
description: "Only FLAC and MP3 files are supported as input. Please convert M4A files first.",
});
}
const GetFileSizes = (files: string[]): Promise<Record<string, number>> => (window as any)["go"]["main"]["App"]["GetFileSizes"](files);
const validPaths = paths.filter((path) => {
const ext = path.toLowerCase().slice(path.lastIndexOf("."));
return validExtensions.includes(ext);
});
const fileSizes = validPaths.length > 0 ? await GetFileSizes(validPaths) : {};
setFiles((prev) => {
const newFiles: AudioFile[] = validPaths
.filter((path) => !prev.some((f) => f.path === path))
.map((path) => {
const name = path.split(/[/\\]/).pop() || path;
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
return {
path,
name,
format: ext,
size: fileSizes[path] || 0,
status: "pending" as const,
};
});
if (newFiles.length > 0) {
if (paths.length > newFiles.length) {
const skipped = paths.length - newFiles.length;
toast.info("Some files skipped", {
description: `${skipped} file(s) were skipped (unsupported format or already added)`,
});
}
return [...prev, ...newFiles];
}
if (paths.length > 0 && m4aFiles.length === 0) {
toast.info("No new files added", {
description: "All files were already added or have unsupported format",
});
}
return prev;
});
}, []);
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0)
return;
addFiles(paths);
}, [addFiles]);
useEffect(() => {
if (ffmpegInstalled === true) {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}
}, [handleFileDrop, ffmpegInstalled]);
const removeFile = (path: string) => {
setFiles((prev) => prev.filter((f) => f.path !== path));
};
const clearFiles = () => {
setFiles([]);
};
const handleConvert = async () => {
if (files.length === 0) {
toast.error("No files selected", {
description: "Please add audio files to convert",
});
return;
}
setConverting(true);
try {
const inputPaths = files.map((f) => f.path);
setFiles((prev) => prev.map((f) => {
if (inputPaths.includes(f.path)) {
return { ...f, status: "converting" as const, error: undefined };
}
return f;
}));
const results = await ConvertAudio({
input_files: inputPaths,
output_format: outputFormat,
bitrate: bitrate,
codec: outputFormat === "m4a" ? m4aCodec : "",
});
setFiles((prev) => prev.map((f) => {
const result = results.find((r) => r.input_file === f.path);
if (result) {
return {
...f,
status: result.success ? "success" : "error",
error: result.error,
outputPath: result.output_file,
};
}
return f;
}));
const successCount = results.filter((r) => r.success).length;
const failCount = results.filter((r) => !r.success).length;
if (successCount > 0) {
toast.success("Conversion Complete", {
description: `Successfully converted ${successCount} file(s)${failCount > 0 ? `, ${failCount} failed` : ""}`,
});
}
else if (failCount > 0) {
toast.error("Conversion Failed", {
description: `All ${failCount} file(s) failed to convert`,
});
}
}
catch (err) {
toast.error("Conversion Error", {
description: err instanceof Error ? err.message : "Unknown error",
});
setFiles((prev) => prev.map((f) => ({ ...f, status: "error" as const, error: "Conversion failed" })));
}
finally {
setConverting(false);
}
};
const getStatusIcon = (status: AudioFile["status"]) => {
switch (status) {
case "converting":
return <Spinner className="h-4 w-4 text-primary"/>;
case "success":
return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
case "error":
return <AlertCircle className="h-4 w-4 text-destructive"/>;
default:
return <FileMusic className="h-4 w-4 text-muted-foreground"/>;
}
};
const convertableCount = files.filter((f) => f.status === "pending" || f.status === "success").length;
const successCount = files.filter((f) => f.status === "success").length;
if (ffmpegInstalled === false) {
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center gap-4">
<h1 className="text-2xl font-bold">Audio Converter</h1>
</div>
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} border-muted-foreground/30`}>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Download className="h-8 w-8 text-primary"/>
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
FFmpeg is required to convert audio files
</p>
<Button onClick={handleInstallFfmpeg} disabled={installingFfmpeg} size="lg">
{installingFfmpeg ? (<>
<Spinner className="h-5 w-5"/>
Installing FFmpeg...
</>) : (<>
<Download className="h-5 w-5"/>
Install FFmpeg
</>)}
</Button>
{installingFfmpeg && downloadProgress.is_downloading && downloadProgress.mb_downloaded > 0 && (<div className="w-full max-w-md mt-6 space-y-2 px-4">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Downloading FFmpeg</span>
<span className="font-mono tabular-nums">
{downloadProgress.mb_downloaded.toFixed(2)} MB
{downloadProgress.speed_mbps > 0 && (<span className="text-muted-foreground ml-2">
@ {downloadProgress.speed_mbps.toFixed(2)} MB/s
</span>)}
</span>
</div>
<Progress value={Math.min(100, (downloadProgress.mb_downloaded / 200) * 100)} className="h-2"/>
</div>)}
</div>
</div>);
}
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Audio Converter</h1>
{files.length > 0 && (<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleSelectFiles}>
<Upload className="h-4 w-4"/>
Add More
</Button>
<Button variant="outline" size="sm" onClick={clearFiles} disabled={converting}>
<Trash2 className="h-4 w-4"/>
Clear All
</Button>
</div>)}
</div>
<div className={`flex flex-col items-center justify-center border-2 border-dashed rounded-lg transition-all ${isFullscreen ? "flex-1 min-h-[400px]" : "h-[400px]"} ${isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30"}`} onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}} onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}} onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}} style={{ "--wails-drop-target": "drop" } as React.CSSProperties}>
{files.length === 0 ? (<>
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<Upload className="h-8 w-8 text-primary"/>
</div>
<p className="text-sm text-muted-foreground mb-4 text-center">
{isDragging
? "Drop your audio files here"
: "Drag and drop audio files here, or click the button below to select"}
</p>
<Button onClick={handleSelectFiles} size="lg">
<Upload className="h-5 w-5"/>
Select Files
</Button>
<p className="text-xs text-muted-foreground mt-4 text-center">
Supported formats: FLAC, MP3
</p>
</>) : (<div className="w-full h-full p-6 space-y-4 flex flex-col">
<div className="space-y-2 pb-4 border-b shrink-0">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Format:</Label>
<ToggleGroup type="single" variant="outline" value={outputFormat} onValueChange={(value) => {
if (value && !isFormatDisabled)
setOutputFormat(value as "mp3" | "m4a");
}} disabled={isFormatDisabled}>
{!isFormatDisabled && (<ToggleGroupItem value="mp3" aria-label="MP3">
MP3
</ToggleGroupItem>)}
<ToggleGroupItem value="m4a" aria-label="M4A" disabled={isFormatDisabled}>
M4A
</ToggleGroupItem>
</ToggleGroup>
</div>
{outputFormat === "m4a" && hasFlacFiles && (<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Codec:</Label>
<ToggleGroup type="single" variant="outline" value={m4aCodec} onValueChange={(value) => {
if (value)
setM4aCodec(value as "aac" | "alac");
}}>
{M4A_CODEC_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
{option.label}
</ToggleGroupItem>))}
</ToggleGroup>
</div>)}
{!(outputFormat === "m4a" && m4aCodec === "alac") && (<div className="flex items-center gap-2">
<Label className="whitespace-nowrap">Bitrate:</Label>
<ToggleGroup type="single" variant="outline" value={bitrate} onValueChange={(value) => {
if (value)
setBitrate(value);
}}>
{BITRATE_OPTIONS.map((option) => (<ToggleGroupItem key={option.value} value={option.value} aria-label={option.label}>
{option.label}
</ToggleGroupItem>))}
</ToggleGroup>
</div>)}
</div>
</div>
<div className="flex items-center justify-between shrink-0">
<div className="text-sm text-muted-foreground">
{files.length} file(s) {successCount} converted
</div>
</div>
<div className="flex-1 space-y-2 overflow-y-auto min-h-0">
{files.map((file) => (<div key={file.path} className="flex items-center gap-3 rounded-lg border p-3">
{getStatusIcon(file.status)}
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium">{file.name}</p>
{file.error && (<p className="truncate text-xs text-destructive">
{file.error}
</p>)}
</div>
<span className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</span>
<span className="text-xs uppercase text-muted-foreground">
{file.format}
</span>
{file.status !== "converting" && (<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => removeFile(file.path)} disabled={converting}>
<X className="h-4 w-4"/>
</Button>)}
</div>))}
</div>
<div className="flex justify-center pt-4 border-t shrink-0">
<Button onClick={handleConvert} disabled={converting || convertableCount === 0} size="lg">
{converting ? (<>
<Spinner className="h-4 w-4"/>
Converting...
</>) : (<>
<WandSparkles className="h-4 w-4"/>
Convert {convertableCount > 0 ? `${convertableCount} File(s)` : ""}
</>)}
</Button>
</div>
</div>)}
</div>
</div>);
}
@@ -0,0 +1,81 @@
import { useState, useEffect, useRef } from "react";
import { Trash2, Copy, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { logger, type LogEntry } from "@/lib/logger";
const levelColors: Record<string, string> = {
info: "text-blue-500",
success: "text-green-500",
warning: "text-yellow-500",
error: "text-red-500",
debug: "text-gray-500",
};
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());
});
setLogs(logger.getLogs());
return () => {
unsubscribe();
};
}, []);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [logs]);
const handleClear = () => {
logger.clear();
};
const handleCopy = async () => {
const logText = logs
.map((log) => `[${formatTime(log.timestamp)}] [${log.level}] ${log.message}`)
.join("\n");
try {
await navigator.clipboard.writeText(logText);
setCopied(true);
setTimeout(() => setCopied(false), 500);
}
catch (err) {
console.error("Failed to copy logs:", err);
}
};
return (<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"/>}
Copy
</Button>
<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">
<span className="text-muted-foreground shrink-0">
[{formatTime(log.timestamp)}]
</span>
<span className={`shrink-0 w-16 ${levelColors[log.level]}`}>
[{log.level}]
</span>
<span className="break-all">{log.message}</span>
</div>)))}
</div>
</div>);
}
+14 -14
View File
@@ -1,29 +1,29 @@
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { StopCircle } from "lucide-react";
interface DownloadProgressProps {
progress: number;
currentTrack: { name: string; artists: string } | null;
onStop: () => void;
progress: number;
currentTrack: {
name: string;
artists: string;
} | null;
onStop: () => void;
}
export function DownloadProgress({ progress, currentTrack, onStop }: DownloadProgressProps) {
return (
<div className="w-full space-y-2 mt-4">
const clampedProgress = Math.min(100, Math.max(0, progress));
return (<div className="w-full space-y-2 mt-4">
<div className="flex items-center gap-2">
<Progress value={progress} className="h-2 flex-1" />
<Progress value={clampedProgress} className="h-2 flex-1"/>
<Button variant="destructive" size="sm" onClick={onStop} className="gap-1.5">
<StopCircle className="h-4 w-4" />
<StopCircle className="h-4 w-4"/>
Stop
</Button>
</div>
<p className="text-xs text-muted-foreground">
{progress}% -{" "}
{clampedProgress}% -{" "}
{currentTrack
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
? `${currentTrack.name} - ${currentTrack.artists}`
: "Preparing download..."}
</p>
</div>
);
</div>);
}
@@ -1,30 +1,31 @@
import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { Download } from "lucide-react";
export function DownloadProgressToast() {
const progress = useDownloadProgress();
if (!progress.is_downloading) {
return null;
}
return (
<div className="fixed bottom-4 left-4 z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
<div className="bg-background border rounded-lg shadow-lg p-3">
import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
import { Download, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
interface DownloadProgressToastProps {
onClick: () => void;
}
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
const progress = useDownloadProgress();
const queueInfo = useDownloadQueueData();
const hasActiveDownloads = queueInfo.queue.some(item => item.status === "queued" || item.status === "downloading");
if (!hasActiveDownloads) {
return null;
}
return (<div className="fixed bottom-4 left-[calc(56px+1rem)] z-50 animate-in slide-in-from-bottom-5 data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom-5">
<Button variant="outline" className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer" onClick={onClick}>
<div className="flex items-center gap-3">
<Download className="h-4 w-4 text-primary animate-bounce" />
<Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`}/>
<div className="flex flex-col min-w-[80px]">
<p className="text-sm font-medium font-mono tabular-nums">
{progress.mb_downloaded.toFixed(2)} MB
</p>
{progress.speed_mbps > 0 && (
<p className="text-xs text-muted-foreground font-mono tabular-nums">
{progress.speed_mbps > 0 && (<p className="text-xs text-muted-foreground font-mono tabular-nums">
{progress.speed_mbps.toFixed(2)} MB/s
</p>
)}
</p>)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1"/>
</div>
</div>
</div>
);
</Button>
</div>);
}
+231
View File
@@ -0,0 +1,231 @@
import { useEffect, useState } from "react";
import { X, Download, CheckCircle2, XCircle, Clock, FileCheck, Trash2, HardDrive, Zap, Timer } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { GetDownloadQueue, ClearCompletedDownloads } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
interface DownloadQueueProps {
isOpen: boolean;
onClose: () => void;
}
export function DownloadQueue({ isOpen, onClose }: DownloadQueueProps) {
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(new backend.DownloadQueueInfo({
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);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "downloading":
return <Download className="h-4 w-4 text-blue-500 animate-bounce"/>;
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500"/>;
case "failed":
return <XCircle className="h-4 w-4 text-red-500"/>;
case "skipped":
return <FileCheck className="h-4 w-4 text-yellow-500"/>;
case "queued":
return <Clock className="h-4 w-4 text-muted-foreground"/>;
default:
return null;
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
downloading: "default",
completed: "outline",
failed: "destructive",
skipped: "secondary",
queued: "outline",
};
return (<Badge variant={variants[status] || "outline"} className="text-xs">
{status}
</Badge>);
};
const formatDuration = (startTimestamp: number) => {
if (startTimestamp === 0)
return "—";
const now = Math.floor(Date.now() / 1000);
const durationSeconds = now - startTimestamp;
const hours = Math.floor(durationSeconds / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
}
else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
}
else {
return `${seconds}s`;
}
};
return (<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
<div className="flex items-center justify-between mb-4">
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
<div className="flex items-center gap-2">
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (<Button variant="ghost" size="sm" className="h-7 text-xs gap-1.5" onClick={handleClearHistory}>
<Trash2 className="h-3 w-3"/>
Clear History
</Button>)}
<Button variant="ghost" size="icon" className="h-7 w-7 rounded-full hover:bg-muted" onClick={onClose}>
<X className="h-4 w-4"/>
</Button>
</div>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Queued:</span>
<span className="font-semibold">{queueInfo.queued_count}</span>
</div>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500"/>
<span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span>
</div>
<div className="flex items-center gap-1.5">
<FileCheck className="h-3.5 w-3.5 text-yellow-500"/>
<span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span>
</div>
<div className="flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-red-500"/>
<span className="text-muted-foreground">Failed:</span>
<span className="font-semibold">{queueInfo.failed_count}</span>
</div>
</div>
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
<div className="flex items-center gap-1.5">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Downloaded:</span>
<span className="font-semibold font-mono">
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Speed:</span>
<span className="font-semibold font-mono">
{queueInfo.current_speed > 0 && queueInfo.is_downloading
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Timer className="h-3.5 w-3.5 text-muted-foreground"/>
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold font-mono">
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
</span>
</div>
</div>
</DialogHeader>
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
<div className="space-y-2 py-4">
{queueInfo.queue.length === 0 ? (<div className="text-center py-12 text-muted-foreground">
<Download className="h-12 w-12 mx-auto mb-3 opacity-20"/>
<p>No downloads in queue</p>
</div>) : (queueInfo.queue.map((item) => (<div key={item.id} className="border rounded-lg p-3 hover:bg-muted/30 transition-colors">
<div className="flex items-start gap-3">
<div className="mt-1">{getStatusIcon(item.status)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.track_name}</p>
<p className="text-sm text-muted-foreground truncate">
{item.artist_name}
{item.album_name && `${item.album_name}`}
</p>
</div>
{getStatusBadge(item.status)}
</div>
{item.status === "downloading" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
<span>
{item.progress > 0
? `${item.progress.toFixed(2)} MB`
: queueInfo.is_downloading && queueInfo.current_speed > 0
? "Downloading..."
: "Starting..."}
</span>
<span>
{item.speed > 0
? `${item.speed.toFixed(2)} MB/s`
: queueInfo.current_speed > 0
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>)}
{item.status === "completed" && (<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
</div>)}
{item.status === "skipped" && (<div className="mt-1.5 text-xs text-muted-foreground">
File already exists
</div>)}
{item.status === "failed" && item.error_message && (<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
{item.error_message}
</div>)}
{(item.status === "completed" || item.status === "skipped") && item.file_path && (<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
{item.file_path}
</div>)}
</div>
</div>
</div>)))}
</div>
</div>
</DialogContent>
</Dialog>);
}
+96
View File
@@ -0,0 +1,96 @@
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;
}
interface FetchHistoryProps {
history: HistoryItem[];
onSelect: (item: HistoryItem) => void;
onRemove: (id: string) => void;
}
export function FetchHistory({ history, onSelect, onRemove }: FetchHistoryProps) {
if (history.length === 0)
return null;
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) => {
e.stopPropagation();
onRemove(item.id);
}}>
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
</button>
<div className="p-2">
<div className="aspect-square w-full rounded-md overflow-hidden mb-2 bg-muted">
{item.image ? (<img src={item.image} alt={item.name} className="w-full h-full object-cover"/>) : (<div className="w-full h-full flex items-center justify-center text-muted-foreground text-xs">
No Image
</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}>
{item.artist}
</p>
{(() => {
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>);
}
+793
View File
@@ -0,0 +1,793 @@
import { useState, useEffect, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Checkbox } from "@/components/ui/checkbox";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { FolderOpen, RefreshCw, FileMusic, ChevronRight, ChevronDown, Pencil, Eye, Folder, Info, RotateCcw, FileText, Image, Copy, Check, } from "lucide-react";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Spinner } from "@/components/ui/spinner";
import { Badge } from "@/components/ui/badge";
import { SelectFolder } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { getSettings } from "@/lib/settings";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
const ListDirectoryFiles = (path: string): Promise<backend.FileInfo[]> => (window as any)['go']['main']['App']['ListDirectoryFiles'](path);
const PreviewRenameFiles = (files: string[], format: string): Promise<backend.RenamePreview[]> => (window as any)['go']['main']['App']['PreviewRenameFiles'](files, format);
const RenameFilesByMetadata = (files: string[], format: string): Promise<backend.RenameResult[]> => (window as any)['go']['main']['App']['RenameFilesByMetadata'](files, format);
const ReadFileMetadata = (path: string): Promise<backend.AudioMetadata> => (window as any)['go']['main']['App']['ReadFileMetadata'](path);
const IsFFprobeInstalled = (): Promise<boolean> => (window as any)['go']['main']['App']['IsFFprobeInstalled']();
const DownloadFFmpeg = (): Promise<{
success: boolean;
message: string;
error?: string;
}> => (window as any)['go']['main']['App']['DownloadFFmpeg']();
const ReadTextFile = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadTextFile'](path);
const RenameFileTo = (oldPath: string, newName: string): Promise<void> => (window as any)['go']['main']['App']['RenameFileTo'](oldPath, newName);
const ReadImageAsBase64 = (path: string): Promise<string> => (window as any)['go']['main']['App']['ReadImageAsBase64'](path);
interface FileNode {
name: string;
path: string;
is_dir: boolean;
size: number;
children?: FileNode[];
expanded?: boolean;
}
interface FileMetadata {
title: string;
artist: string;
album: string;
album_artist: string;
track_number: number;
disc_number: number;
year: string;
}
type TabType = "track" | "lyric" | "cover";
const FORMAT_PRESETS: Record<string, {
label: string;
template: string;
}> = {
"title": { label: "Title", template: "{title}" },
"title-artist": { label: "Title - Artist", template: "{title} - {artist}" },
"artist-title": { label: "Artist - Title", template: "{artist} - {title}" },
"track-title": { label: "Track. Title", template: "{track}. {title}" },
"track-title-artist": { label: "Track. Title - Artist", template: "{track}. {title} - {artist}" },
"track-artist-title": { label: "Track. Artist - Title", template: "{track}. {artist} - {title}" },
"title-album-artist": { label: "Title - Album Artist", template: "{title} - {album_artist}" },
"track-title-album-artist": { label: "Track. Title - Album Artist", template: "{track}. {title} - {album_artist}" },
"artist-album-title": { label: "Artist - Album - Title", template: "{artist} - {album} - {title}" },
"track-dash-title": { label: "Track - Title", template: "{track} - {title}" },
"disc-track-title": { label: "Disc-Track. Title", template: "{disc}-{track}. {title}" },
"disc-track-title-artist": { label: "Disc-Track. Title - Artist", template: "{disc}-{track}. {title} - {artist}" },
"custom": { label: "Custom...", template: "{title} - {artist}" },
};
const STORAGE_KEY = "spotiflac_file_manager_state";
const DEFAULT_PRESET = "title-artist";
const DEFAULT_CUSTOM_FORMAT = "{title} - {artist}";
function formatFileSize(bytes: number): string {
if (bytes === 0)
return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
export function FileManagerPage() {
const [rootPath, setRootPath] = useState(() => {
const settings = getSettings();
return settings.downloadPath || "";
});
const [allFiles, setAllFiles] = useState<FileNode[]>([]);
const [selectedFiles, setSelectedFiles] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<TabType>("track");
const [formatPreset, setFormatPreset] = useState<string>(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.formatPreset && FORMAT_PRESETS[parsed.formatPreset]) {
return parsed.formatPreset;
}
}
}
catch { }
return DEFAULT_PRESET;
});
const [customFormat, setCustomFormat] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.customFormat)
return parsed.customFormat;
}
}
catch { }
return DEFAULT_CUSTOM_FORMAT;
});
const renameFormat = formatPreset === "custom" ? (customFormat || FORMAT_PRESETS["custom"].template) : FORMAT_PRESETS[formatPreset].template;
const [showPreview, setShowPreview] = useState(false);
const [previewData, setPreviewData] = useState<backend.RenamePreview[]>([]);
const [renaming, setRenaming] = useState(false);
const [previewOnly, setPreviewOnly] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const [showMetadata, setShowMetadata] = useState(false);
const [metadataFile, setMetadataFile] = useState<string>("");
const [metadataInfo, setMetadataInfo] = useState<FileMetadata | null>(null);
const [loadingMetadata, setLoadingMetadata] = useState(false);
const [showFFprobeDialog, setShowFFprobeDialog] = useState(false);
const [installingFFprobe, setInstallingFFprobe] = useState(false);
const [showLyricsPreview, setShowLyricsPreview] = useState(false);
const [lyricsContent, setLyricsContent] = useState("");
const [lyricsFile, setLyricsFile] = useState("");
const [lyricsTab, setLyricsTab] = useState<"synced" | "plain">("synced");
const [copySuccess, setCopySuccess] = useState(false);
const [showCoverPreview, setShowCoverPreview] = useState(false);
const [coverFile, setCoverFile] = useState("");
const [coverData, setCoverData] = useState("");
const [showManualRename, setShowManualRename] = useState(false);
const [manualRenameFile, setManualRenameFile] = useState("");
const [manualRenameName, setManualRenameName] = useState("");
const [manualRenaming, setManualRenaming] = useState(false);
useEffect(() => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ formatPreset, customFormat }));
}
catch { }
}, [formatPreset, customFormat]);
useEffect(() => {
const checkFullscreen = () => {
const isMaximized = window.innerHeight >= window.screen.height * 0.9;
setIsFullscreen(isMaximized);
};
checkFullscreen();
window.addEventListener("resize", checkFullscreen);
window.addEventListener("focus", checkFullscreen);
return () => {
window.removeEventListener("resize", checkFullscreen);
window.removeEventListener("focus", checkFullscreen);
};
}, []);
const filterFilesByType = (nodes: FileNode[], type: TabType): FileNode[] => {
return nodes
.map((node) => {
if (node.is_dir && node.children) {
const filteredChildren = filterFilesByType(node.children, type);
if (filteredChildren.length > 0) {
return { ...node, children: filteredChildren };
}
return null;
}
const ext = node.name.toLowerCase();
if (type === "track" && (ext.endsWith(".flac") || ext.endsWith(".mp3") || ext.endsWith(".m4a")))
return node;
if (type === "lyric" && ext.endsWith(".lrc"))
return node;
if (type === "cover" && (ext.endsWith(".jpg") || ext.endsWith(".jpeg") || ext.endsWith(".png")))
return node;
return null;
})
.filter((node): node is FileNode => node !== null);
};
const loadFiles = useCallback(async () => {
if (!rootPath)
return;
setLoading(true);
try {
const result = await ListDirectoryFiles(rootPath);
if (!result || !Array.isArray(result)) {
setAllFiles([]);
setSelectedFiles(new Set());
return;
}
setAllFiles(result as FileNode[]);
setSelectedFiles(new Set());
}
catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err || "");
if (!errorMsg.toLowerCase().includes("empty") && !errorMsg.toLowerCase().includes("no file")) {
toast.error("Failed to load files", { description: errorMsg || "Unknown error" });
}
setAllFiles([]);
setSelectedFiles(new Set());
}
finally {
setLoading(false);
}
}, [rootPath]);
useEffect(() => {
if (rootPath)
loadFiles();
}, [rootPath, loadFiles]);
const filteredFiles = filterFilesByType(allFiles, activeTab);
const getAllFilesFlat = (nodes: FileNode[]): FileNode[] => {
const result: FileNode[] = [];
for (const node of nodes) {
if (!node.is_dir)
result.push(node);
if (node.children)
result.push(...getAllFilesFlat(node.children));
}
return result;
};
const allAudioFiles = getAllFilesFlat(filterFilesByType(allFiles, "track"));
const allLyricFiles = getAllFilesFlat(filterFilesByType(allFiles, "lyric"));
const allCoverFiles = getAllFilesFlat(filterFilesByType(allFiles, "cover"));
const handleSelectFolder = async () => {
try {
const path = await SelectFolder(rootPath);
if (path)
setRootPath(path);
}
catch (err) {
toast.error("Failed to select folder", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const toggleExpand = (path: string) => {
setAllFiles((prev) => toggleNodeExpand(prev, path));
};
const toggleNodeExpand = (nodes: FileNode[], path: string): FileNode[] => {
return nodes.map((node) => {
if (node.path === path)
return { ...node, expanded: !node.expanded };
if (node.children)
return { ...node, children: toggleNodeExpand(node.children, path) };
return node;
});
};
const toggleSelect = (path: string) => {
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (newSet.has(path))
newSet.delete(path);
else
newSet.add(path);
return newSet;
});
};
const toggleFolderSelect = (node: FileNode) => {
const folderFiles = getAllFilesFlat([node]);
const allSelected = folderFiles.every((f) => selectedFiles.has(f.path));
setSelectedFiles((prev) => {
const newSet = new Set(prev);
if (allSelected)
folderFiles.forEach((f) => newSet.delete(f.path));
else
folderFiles.forEach((f) => newSet.add(f.path));
return newSet;
});
};
const isFolderSelected = (node: FileNode): boolean | "indeterminate" => {
const folderFiles = getAllFilesFlat([node]);
if (folderFiles.length === 0)
return false;
const selectedCount = folderFiles.filter((f) => selectedFiles.has(f.path)).length;
if (selectedCount === 0)
return false;
if (selectedCount === folderFiles.length)
return true;
return "indeterminate";
};
const selectAll = () => setSelectedFiles(new Set(allAudioFiles.map((f) => f.path)));
const deselectAll = () => setSelectedFiles(new Set());
const resetToDefault = () => { setFormatPreset(DEFAULT_PRESET); setCustomFormat(DEFAULT_CUSTOM_FORMAT); setShowResetConfirm(false); };
const handlePreview = async (isPreviewOnly: boolean) => {
if (selectedFiles.size === 0) {
toast.error("No files selected");
return;
}
const hasM4A = Array.from(selectedFiles).some(f => f.toLowerCase().endsWith(".m4a"));
if (hasM4A) {
const installed = await IsFFprobeInstalled();
if (!installed) {
setShowFFprobeDialog(true);
return;
}
}
try {
const result = await PreviewRenameFiles(Array.from(selectedFiles), renameFormat);
setPreviewData(result);
setPreviewOnly(isPreviewOnly);
setShowPreview(true);
}
catch (err) {
toast.error("Failed to generate preview", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const handleShowMetadata = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
if (filePath.toLowerCase().endsWith(".m4a")) {
const installed = await IsFFprobeInstalled();
if (!installed) {
setShowFFprobeDialog(true);
return;
}
}
setMetadataFile(filePath);
setLoadingMetadata(true);
try {
const metadata = await ReadFileMetadata(filePath);
setMetadataInfo(metadata as FileMetadata);
setShowMetadata(true);
}
catch (err) {
toast.error("Failed to read metadata", { description: err instanceof Error ? err.message : "Unknown error" });
setMetadataInfo(null);
}
finally {
setLoadingMetadata(false);
}
};
const handleInstallFFprobe = async () => {
setInstallingFFprobe(true);
try {
const result = await DownloadFFmpeg();
if (result.success) {
toast.success("FFprobe installed successfully");
setShowFFprobeDialog(false);
}
else
toast.error("Failed to install FFprobe", { description: result.error || result.message });
}
catch (err) {
toast.error("Failed to install FFprobe", { description: err instanceof Error ? err.message : "Unknown error" });
}
finally {
setInstallingFFprobe(false);
}
};
const handleShowLyrics = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
setLyricsFile(filePath);
setLyricsTab("synced");
try {
const content = await ReadTextFile(filePath);
setLyricsContent(content);
setShowLyricsPreview(true);
}
catch (err) {
toast.error("Failed to read lyrics file", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const handleShowCover = async (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
setCoverFile(filePath);
try {
const data = await ReadImageAsBase64(filePath);
setCoverData(data);
setShowCoverPreview(true);
}
catch (err) {
toast.error("Failed to load image", { description: err instanceof Error ? err.message : "Unknown error" });
}
};
const getPlainLyrics = (content: string) => {
return content.split('\n').map(line => line.replace(/^\[[\d:.]+\]\s*/, '')).filter(line => !line.startsWith('[') || line.includes(']')).map(line => line.startsWith('[') ? '' : line).join('\n').trim();
};
const formatTimestamp = (timestamp: string): string => {
const match = timestamp.match(/\[(\d+):(\d+)(?:\.(\d+))?\]/);
if (!match)
return timestamp;
const minutes = parseInt(match[1], 10);
const seconds = match[2];
return `${minutes}:${seconds}`;
};
const renderSyncedLyrics = (content: string) => {
if (!content)
return <div className="text-sm text-muted-foreground">No lyrics content</div>;
const lines = content.split('\n');
return lines.map((line, index) => {
if (line.match(/^\[(ti|ar|al|by|length|offset):/i))
return null;
const match = line.match(/^(\[[\d:.]+\])(.*)$/);
if (match) {
const timestamp = match[1];
const text = match[2].trim();
if (!text)
return null;
return (<div key={index} className="flex items-center gap-2 py-1">
<Badge variant="secondary" className="font-mono text-xs shrink-0">
{formatTimestamp(timestamp)}
</Badge>
<span className="text-sm">{text}</span>
</div>);
}
if (!line.trim())
return null;
return (<div key={index} className="py-1">
<span className="text-sm">{line}</span>
</div>);
}).filter(item => item !== null);
};
const handleCopyLyrics = async () => {
try {
const textToCopy = lyricsTab === "synced" ? lyricsContent : getPlainLyrics(lyricsContent);
await navigator.clipboard.writeText(textToCopy);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 500);
}
catch {
toast.error("Failed to copy lyrics");
}
};
const handleManualRename = (filePath: string, e: React.MouseEvent) => {
e.stopPropagation();
const fileName = filePath.split(/[/\\]/).pop() || "";
const nameWithoutExt = fileName.replace(/\.[^.]+$/, "");
setManualRenameFile(filePath);
setManualRenameName(nameWithoutExt);
setShowManualRename(true);
};
const handleConfirmManualRename = async () => {
if (!manualRenameFile || !manualRenameName.trim())
return;
setManualRenaming(true);
try {
await RenameFileTo(manualRenameFile, manualRenameName.trim());
toast.success("File renamed successfully");
setShowManualRename(false);
loadFiles();
}
catch (err) {
toast.error("Failed to rename file", { description: err instanceof Error ? err.message : "Unknown error" });
}
finally {
setManualRenaming(false);
}
};
const handleRename = async () => {
if (selectedFiles.size === 0)
return;
setRenaming(true);
try {
const result = await RenameFilesByMetadata(Array.from(selectedFiles), renameFormat);
const successCount = result.filter((r: backend.RenameResult) => r.success).length;
const failCount = result.filter((r: backend.RenameResult) => !r.success).length;
if (successCount > 0)
toast.success("Rename Complete", { description: `${successCount} file(s) renamed${failCount > 0 ? `, ${failCount} failed` : ""}` });
else
toast.error("Rename Failed", { description: `All ${failCount} file(s) failed to rename` });
setShowPreview(false);
setSelectedFiles(new Set());
loadFiles();
}
catch (err) {
toast.error("Rename Failed", { description: err instanceof Error ? err.message : "Unknown error" });
}
finally {
setRenaming(false);
}
};
const renderTrackTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (<div key={node.path}>
<div className={`flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer ${selectedFiles.has(node.path) ? "bg-primary/10" : ""}`} style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={() => (node.is_dir ? toggleExpand(node.path) : toggleSelect(node.path))}>
{node.is_dir ? (<>
<Checkbox checked={isFolderSelected(node) === true} ref={(el) => {
if (el)
(el as HTMLButtonElement).dataset.state = isFolderSelected(node) === "indeterminate" ? "indeterminate" : isFolderSelected(node) ? "checked" : "unchecked";
}} onCheckedChange={() => toggleFolderSelect(node)} onClick={(e) => e.stopPropagation()} className="shrink-0 data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground"/>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
</>) : (<>
<Checkbox checked={selectedFiles.has(node.path)} onCheckedChange={() => toggleSelect(node.path)} onClick={(e) => e.stopPropagation()} className="shrink-0"/>
<FileMusic className="h-4 w-4 text-primary shrink-0"/>
</>)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleShowMetadata(node.path, e)}>
<Info className="h-3.5 w-3.5 text-muted-foreground"/>
</button>
</TooltipTrigger>
<TooltipContent>View Metadata</TooltipContent>
</Tooltip>
</>)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderTrackTree(node.children, depth + 1)}</div>}
</div>));
};
const renderLyricTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (<div key={node.path}>
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowLyrics(node.path, e)}>
{node.is_dir ? (<>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
</>) : (<FileText className="h-4 w-4 text-blue-500 shrink-0"/>)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
</button>
</TooltipTrigger>
<TooltipContent>Rename</TooltipContent>
</Tooltip>
</>)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderLyricTree(node.children, depth + 1)}</div>}
</div>));
};
const renderCoverTree = (nodes: FileNode[], depth = 0) => {
return nodes.map((node) => (<div key={node.path}>
<div className="flex items-center gap-2 py-1.5 px-2 rounded hover:bg-muted/50 cursor-pointer" style={{ paddingLeft: `${depth * 16 + 8}px` }} onClick={(e) => node.is_dir ? toggleExpand(node.path) : handleShowCover(node.path, e)}>
{node.is_dir ? (<>
{node.expanded ? <ChevronDown className="h-4 w-4 text-muted-foreground shrink-0"/> : <ChevronRight className="h-4 w-4 text-muted-foreground shrink-0"/>}
<Folder className="h-4 w-4 text-yellow-500 shrink-0"/>
</>) : (<Image className="h-4 w-4 text-green-500 shrink-0"/>)}
<span className="truncate text-sm flex-1">
{node.name}
{node.is_dir && <span className="text-muted-foreground ml-1">({getAllFilesFlat([node]).length})</span>}
</span>
{!node.is_dir && (<>
<span className="text-xs text-muted-foreground shrink-0">{formatFileSize(node.size)}</span>
<Tooltip>
<TooltipTrigger asChild>
<button className="p-1 rounded hover:bg-muted shrink-0" onClick={(e) => handleManualRename(node.path, e)}>
<Pencil className="h-3.5 w-3.5 text-muted-foreground"/>
</button>
</TooltipTrigger>
<TooltipContent>Rename</TooltipContent>
</Tooltip>
</>)}
</div>
{node.is_dir && node.expanded && node.children && <div>{renderCoverTree(node.children, depth + 1)}</div>}
</div>));
};
const allSelected = allAudioFiles.length > 0 && selectedFiles.size === allAudioFiles.length;
return (<div className={`space-y-6 ${isFullscreen ? "h-full flex flex-col" : ""}`}>
<div className="flex items-center justify-between shrink-0">
<h1 className="text-2xl font-bold">File Manager</h1>
</div>
<div className="flex items-center gap-2 shrink-0">
<InputWithContext value={rootPath} onChange={(e) => setRootPath(e.target.value)} placeholder="Select a folder..." className="flex-1"/>
<Button onClick={handleSelectFolder}>
<FolderOpen className="h-4 w-4"/>
Browse
</Button>
<Button variant="outline" onClick={loadFiles} disabled={loading || !rootPath}>
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}/>
Refresh
</Button>
</div>
<div className="flex gap-2 border-b shrink-0">
<Button variant={activeTab === "track" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("track")} className="rounded-b-none">
<FileMusic className="h-4 w-4"/>
Track ({allAudioFiles.length})
</Button>
<Button variant={activeTab === "lyric" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("lyric")} className="rounded-b-none">
<FileText className="h-4 w-4"/>
Lyric ({allLyricFiles.length})
</Button>
<Button variant={activeTab === "cover" ? "default" : "ghost"} size="sm" onClick={() => setActiveTab("cover")} className="rounded-b-none">
<Image className="h-4 w-4"/>
Cover ({allCoverFiles.length})
</Button>
</div>
{activeTab === "track" && (<div className="space-y-2 shrink-0">
<div className="flex items-center gap-2">
<Label className="text-sm">Rename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-xs whitespace-nowrap">Variables: {"{title}"}, {"{artist}"}, {"{album}"}, {"{album_artist}"}, {"{track}"}, {"{disc}"}, {"{year}"}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Select value={formatPreset} onValueChange={setFormatPreset}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{Object.entries(FORMAT_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent>
</Select>
{formatPreset === "custom" && (<InputWithContext value={customFormat} onChange={(e) => setCustomFormat(e.target.value)} placeholder="{artist} - {title}" className="flex-1"/>)}
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={() => setShowResetConfirm(true)}>
<RotateCcw className="h-4 w-4"/>
</Button>
</TooltipTrigger>
<TooltipContent>Reset to Default</TooltipContent>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{renameFormat.replace(/\{title\}/g, "All The Stars").replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
</p>
</div>)}
<div className={`border rounded-lg ${isFullscreen ? "flex-1 flex flex-col min-h-0" : ""}`}>
{activeTab === "track" && (<div className="flex items-center justify-between p-3 border-b bg-muted/30 shrink-0">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={allSelected ? deselectAll : selectAll}>
{allSelected ? "Deselect All" : "Select All"}
</Button>
<span className="text-sm text-muted-foreground">{selectedFiles.size} of {allAudioFiles.length} file(s) selected</span>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => handlePreview(true)} disabled={selectedFiles.size === 0 || loading}>
<Eye className="h-4 w-4"/>
Preview
</Button>
<Button size="sm" onClick={() => handlePreview(false)} disabled={selectedFiles.size === 0 || loading}>
<Pencil className="h-4 w-4"/>
Rename
</Button>
</div>
</div>)}
<div className={`overflow-y-auto p-2 ${isFullscreen ? "flex-1 min-h-0" : "max-h-[400px]"}`}>
{loading ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : filteredFiles.length === 0 ? (<div className="text-center py-8 text-muted-foreground">
{rootPath ? `No ${activeTab} files found` : "Select a folder to browse"}
</div>) : (activeTab === "track" ? renderTrackTree(filteredFiles) :
activeTab === "lyric" ? renderLyricTree(filteredFiles) :
renderCoverTree(filteredFiles))}
</div>
</div>
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription>This will reset the rename format to "Title - Artist". Your custom format will be lost.</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
<Button onClick={resetToDefault}>Reset</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showPreview} onOpenChange={setShowPreview}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
<DialogHeader>
<DialogTitle>Rename Preview</DialogTitle>
<DialogDescription>Review the changes before renaming. Files with errors will be skipped.</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-y-auto space-y-2 py-4">
{previewData.map((item, index) => (<div key={index} className={`p-3 rounded-lg border ${item.error ? "border-destructive/50 bg-destructive/5" : "border-border"}`}>
<div className="text-sm">
<div className="text-muted-foreground break-all">{item.old_name}</div>
{item.error ? <div className="text-destructive text-xs mt-1">{item.error}</div> : <div className="text-primary font-medium break-all mt-1"> {item.new_name}</div>}
</div>
</div>))}
</div>
<DialogFooter>
{previewOnly ? (<Button onClick={() => setShowPreview(false)}>Close</Button>) : (<>
<Button variant="outline" onClick={() => setShowPreview(false)}>Cancel</Button>
<Button onClick={handleRename} disabled={renaming}>
{renaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : <>Rename {previewData.filter((p) => !p.error).length} File(s)</>}
</Button>
</>)}
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showMetadata} onOpenChange={setShowMetadata}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>File Metadata</DialogTitle>
<DialogDescription className="break-all">{metadataFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
{loadingMetadata ? (<div className="flex items-center justify-center py-8"><Spinner className="h-6 w-6"/></div>) : metadataInfo ? (<div className="space-y-3 py-2">
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Title</span><span>{metadataInfo.title || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Artist</span><span>{metadataInfo.artist || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album</span><span>{metadataInfo.album || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Album Artist</span><span>{metadataInfo.album_artist || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Track</span><span>{metadataInfo.track_number || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Disc</span><span>{metadataInfo.disc_number || "-"}</span></div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm"><span className="text-muted-foreground">Year</span><span>{metadataInfo.year ? metadataInfo.year.substring(0, 4) : "-"}</span></div>
</div>) : (<div className="text-center py-4 text-muted-foreground">No metadata available</div>)}
<DialogFooter><Button onClick={() => setShowMetadata(false)}>Close</Button></DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showFFprobeDialog} onOpenChange={setShowFFprobeDialog}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>FFprobe Required</DialogTitle>
<DialogDescription>Reading M4A metadata requires FFprobe. Would you like to download and install it now?</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowFFprobeDialog(false)} disabled={installingFFprobe}>Cancel</Button>
<Button onClick={handleInstallFFprobe} disabled={installingFFprobe}>
{installingFFprobe ? <><Spinner className="h-4 w-4"/>Installing...</> : "Install FFprobe"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showLyricsPreview} onOpenChange={setShowLyricsPreview}>
<DialogContent className="max-w-lg max-h-[80vh] overflow-hidden flex flex-col [&>button]:hidden">
<DialogHeader>
<DialogTitle>Lyrics Preview</DialogTitle>
<DialogDescription className="break-all">{lyricsFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
<div className="flex gap-2 border-b pb-2">
<Button variant={lyricsTab === "synced" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("synced")}>Synced</Button>
<Button variant={lyricsTab === "plain" ? "default" : "ghost"} size="sm" onClick={() => setLyricsTab("plain")}>Plain</Button>
</div>
<div className="flex-1 overflow-y-auto py-4">
{lyricsTab === "synced" ? (<div className="bg-muted/30 p-4 rounded-lg space-y-0">
{renderSyncedLyrics(lyricsContent)}
</div>) : (<pre className="text-sm whitespace-pre-wrap font-mono bg-muted/30 p-4 rounded-lg">
{getPlainLyrics(lyricsContent) || "No lyrics content"}
</pre>)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleCopyLyrics} className="gap-1.5">
{copySuccess ? <Check className="h-4 w-4"/> : <Copy className="h-4 w-4"/>}
Copy
</Button>
<Button onClick={() => setShowLyricsPreview(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showCoverPreview} onOpenChange={setShowCoverPreview}>
<DialogContent className="max-w-lg [&>button]:hidden">
<DialogHeader>
<DialogTitle>Cover Preview</DialogTitle>
<DialogDescription className="break-all">{coverFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center p-4">
{coverData ? <img src={coverData} alt="Cover" className="max-w-full max-h-[350px] rounded-lg object-contain"/> : <div className="text-muted-foreground">Loading...</div>}
</div>
<DialogFooter><Button onClick={() => setShowCoverPreview(false)}>Close</Button></DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={showManualRename} onOpenChange={setShowManualRename}>
<DialogContent className="max-w-2xl [&>button]:hidden">
<DialogHeader>
<DialogTitle>Rename File</DialogTitle>
<DialogDescription className="break-all">{manualRenameFile.split(/[/\\]/).pop()}</DialogDescription>
</DialogHeader>
<div className="py-4">
<Label htmlFor="newName" className="text-sm">New Name</Label>
<div className="flex items-center gap-2 mt-2">
<InputWithContext id="newName" value={manualRenameName} onChange={(e) => setManualRenameName(e.target.value)} placeholder="Enter new name" className="flex-1" onKeyDown={(e) => {
if (e.key === "Enter" && !manualRenaming)
handleConfirmManualRename();
}}/>
<span className="text-sm text-muted-foreground shrink-0">{manualRenameFile.match(/\.[^.]+$/)?.[0] || ""}</span>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowManualRename(false)} disabled={manualRenaming}>Cancel</Button>
<Button onClick={handleConfirmManualRename} disabled={manualRenaming || !manualRenameName.trim()}>
{manualRenaming ? <><Spinner className="h-4 w-4"/>Renaming...</> : "Rename"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
+26 -63
View File
@@ -1,79 +1,42 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Settings } from "@/components/Settings";
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;
version: string;
hasUpdate: boolean;
releaseDate?: string | null;
}
export function Header({ version, hasUpdate }: HeaderProps) {
return (
<div className="relative">
export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
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">
<Badge variant="default" asChild>
<a
href="https://github.com/afkarxyz/SpotiFLAC/releases"
target="_blank"
rel="noopener noreferrer"
className="cursor-pointer hover:opacity-80 transition-opacity"
>
v{version}
</a>
</Badge>
{hasUpdate && (
<span className="absolute -top-1 -right-1 flex h-3 w-3">
<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">
v{version}
</button>
</Badge>
</TooltipTrigger>
{hasUpdate && releaseDate && (<TooltipContent>
<p>{formatRelativeTime(releaseDate)}</p>
</TooltipContent>)}
</Tooltip>
{hasUpdate && (<span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500"></span>
</span>
)}
</span>)}
</div>
</div>
<p className="text-muted-foreground">
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music no account required.
Get Spotify tracks in true FLAC from Tidal, Qobuz & Amazon Music no account required.
</p>
</div>
<div className="absolute right-0 top-0 flex gap-2">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" size="icon" asChild>
<a
href="https://github.com/afkarxyz/SpotiFLAC/issues"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub Issues"
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>Report bug or request feature</p>
</TooltipContent>
</Tooltip>
<Settings />
</div>
</div>
);
</div>);
}
+18
View File
@@ -0,0 +1,18 @@
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`}>
<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`}>
<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>);
+116 -134
View File
@@ -1,168 +1,150 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Download, FolderOpen, ImageDown, FileText } 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 } from "@/types/api";
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>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: { name: string; artists: string } | null;
currentPage: number;
itemsPerPage: number;
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) => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
trackList: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
bulkDownloadType: "all" | "selected" | null;
downloadProgress: number;
currentDownloadInfo: {
name: string;
artists: string;
} | null;
currentPage: number;
itemsPerPage: number;
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
isBulkDownloadingLyrics?: boolean;
onSearchChange: (value: string) => void;
onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllLyrics?: () => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void;
onDownloadSelected: () => void;
onStopDownload: () => void;
onOpenFolder: () => void;
onPageChange: (page: number) => void;
onAlbumClick: (album: {
id: string;
name: string;
external_urls: string;
}) => void;
onArtistClick: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onTrackClick: (track: TrackMetadata) => void;
}
export function PlaylistInfo({
playlistInfo,
trackList,
searchQuery,
sortBy,
selectedTracks,
downloadedTracks,
downloadingTrack,
isDownloading,
bulkDownloadType,
downloadProgress,
currentDownloadInfo,
currentPage,
itemsPerPage,
onSearchChange,
onSortChange,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onDownloadAll,
onDownloadSelected,
onStopDownload,
onOpenFolder,
onPageChange,
}: PlaylistInfoProps) {
return (
<div className="space-y-6">
export function PlaylistInfo({ playlistInfo, trackList, searchQuery, sortBy, selectedTracks, downloadedTracks, failedTracks, skippedTracks, downloadingTrack, isDownloading, bulkDownloadType, downloadProgress, currentDownloadInfo, currentPage, itemsPerPage, downloadedLyrics, failedLyrics, skippedLyrics, downloadingLyricsTrack, checkingAvailabilityTrack, availabilityMap, downloadedCovers, failedCovers, skippedCovers, downloadingCoverTrack, isBulkDownloadingCovers, isBulkDownloadingLyrics, onSearchChange, onSortChange, onToggleTrack, onToggleSelectAll, onDownloadTrack, onDownloadLyrics, onDownloadCover, onCheckAvailability, onDownloadAllLyrics, onDownloadAllCovers, onDownloadAll, onDownloadSelected, onStopDownload, onOpenFolder, onPageChange, onAlbumClick, onArtistClick, onTrackClick, }: PlaylistInfoProps) {
return (<div className="space-y-6">
<Card>
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{playlistInfo.owner.images && (
<img
src={playlistInfo.owner.images}
alt={playlistInfo.owner.name}
className="w-48 h-48 rounded-md shadow-lg object-cover"
/>
)}
{playlistInfo.cover && (<img src={playlistInfo.cover} alt={playlistInfo.owner.name} className="w-48 h-48 rounded-md shadow-lg object-cover"/>)}
<div className="flex-1 space-y-4">
<div className="space-y-2">
<p className="text-sm font-medium">Playlist</p>
<h2 className="text-4xl font-bold">{playlistInfo.owner.name}</h2>
{playlistInfo.description && (<p className="text-sm text-muted-foreground">{playlistInfo.description}</p>)}
<div className="flex items-center gap-2 text-sm">
<span className="font-medium">{playlistInfo.owner.display_name}</span>
<div className="flex items-center gap-2">
{playlistInfo.owner.images && (<img src={playlistInfo.owner.images} alt={playlistInfo.owner.display_name} className="w-5 h-5 rounded-full object-cover"/>)}
<span className="font-medium">{playlistInfo.owner.display_name}</span>
</div>
<span></span>
<span>{playlistInfo.tracks.total} songs</span>
<span>
{playlistInfo.tracks.total} {playlistInfo.tracks.total === 1 ? "song" : "songs"}
</span>
<span></span>
<span>{playlistInfo.followers.total.toLocaleString()} followers</span>
</div>
</div>
<div className="flex gap-2">
<Button
onClick={onDownloadAll}
disabled={isDownloading}
>
{isDownloading && bulkDownloadType === "all" ? (
<Spinner />
) : (
<Download className="h-4 w-4" />
)}
<div className="flex gap-2 flex-wrap">
<Button onClick={onDownloadAll} disabled={isDownloading}>
{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" />
)}
{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>
)}
{downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
</Button>)}
{onDownloadAllLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllLyrics} variant="outline" disabled={isBulkDownloadingLyrics}>
{isBulkDownloadingLyrics ? <Spinner /> : <FileText className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Lyrics</p>
</TooltipContent>
</Tooltip>)}
{onDownloadAllCovers && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={onDownloadAllCovers} variant="outline" disabled={isBulkDownloadingCovers}>
{isBulkDownloadingCovers ? <Spinner /> : <ImageDown className="h-4 w-4"/>}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download All Covers</p>
</TooltipContent>
</Tooltip>)}
{downloadedTracks.size > 0 && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
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}
downloadingTrack={downloadingTrack}
isDownloading={isDownloading}
currentPage={currentPage}
itemsPerPage={itemsPerPage}
showCheckboxes={true}
hideAlbumColumn={false}
onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack}
onPageChange={onPageChange}
/>
<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>);
}
+18 -33
View File
@@ -1,41 +1,25 @@
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Search, ArrowUpDown } from "lucide-react";
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"
/>
<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>
@@ -45,10 +29,11 @@ export function SearchAndSort({
<SelectItem value="artist-desc">Artist (Z-A)</SelectItem>
<SelectItem value="duration-asc">Duration (Short)</SelectItem>
<SelectItem value="duration-desc">Duration (Long)</SelectItem>
<SelectItem value="plays-asc">Plays (Low)</SelectItem>
<SelectItem value="plays-desc">Plays (High)</SelectItem>
<SelectItem value="downloaded">Downloaded</SelectItem>
<SelectItem value="not-downloaded">Not Downloaded</SelectItem>
</SelectContent>
</Select>
</div>
);
</div>);
}
+362 -63
View File
@@ -1,74 +1,373 @@
import { useState, useEffect, useRef } from "react";
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Card, CardContent } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Search, Info, XCircle } from "lucide-react";
import { CloudDownload, Info, XCircle, Link, Search, X, ChevronDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { FetchHistory } from "@/components/FetchHistory";
import type { HistoryItem } from "@/components/FetchHistory";
import { SearchSpotify, SearchSpotifyByType } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
import { cn } from "@/lib/utils";
type ResultTab = "tracks" | "albums" | "artists" | "playlists";
const RECENT_SEARCHES_KEY = "spotiflac_recent_searches";
const MAX_RECENT_SEARCHES = 8;
const SEARCH_LIMIT = 50;
interface SearchBarProps {
url: string;
loading: boolean;
onUrlChange: (url: string) => void;
onFetch: () => void;
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;
}
export function SearchBar({ url, loading, onUrlChange, onFetch }: SearchBarProps) {
return (
<Card>
<CardContent className="px-6 py-6 space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="spotify-url">Spotify URL</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="right">
<p>Supports track, album, playlist, and artist URLs</p>
</TooltipContent>
</Tooltip>
export function SearchBar({ url, loading, onUrlChange, onFetch, onFetchUrl, history, onHistorySelect, onHistoryRemove, hasResult, searchMode, onSearchModeChange, }: SearchBarProps) {
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState<backend.SearchResponse | null>(null);
const [isSearching, setIsSearching] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [lastSearchedQuery, setLastSearchedQuery] = useState("");
const [activeTab, setActiveTab] = useState<ResultTab>("tracks");
const [recentSearches, setRecentSearches] = useState<string[]>([]);
const [hasMore, setHasMore] = useState<Record<ResultTab, boolean>>({
tracks: false,
albums: false,
artists: false,
playlists: false,
});
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
try {
const saved = localStorage.getItem(RECENT_SEARCHES_KEY);
if (saved) {
setRecentSearches(JSON.parse(saved));
}
}
catch (error) {
console.error("Failed to load recent searches:", error);
}
}, []);
const saveRecentSearch = (query: string) => {
const trimmed = query.trim();
if (!trimmed)
return;
setRecentSearches((prev) => {
const filtered = prev.filter((s) => s.toLowerCase() !== trimmed.toLowerCase());
const updated = [trimmed, ...filtered].slice(0, MAX_RECENT_SEARCHES);
try {
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
}
catch (error) {
console.error("Failed to save recent searches:", error);
}
return updated;
});
};
const removeRecentSearch = (query: string) => {
setRecentSearches((prev) => {
const updated = prev.filter((s) => s !== query);
try {
localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(updated));
}
catch (error) {
console.error("Failed to save recent searches:", error);
}
return updated;
});
};
useEffect(() => {
if (!searchMode || !searchQuery.trim()) {
return;
}
if (searchQuery.trim() === lastSearchedQuery) {
return;
}
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(async () => {
setIsSearching(true);
try {
const results = await SearchSpotify({ query: searchQuery, limit: SEARCH_LIMIT });
setSearchResults(results);
setLastSearchedQuery(searchQuery.trim());
saveRecentSearch(searchQuery.trim());
setHasMore({
tracks: results.tracks.length === SEARCH_LIMIT,
albums: results.albums.length === SEARCH_LIMIT,
artists: results.artists.length === SEARCH_LIMIT,
playlists: results.playlists.length === SEARCH_LIMIT,
});
if (results.tracks.length > 0)
setActiveTab("tracks");
else if (results.albums.length > 0)
setActiveTab("albums");
else if (results.artists.length > 0)
setActiveTab("artists");
else if (results.playlists.length > 0)
setActiveTab("playlists");
}
catch (error) {
console.error("Search failed:", error);
setSearchResults(null);
}
finally {
setIsSearching(false);
}
}, 400);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, searchMode, lastSearchedQuery]);
const handleLoadMore = async () => {
if (!searchResults || !lastSearchedQuery || isLoadingMore)
return;
const typeMap: Record<ResultTab, string> = {
tracks: "track",
albums: "album",
artists: "artist",
playlists: "playlist",
};
const currentCount = getTabCount(activeTab);
setIsLoadingMore(true);
try {
const moreResults = await SearchSpotifyByType({
query: lastSearchedQuery,
search_type: typeMap[activeTab],
limit: SEARCH_LIMIT,
offset: currentCount,
});
if (moreResults.length > 0) {
setSearchResults((prev) => {
if (!prev)
return prev;
const updated = new backend.SearchResponse({
tracks: activeTab === "tracks" ? [...prev.tracks, ...moreResults] : prev.tracks,
albums: activeTab === "albums" ? [...prev.albums, ...moreResults] : prev.albums,
artists: activeTab === "artists" ? [...prev.artists, ...moreResults] : prev.artists,
playlists: activeTab === "playlists" ? [...prev.playlists, ...moreResults] : prev.playlists,
});
return updated;
});
}
setHasMore((prev) => ({
...prev,
[activeTab]: moreResults.length === SEARCH_LIMIT,
}));
}
catch (error) {
console.error("Load more failed:", error);
}
finally {
setIsLoadingMore(false);
}
};
const handleResultClick = (externalUrl: string) => {
onSearchModeChange(false);
onFetchUrl(externalUrl);
};
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const hasAnyResults = searchResults && (searchResults.tracks.length > 0 ||
searchResults.albums.length > 0 ||
searchResults.artists.length > 0 ||
searchResults.playlists.length > 0);
const getTabCount = (tab: ResultTab): number => {
if (!searchResults)
return 0;
switch (tab) {
case "tracks": return searchResults.tracks.length;
case "albums": return searchResults.albums.length;
case "artists": return searchResults.artists.length;
case "playlists": return searchResults.playlists.length;
}
};
const tabs: {
key: ResultTab;
label: string;
}[] = [
{ key: "tracks", label: "Tracks" },
{ key: "albums", label: "Albums" },
{ key: "artists", label: "Artists" },
{ key: "playlists", label: "Playlists" },
];
return (<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="flex items-center bg-muted rounded-md p-1">
<button type="button" onClick={() => onSearchModeChange(false)} className={cn("flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer", !searchMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")}>
<Link className="h-3.5 w-3.5"/>
URL
</button>
<button type="button" onClick={() => onSearchModeChange(true)} className={cn("flex items-center gap-1.5 px-2.5 py-1 rounded text-sm font-medium transition-colors cursor-pointer", searchMode
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground")}>
<Search className="h-3.5 w-3.5"/>
Search
</button>
</div>
<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"
onClick={() => onUrlChange("")}
>
<XCircle className="h-4 w-4" />
</button>
)}
</div>
<Button onClick={onFetch} disabled={loading}>
{loading ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-4 w-4 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="right">
{!searchMode ? (<>
<p>Supports track, album, playlist, and artist URLs</p>
<p className="mt-1">Note: Playlist must be public (not private)</p>
</>) : (<p>Search for tracks, albums, artists, or playlists</p>)}
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<div className="relative flex-1">
{!searchMode ? (<>
<InputWithContext id="spotify-url" placeholder="https://open.spotify.com/..." value={url} onChange={(e) => onUrlChange(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onFetch()} className="pr-8"/>
{url && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => onUrlChange("")}>
<XCircle className="h-4 w-4"/>
</button>)}
</>) : (<>
<InputWithContext id="spotify-search" placeholder="Search tracks, albums, artists..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pr-8"/>
{searchQuery && (<button type="button" className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors cursor-pointer" onClick={() => {
setSearchQuery("");
setSearchResults(null);
setLastSearchedQuery("");
}}>
<XCircle className="h-4 w-4"/>
</button>)}
</>)}
</div>
{!searchMode && (<Button onClick={onFetch} disabled={loading}>
{loading ? (<>
<Spinner />
Fetching...
</>
) : (
<>
<Search className="h-4 w-4" />
</>) : (<>
<CloudDownload className="h-4 w-4"/>
Fetch
</>
)}
</Button>
</div>
</>)}
</Button>)}
</div>
</CardContent>
</Card>
);
</div>
{!searchMode && !hasResult && (<FetchHistory history={history} onSelect={onHistorySelect} onRemove={onHistoryRemove}/>)}
{searchMode && (<div className="space-y-4">
{!searchQuery && !searchResults && recentSearches.length > 0 && (<div className="space-y-2">
<p className="text-sm text-muted-foreground">Recent Searches</p>
<div className="flex flex-wrap gap-2">
{recentSearches.map((query) => (<div key={query} className="group relative flex items-center px-3 py-1.5 bg-muted hover:bg-accent rounded-full text-sm cursor-pointer transition-colors" onClick={() => setSearchQuery(query)}>
<span>{query}</span>
<button type="button" className="absolute -top-1.5 -right-1.5 z-10 w-5 h-5 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-all cursor-pointer shadow-sm" onClick={(e) => {
e.stopPropagation();
removeRecentSearch(query);
}}>
<X className="h-3 w-3 text-red-900" strokeWidth={3}/>
</button>
</div>))}
</div>
</div>)}
{isSearching && (<div className="flex items-center justify-center py-8">
<Spinner />
<span className="ml-2 text-muted-foreground">Searching...</span>
</div>)}
{!isSearching && searchQuery && !hasAnyResults && (<div className="text-center py-8 text-muted-foreground">
No results found for "{searchQuery}"
</div>)}
{!isSearching && hasAnyResults && (<>
<div className="flex gap-1 border-b">
{tabs.map((tab) => {
const count = getTabCount(tab.key);
if (count === 0)
return null;
return (<button key={tab.key} type="button" onClick={() => setActiveTab(tab.key)} className={cn("px-4 py-2 text-sm font-medium transition-colors cursor-pointer border-b-2 -mb-px", activeTab === tab.key
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground")}>
{tab.label} ({count})
</button>);
})}
</div>
<div className="grid gap-2">
{activeTab === "tracks" && searchResults?.tracks.map((track) => (<button key={track.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(track.external_urls)}>
{track.images ? (<img src={track.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{track.name}</p>
<p className="text-sm text-muted-foreground truncate">{track.artists}</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{formatDuration(track.duration_ms || 0)}
</span>
</button>))}
{activeTab === "albums" && searchResults?.albums.map((album) => (<button key={album.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(album.external_urls)}>
{album.images ? (<img src={album.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{album.name}</p>
<p className="text-sm text-muted-foreground truncate">{album.artists}</p>
</div>
<span className="text-sm text-muted-foreground shrink-0">
{album.release_date || ""}
</span>
</button>))}
{activeTab === "artists" && searchResults?.artists.map((artist) => (<button key={artist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(artist.external_urls)}>
{artist.images ? (<img src={artist.images} alt="" className="w-12 h-12 rounded-full object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded-full bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{artist.name}</p>
<p className="text-sm text-muted-foreground">Artist</p>
</div>
</button>))}
{activeTab === "playlists" && searchResults?.playlists.map((playlist) => (<button key={playlist.id} type="button" className="flex items-center gap-3 p-3 rounded-lg bg-card hover:bg-accent border cursor-pointer text-left transition-colors" onClick={() => handleResultClick(playlist.external_urls)}>
{playlist.images ? (<img src={playlist.images} alt="" className="w-12 h-12 rounded object-cover shrink-0"/>) : (<div className="w-12 h-12 rounded bg-muted shrink-0"/>)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{playlist.name}</p>
<p className="text-sm text-muted-foreground truncate">
{playlist.owner || ""}
</p>
</div>
</button>))}
</div>
{hasMore[activeTab] && (<div className="flex justify-center pt-2">
<Button variant="outline" onClick={handleLoadMore} disabled={isLoadingMore}>
{isLoadingMore ? (<>
<Spinner />
Loading...
</>) : (<>
<ChevronDown className="h-4 w-4"/>
Load More
</>)}
</Button>
</div>)}
</>)}
</div>)}
</div>);
}
-387
View File
@@ -1,387 +0,0 @@
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info } from "lucide-react";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App";
// Service Icons
const TidalIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>
);
const DeezerIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
</svg>
);
const QobuzIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor" className="inline-block w-[1.1em] h-[1.1em] mr-2">
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>
);
const AmazonIcon = () => (
<svg viewBox="0 0 24 24" fill="currentColor" style={{ display: "block", width: "1.1em", height: "1.1em" }} className="inline-block mr-2">
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>
);
export function Settings() {
const [open, setOpen] = useState(false);
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [, setIsLoadingDefaults] = useState(false);
// Apply saved settings
useEffect(() => {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
// Setup listener for system theme changes
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(savedSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}, [savedSettings.themeMode, savedSettings.theme]);
// Apply temp settings for preview when dialog is open
useEffect(() => {
if (open) {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
// Setup listener for system theme changes during preview
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (tempSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(tempSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => {
mediaQuery.removeEventListener("change", handleChange);
};
}
}, [open, tempSettings.themeMode, tempSettings.theme]);
useEffect(() => {
// Load settings with defaults from backend on mount
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
setIsLoadingDefaults(true);
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
setIsLoadingDefaults(false);
}
};
loadDefaults();
}, []);
// Reset temp settings when dialog opens
useEffect(() => {
if (open) {
setTempSettings(savedSettings);
}
}, [open, savedSettings]);
const handleSave = () => {
saveSettings(tempSettings);
setSavedSettings(tempSettings);
setOpen(false);
};
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
setSavedSettings(defaultSettings);
// Apply default theme mode and theme
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
};
const handleCancel = () => {
// Revert to saved settings
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
setTempSettings(savedSettings);
setOpen(false);
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
// Dialog is closing, revert to saved settings
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
setTempSettings(savedSettings);
}
setOpen(newOpen);
};
const handleDownloadPathChange = (value: string) => {
setTempSettings((prev) => ({ ...prev, downloadPath: value }));
};
const handleDownloaderChange = (value: "auto" | "deezer" | "tidal" | "qobuz" | "amazon") => {
setTempSettings((prev) => ({ ...prev, downloader: value }));
};
const handleThemeChange = (value: string) => {
setTempSettings((prev) => ({ ...prev, theme: value }));
};
const handleThemeModeChange = (value: "auto" | "light" | "dark") => {
setTempSettings((prev) => ({ ...prev, themeMode: value }));
};
const handleBrowseFolder = async () => {
try {
// Call backend to open folder selection dialog
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
console.log("Selected path:", selectedPath);
if (selectedPath && selectedPath.trim() !== "") {
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
} else {
console.log("No folder selected or user cancelled");
}
} catch (error) {
console.error("Error selecting folder:", error);
alert(`Error selecting folder: ${error}`);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<SettingsIcon className="h-5 w-5" />
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[700px] flex flex-col" aria-describedby={undefined}>
<DialogHeader>
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-6 py-2">
{/* Left Column */}
<div className="space-y-4">
{/* Download Path */}
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext
id="download-path"
value={tempSettings.downloadPath}
onChange={(e) => handleDownloadPathChange(e.target.value)}
placeholder="C:\Users\YourUsername\Music"
/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4" />
Browse
</Button>
</div>
</div>
{/* Source Selection */}
<div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<Select
value={tempSettings.downloader}
onValueChange={handleDownloaderChange}
>
<SelectTrigger id="downloader">
<SelectValue placeholder="Select a source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal">
<span className="flex items-center">
<TidalIcon />
Tidal
</span>
</SelectItem>
<SelectItem value="deezer">
<span className="flex items-center">
<DeezerIcon />
Deezer
</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center">
<QobuzIcon />
Qobuz
</span>
</SelectItem>
<SelectItem value="amazon">
<span className="flex items-center">
<AmazonIcon />
Amazon Music
</span>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Theme Mode Selection */}
<div className="space-y-2">
<Label htmlFor="theme-mode">Theme</Label>
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
<SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
{/* Theme Color Selection */}
<div className="space-y-2">
<Label htmlFor="theme">Theme Color</Label>
<Select value={tempSettings.theme} onValueChange={handleThemeChange}>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (
<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: theme.cssVars.light.primary }}
/>
{theme.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{/* Right Column */}
<div className="space-y-4">
{/* Filename Format */}
<div className="space-y-2">
<Label className="text-sm">Filename Format</Label>
<RadioGroup
value={tempSettings.filenameFormat}
onValueChange={(value) => setTempSettings(prev => ({ ...prev, filenameFormat: value as any }))}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="title-artist" id="title-artist" />
<Label htmlFor="title-artist" className="cursor-pointer font-normal text-sm">Title - Artist</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="artist-title" id="artist-title" />
<Label htmlFor="artist-title" className="cursor-pointer font-normal text-sm">Artist - Title</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="title" id="title" />
<Label htmlFor="title" className="cursor-pointer font-normal text-sm">Title</Label>
</div>
</RadioGroup>
</div>
<div className="border-t" />
{/* Folder Settings */}
<div className="space-y-2">
<h3 className="font-medium text-sm">Folder Settings</h3>
<div className="flex items-center gap-2">
<Checkbox
id="track-number"
checked={tempSettings.trackNumber}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, trackNumber: checked as boolean }))}
/>
<Label htmlFor="track-number" className="cursor-pointer text-sm">Track Number</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
<p className="text-xs text-center">Adds track numbers to filenames. Uses album track numbers when Album Subfolder is enabled, otherwise uses playlist position</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="artist-subfolder"
checked={tempSettings.artistSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, artistSubfolder: checked as boolean }))}
/>
<Label htmlFor="artist-subfolder" className="cursor-pointer text-sm">Artist Subfolder <span className="font-normal">(Playlist only)</span></Label>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="album-subfolder"
checked={tempSettings.albumSubfolder}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, albumSubfolder: checked as boolean }))}
/>
<Label htmlFor="album-subfolder" className="cursor-pointer text-sm">Album Subfolder <span className="font-normal">(Playlist & Discography)</span></Label>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:justify-between">
<Button variant="outline" onClick={handleReset} size="sm" className="gap-1.5">
<RotateCcw className="h-3.5 w-3.5" />
Reset to Default
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel} size="sm">
Cancel
</Button>
<Button onClick={handleSave} size="sm" className="gap-1.5">
<Save className="h-3.5 w-3.5" />
Save Changes
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+361
View File
@@ -0,0 +1,361 @@
import { useState, useEffect, useCallback } from "react";
import { flushSync } from "react-dom";
import { Button } from "@/components/ui/button";
import { InputWithContext } from "@/components/ui/input-with-context";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { FolderOpen, Save, RotateCcw, Info } from "lucide-react";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, FOLDER_PRESETS, FILENAME_PRESETS, TEMPLATE_VARIABLES, type Settings as SettingsType, type FontFamily, type FolderPreset, type FilenamePreset } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
const TidalIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>);
const QobuzIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>);
const AmazonIcon = () => (<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>);
interface SettingsPageProps {
onUnsavedChangesChange?: (hasUnsavedChanges: boolean) => void;
onResetRequest?: (resetFn: () => void) => void;
}
export function SettingsPage({ onUnsavedChangesChange, onResetRequest }: SettingsPageProps) {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
const [showResetConfirm, setShowResetConfirm] = useState(false);
const hasUnsavedChanges = JSON.stringify(savedSettings) !== JSON.stringify(tempSettings);
const resetToSaved = useCallback(() => {
const freshSavedSettings = getSettings();
flushSync(() => {
setTempSettings(freshSavedSettings);
setIsDark(document.documentElement.classList.contains('dark'));
});
}, []);
useEffect(() => {
if (onResetRequest) {
onResetRequest(resetToSaved);
}
}, [onResetRequest, resetToSaved]);
useEffect(() => {
onUnsavedChangesChange?.(hasUnsavedChanges);
}, [hasUnsavedChanges, onUnsavedChangesChange]);
useEffect(() => {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
applyTheme(savedSettings.theme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [savedSettings.themeMode, savedSettings.theme]);
useEffect(() => {
applyThemeMode(tempSettings.themeMode);
applyTheme(tempSettings.theme);
applyFont(tempSettings.fontFamily);
setTimeout(() => {
setIsDark(document.documentElement.classList.contains('dark'));
}, 0);
}, [tempSettings.themeMode, tempSettings.theme, tempSettings.fontFamily]);
useEffect(() => {
const loadDefaults = async () => {
if (!savedSettings.downloadPath) {
const settingsWithDefaults = await getSettingsWithDefaults();
setSavedSettings(settingsWithDefaults);
setTempSettings(settingsWithDefaults);
saveSettings(settingsWithDefaults);
}
};
loadDefaults();
}, []);
const handleSave = () => {
saveSettings(tempSettings);
setSavedSettings(tempSettings);
toast.success("Settings saved");
onUnsavedChangesChange?.(false);
};
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
setSavedSettings(defaultSettings);
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily);
setShowResetConfirm(false);
toast.success("Settings reset to default");
};
const handleBrowseFolder = async () => {
try {
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
if (selectedPath && selectedPath.trim() !== "") {
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
}
}
catch (error) {
console.error("Error selecting folder:", error);
toast.error(`Error selecting folder: ${error}`);
}
};
return (<div className="space-y-6">
<h1 className="text-2xl font-bold">Settings</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext id="download-path" value={tempSettings.downloadPath} onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))} placeholder="C:\Users\YourUsername\Music"/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4"/>
Browse
</Button>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="theme-mode">Mode</Label>
<Select value={tempSettings.themeMode} onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}>
<SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="theme">Accent</Label>
<Select value={tempSettings.theme} onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme"/>
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2">
<span className="w-3 h-3 rounded-full border border-border" style={{
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
}}/>
{theme.label}
</span>
</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="font">Font</Label>
<Select value={tempSettings.fontFamily} onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}>
<SelectTrigger id="font">
<SelectValue placeholder="Select a font"/>
</SelectTrigger>
<SelectContent>
{FONT_OPTIONS.map((font) => (<SelectItem key={font.value} value={font.value}>
<span style={{ fontFamily: font.fontFamily }}>{font.label}</span>
</SelectItem>))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
<Switch id="sfx-enabled" checked={tempSettings.sfxEnabled} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}/>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="downloader" className="text-sm">Source</Label>
<div className="flex gap-2">
<Select value={tempSettings.downloader} onValueChange={(value: "auto" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}>
<SelectTrigger id="downloader" className="h-9 w-fit">
<SelectValue placeholder="Select a source"/>
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="tidal">
<span className="flex items-center"><TidalIcon />Tidal</span>
</SelectItem>
<SelectItem value="qobuz">
<span className="flex items-center"><QobuzIcon />Qobuz</span>
</SelectItem>
<SelectItem value="amazon">
<span className="flex items-center"><AmazonIcon />Amazon Music</span>
</SelectItem>
</SelectContent>
</Select>
{tempSettings.downloader === "tidal" && (<Select value={tempSettings.tidalQuality} onValueChange={(value: "LOSSLESS" | "HI_RES_LOSSLESS") => setTempSettings((prev) => ({ ...prev, tidalQuality: value }))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="LOSSLESS">Lossless (16-bit/CD Quality)</SelectItem>
<SelectItem value="HI_RES_LOSSLESS">Hi-Res Lossless (24-bit/48kHz+)</SelectItem>
</SelectContent>
</Select>)}
{tempSettings.downloader === "qobuz" && (<Select value={tempSettings.qobuzQuality} onValueChange={(value: "6" | "7" | "27") => setTempSettings((prev) => ({ ...prev, qobuzQuality: value }))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="6">FLAC 16-bit (CD Quality)</SelectItem>
<SelectItem value="7">FLAC 24-bit</SelectItem>
<SelectItem value="27">Hi-Res (24-bit/96kHz+)</SelectItem>
</SelectContent>
</Select>)}
{tempSettings.downloader === "amazon" && (<Select value={tempSettings.amazonQuality} onValueChange={(value: "HI_RES") => setTempSettings((prev) => ({ ...prev, amazonQuality: value }))}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="HI_RES">Hi-Res (24-bit/96kHz+)</SelectItem>
</SelectContent>
</Select>)}
</div>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-3">
<Label htmlFor="embed-lyrics" className="cursor-pointer text-sm">Embed Lyrics</Label>
<Switch id="embed-lyrics" checked={tempSettings.embedLyrics} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedLyrics: checked }))}/>
</div>
<div className="flex items-center gap-3">
<Label htmlFor="embed-max-quality-cover" className="cursor-pointer text-sm">Embed Max Quality Cover</Label>
<Switch id="embed-max-quality-cover" checked={tempSettings.embedMaxQualityCover} onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, embedMaxQualityCover: checked }))}/>
</div>
</div>
<div className="border-t"/>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Folder Structure</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<Select value={tempSettings.folderPreset} onValueChange={(value: FolderPreset) => {
const preset = FOLDER_PRESETS[value];
setTempSettings(prev => ({
...prev,
folderPreset: value,
folderTemplate: value === "custom" ? (prev.folderTemplate || preset.template) : preset.template
}));
}}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FOLDER_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent>
</Select>
{tempSettings.folderPreset === "custom" && (<InputWithContext value={tempSettings.folderTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, folderTemplate: e.target.value }))} placeholder="{artist}/{album}" className="h-9 text-sm flex-1"/>)}
</div>
{tempSettings.folderTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.folderTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album\}/g, "Black Panther").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{year\}/g, "2018")}/</span>
</p>)}
</div>
<div className="border-t"/>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label className="text-sm">Filename Format</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="h-3.5 w-3.5 text-muted-foreground cursor-help"/>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-xs whitespace-nowrap">Variables: {TEMPLATE_VARIABLES.map(v => v.key).join(", ")}</p>
</TooltipContent>
</Tooltip>
</div>
<div className="flex gap-2">
<Select value={tempSettings.filenamePreset} onValueChange={(value: FilenamePreset) => {
const preset = FILENAME_PRESETS[value];
setTempSettings(prev => ({
...prev,
filenamePreset: value,
filenameTemplate: value === "custom" ? (prev.filenameTemplate || preset.template) : preset.template
}));
}}>
<SelectTrigger className="h-9 w-fit">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(FILENAME_PRESETS).map(([key, { label }]) => (<SelectItem key={key} value={key}>{label}</SelectItem>))}
</SelectContent>
</Select>
{tempSettings.filenamePreset === "custom" && (<InputWithContext value={tempSettings.filenameTemplate} onChange={(e) => setTempSettings(prev => ({ ...prev, filenameTemplate: e.target.value }))} placeholder="{track}. {title}" className="h-9 text-sm flex-1"/>)}
</div>
{tempSettings.filenameTemplate && (<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">{tempSettings.filenameTemplate.replace(/\{artist\}/g, "Kendrick Lamar, SZA").replace(/\{album_artist\}/g, "Kendrick Lamar").replace(/\{title\}/g, "All The Stars").replace(/\{track\}/g, "01").replace(/\{disc\}/g, "1").replace(/\{year\}/g, "2018")}.flac</span>
</p>)}
</div>
</div>
</div>
<div className="flex gap-2 justify-between pt-4 border-t">
<Button variant="outline" onClick={() => setShowResetConfirm(true)} className="gap-1.5">
<RotateCcw className="h-4 w-4"/>
Reset to Default
</Button>
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4"/>
Save Changes
</Button>
</div>
<Dialog open={showResetConfirm} onOpenChange={setShowResetConfirm}>
<DialogContent className="max-w-md [&>button]:hidden">
<DialogHeader>
<DialogTitle>Reset to Default?</DialogTitle>
<DialogDescription>
This will reset all settings to their default values. Your custom configurations will be lost.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResetConfirm(false)}>Cancel</Button>
<Button onClick={handleReset}>Reset</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>);
}
+128
View File
@@ -0,0 +1,128 @@
import { HomeIcon } from "@/components/ui/home";
import { SettingsIcon } from "@/components/ui/settings";
import { ActivityIcon } from "@/components/ui/activity";
import { TerminalIcon } from "@/components/ui/terminal";
import { FileMusicIcon } from "@/components/ui/file-music";
import { FilePenIcon } from "@/components/ui/file-pen";
import { GithubIcon } from "@/components/ui/github";
import { BlocksIcon } from "@/components/ui/blocks";
import { CoffeeIcon } from "@/components/ui/coffee";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Button } from "@/components/ui/button";
import { openExternal } from "@/lib/utils";
export type PageType = "main" | "settings" | "debug" | "audio-analysis" | "audio-converter" | "file-manager";
interface SidebarProps {
currentPage: PageType;
onPageChange: (page: PageType) => void;
}
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
return (<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
<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>
<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>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "audio-analysis" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-analysis" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-analysis")}>
<ActivityIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Audio Quality Analyzer</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "audio-converter" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "audio-converter" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("audio-converter")}>
<FileMusicIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Audio Converter</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "file-manager" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "file-manager" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("file-manager")}>
<FilePenIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>File Manager</p>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant={currentPage === "debug" ? "secondary" : "ghost"} size="icon" className={`h-10 w-10 ${currentPage === "debug" ? "bg-primary/10 text-primary hover:bg-primary/20" : "hover:bg-primary/10 hover:text-primary"}`} onClick={() => onPageChange("debug")}>
<TerminalIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Debug Logs</p>
</TooltipContent>
</Tooltip>
</div>
<div className="mt-auto flex flex-col gap-2">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-10 w-10 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues/new?title=%5BBug%20Report%5D%20/%20%5BFeature%20Request%5D&body=%3C%21--%20WARNING%3A%20Issues%20that%20do%20not%20follow%20this%20template%20will%20be%20closed%20without%20review.%20Fill%20out%20the%20relevant%20section%20and%20delete%20the%20other.%20--%3E%0A%0A%23%23%23%20%5BBug%20Report%5D%0A%0A%23%23%23%23%20Problem%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Type%0ATrack%20/%20Album%20/%20Playlist%20/%20Artist%0A%0A%23%23%23%23%20Spotify%20URL%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Version%0ASpotiFLAC%20v%0A%0A%23%23%23%23%20OS%0AWindows%20/%20Linux%20/%20macOS%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot%0A%0A---%0A%0A%23%23%23%20%5BFeature%20Request%5D%0A%0A%23%23%23%23%20Description%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Use%20Case%0A%3E%20Type%20here%0A%0A%23%23%23%23%20Additional%20Context%0A%3E%20Type%20here%20or%20send%20screenshot")}>
<GithubIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Report Bug or Feature Request</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://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 hover:bg-primary/10 hover:text-primary" onClick={() => openExternal("https://ko-fi.com/afkarxyz")}>
<CoffeeIcon size={20}/>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Every coffee helps me keep going</p>
</TooltipContent>
</Tooltip>
</div>
</div>);
}
@@ -0,0 +1,193 @@
import { useEffect, useRef } from "react";
import type { SpectrumData } from "@/types/api";
interface SpectrumVisualizationProps {
sampleRate: number;
bitsPerSample: number;
duration: number;
spectrumData?: SpectrumData;
}
export function SpectrumVisualization({ sampleRate, bitsPerSample, duration, spectrumData, }: SpectrumVisualizationProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas)
return;
const ctx = canvas.getContext("2d");
if (!ctx)
return;
const width = canvas.width;
const height = canvas.height;
const marginLeft = 70;
const marginRight = 70;
const marginTop = 30;
const marginBottom = 65;
const plotWidth = width - marginLeft - marginRight;
const plotHeight = height - marginTop - marginBottom;
ctx.fillStyle = "#000000";
ctx.fillRect(0, 0, width, height);
const nyquistFreq = sampleRate / 2;
if (spectrumData) {
drawRealSpectrum(ctx, marginLeft, marginTop, plotWidth, plotHeight, spectrumData);
}
drawAxesAndLabels(ctx, marginLeft, marginTop, plotWidth, plotHeight, nyquistFreq, duration, sampleRate);
drawColorBar(ctx, marginLeft + plotWidth + 15, marginTop, 20, plotHeight);
}, [sampleRate, bitsPerSample, duration, spectrumData]);
const drawRealSpectrum = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, spectrum: SpectrumData) => {
const timeSlices = spectrum.time_slices;
if (timeSlices.length === 0)
return;
const freqBins = timeSlices[0].magnitudes.length;
const nyquistFreq = spectrum.max_freq;
let minDB = 0;
let maxDB = -200;
timeSlices.forEach((slice) => {
slice.magnitudes.forEach((db) => {
if (db > maxDB)
maxDB = db;
if (db < minDB && db > -200)
minDB = db;
});
});
minDB = Math.max(minDB, maxDB - 90);
const dbRange = maxDB - minDB;
const sliceWidth = Math.ceil(width / timeSlices.length);
for (let t = 0; t < timeSlices.length; t++) {
const slice = timeSlices[t];
const xPos = x + (t / timeSlices.length) * width;
for (let f = 0; f < freqBins && f < slice.magnitudes.length; f++) {
const db = slice.magnitudes[f];
const freq = (f / freqBins) * nyquistFreq;
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
const nextFreq = ((f + 1) / freqBins) * nyquistFreq;
const nextFreqRatio = nextFreq / nyquistFreq;
const nextYPos = y + height - (nextFreqRatio * height);
const binHeight = Math.max(1, Math.abs(yPos - nextYPos) + 1);
const intensity = Math.max(0, Math.min(1, (db - minDB) / dbRange));
const color = getSpekColor(intensity);
ctx.fillStyle = color;
ctx.fillRect(xPos, nextYPos, sliceWidth, binHeight);
}
}
};
const getSpekColor = (intensity: number): string => {
if (intensity < 0.08) {
const t = intensity / 0.08;
return `rgb(0, 0, ${Math.floor(t * 80)})`;
}
else if (intensity < 0.18) {
const t = (intensity - 0.08) / 0.10;
return `rgb(${Math.floor(t * 50)}, ${Math.floor(t * 30)}, ${Math.floor(80 + t * 175)})`;
}
else if (intensity < 0.28) {
const t = (intensity - 0.18) / 0.10;
return `rgb(${Math.floor(50 + t * 150)}, ${Math.floor(30 - t * 30)}, ${Math.floor(255 - t * 55)})`;
}
else if (intensity < 0.40) {
const t = (intensity - 0.28) / 0.12;
return `rgb(${Math.floor(200 + t * 55)}, 0, ${Math.floor(200 - t * 200)})`;
}
else if (intensity < 0.52) {
const t = (intensity - 0.40) / 0.12;
return `rgb(255, ${Math.floor(t * 100)}, 0)`;
}
else if (intensity < 0.65) {
const t = (intensity - 0.52) / 0.13;
return `rgb(255, ${Math.floor(100 + t * 80)}, 0)`;
}
else if (intensity < 0.78) {
const t = (intensity - 0.65) / 0.13;
return `rgb(255, ${Math.floor(180 + t * 55)}, ${Math.floor(t * 30)})`;
}
else if (intensity < 0.90) {
const t = (intensity - 0.78) / 0.12;
return `rgb(255, ${Math.floor(235 + t * 20)}, ${Math.floor(30 + t * 100)})`;
}
else {
const t = (intensity - 0.90) / 0.10;
return `rgb(255, 255, ${Math.floor(130 + t * 125)})`;
}
};
const drawAxesAndLabels = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, nyquistFreq: number, duration: number, sampleRate: number) => {
ctx.fillStyle = "#CCCCCC";
ctx.font = "12px Arial";
ctx.textAlign = "right";
ctx.textBaseline = "middle";
const freqLabels = generateFreqLabels(nyquistFreq);
freqLabels.forEach(freq => {
if (freq <= nyquistFreq) {
const freqRatio = freq / nyquistFreq;
const yPos = y + height - (freqRatio * height);
const label = freq >= 1000 ? `${freq / 1000}k` : `${freq}`;
ctx.fillText(label, x - 8, yPos);
}
});
ctx.fillText("0", x - 8, y + height);
ctx.textAlign = "center";
ctx.textBaseline = "top";
const timeStep = getTimeStep(duration);
for (let t = 0; t <= duration; t += timeStep) {
const xPos = x + (t / duration) * width;
ctx.fillText(`${Math.round(t)}s`, xPos, y + height + 8);
}
ctx.fillStyle = "#FFFFFF";
ctx.font = "13px Arial";
ctx.save();
ctx.translate(12, y + height / 2);
ctx.rotate(-Math.PI / 2);
ctx.textAlign = "center";
ctx.fillText("Frequency (Hz)", 0, 0);
ctx.restore();
ctx.textAlign = "center";
ctx.fillText("Time (seconds)", x + width / 2, y + height + 35);
ctx.textAlign = "right";
ctx.fillStyle = "#CCCCCC";
ctx.font = "12px Arial";
ctx.fillText(`Sample Rate: ${sampleRate} Hz`, x + width - 5, y - 3);
};
const generateFreqLabels = (nyquistFreq: number): number[] => {
if (nyquistFreq <= 24000) {
return [2000, 4000, 6000, 8000, 10000, 12000, 14000, 16000, 18000, 20000, 22000];
}
else if (nyquistFreq <= 48000) {
return [5000, 10000, 15000, 20000, 25000, 30000, 35000, 40000, 45000];
}
else if (nyquistFreq <= 96000) {
return [10000, 20000, 30000, 40000, 50000, 60000, 70000, 80000, 90000];
}
else {
return [20000, 40000, 60000, 80000, 100000, 120000, 140000, 160000, 180000];
}
};
const getTimeStep = (duration: number): number => {
if (duration <= 60)
return 15;
if (duration <= 120)
return 30;
if (duration <= 300)
return 30;
if (duration <= 600)
return 60;
return 60;
};
const drawColorBar = (ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number) => {
for (let i = 0; i < height; i++) {
const intensity = 1 - (i / height);
const color = getSpekColor(intensity);
ctx.fillStyle = color;
ctx.fillRect(x, y + i, width, 1);
}
ctx.strokeStyle = "#666666";
ctx.lineWidth = 1;
ctx.strokeRect(x, y, width, height);
ctx.fillStyle = "#FFFFFF";
ctx.font = "11px Arial";
ctx.textAlign = "left";
ctx.textBaseline = "middle";
ctx.fillText("High", x + width + 5, y + 10);
ctx.fillText("Low", x + width + 5, y + height - 10);
};
return (<div className="border border-white/10 rounded-lg overflow-hidden bg-black shadow-xl">
<canvas ref={canvasRef} width={1200} height={600} className="w-full h-auto" style={{ imageRendering: "auto" }}/>
</div>);
}
+22 -62
View File
@@ -1,70 +1,30 @@
import { useState } from "react";
import { X, Minus, Square } from "lucide-react";
import { X, Minus, Maximize } from "lucide-react";
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
export function TitleBar() {
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
const handleMinimize = () => {
WindowMinimise();
};
const handleMaximize = () => {
WindowToggleMaximise();
};
const handleClose = () => {
Quit();
};
return (
<>
{/* Draggable area */}
<div
className="fixed top-0 left-0 right-0 h-12 z-40"
style={{ "--wails-draggable": "drag" } as React.CSSProperties}
onDoubleClick={handleMaximize}
/>
const handleMinimize = () => {
WindowMinimise();
};
const handleMaximize = () => {
WindowToggleMaximise();
};
const handleClose = () => {
Quit();
};
return (<>
{/* Window control buttons */}
<div className="absolute top-4 left-4 z-50 flex gap-2">
<button
onClick={handleClose}
onMouseEnter={() => setHoveredButton("close")}
onMouseLeave={() => setHoveredButton(null)}
className="w-3 h-3 rounded-full bg-red-500 hover:bg-red-600 transition-colors flex items-center justify-center"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Close"
>
{hoveredButton === "close" && (
<X className="w-2 h-2 text-red-900" strokeWidth={3} />
)}
<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}/>
<div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
<button onClick={handleMinimize} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded" style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} aria-label="Minimize">
<Minus className="w-3.5 h-3.5"/>
</button>
<button
onClick={handleMinimize}
onMouseEnter={() => setHoveredButton("minimize")}
onMouseLeave={() => setHoveredButton(null)}
className="w-3 h-3 rounded-full bg-yellow-500 hover:bg-yellow-600 transition-colors flex items-center justify-center"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Minimize"
>
{hoveredButton === "minimize" && (
<Minus className="w-2 h-2 text-yellow-900" strokeWidth={3} />
)}
<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={handleMaximize}
onMouseEnter={() => setHoveredButton("maximize")}
onMouseLeave={() => setHoveredButton(null)}
className="w-3 h-3 rounded-full bg-green-500 hover:bg-green-600 transition-colors flex items-center justify-center"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Maximize"
>
{hoveredButton === "maximize" && (
<Square className="w-1.5 h-1.5 text-green-900" strokeWidth={3} />
)}
<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>
</>
);
</>);
}
+117 -56
View File
@@ -1,78 +1,139 @@
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import type { TrackMetadata } from "@/types/api";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
interface TrackInfoProps {
track: TrackMetadata & { album_name: string; release_date: string };
isDownloading: boolean;
downloadingTrack: string | null;
isDownloaded: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
onOpenFolder: () => void;
track: TrackMetadata & {
album_name: string;
release_date: string;
};
isDownloading: boolean;
downloadingTrack: string | null;
isDownloaded: boolean;
isFailed: boolean;
isSkipped: boolean;
downloadingLyricsTrack?: string | null;
downloadedLyrics?: boolean;
failedLyrics?: boolean;
skippedLyrics?: boolean;
checkingAvailability?: boolean;
availability?: TrackAvailability;
downloadingCover?: boolean;
downloadedCover?: boolean;
failedCover?: boolean;
skippedCover?: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string, playlistName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onOpenFolder: () => void;
}
export function TrackInfo({
track,
isDownloading,
downloadingTrack,
isDownloaded,
onDownload,
onOpenFolder,
}: TrackInfoProps) {
return (
<Card>
export function TrackInfo({ track, isDownloading, downloadingTrack, isDownloaded, isFailed, isSkipped, downloadingLyricsTrack, downloadedLyrics, failedLyrics, skippedLyrics, checkingAvailability, availability, downloadingCover, downloadedCover, failedCover, skippedCover, onDownload, onDownloadLyrics, onCheckAvailability, onDownloadCover, onOpenFolder, }: TrackInfoProps) {
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const formatPlays = (plays: string) => {
const num = parseInt(plays, 10);
if (isNaN(num))
return plays;
return num.toLocaleString();
};
return (<Card>
<CardContent className="px-6">
<div className="flex gap-6 items-start">
{track.images && (
<img
src={track.images}
alt={track.name}
className="w-48 h-48 rounded-md shadow-lg object-cover shrink-0"
/>
)}
<div className="shrink-0">
{track.images && (<div className="relative w-48 h-48 rounded-md shadow-lg overflow-hidden">
<img src={track.images} alt={track.name} className="w-full h-full object-cover"/>
<div className="absolute bottom-1 right-1 bg-black/80 text-white px-1.5 py-0.5 text-xs font-medium rounded">
{formatDuration(track.duration_ms)}
</div>
</div>)}
</div>
<div className="flex-1 space-y-4 min-w-0">
<div className="space-y-1">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{isSkipped ? (<FileCheck className="h-6 w-6 text-yellow-500 shrink-0"/>) : isDownloaded ? (<CheckCircle className="h-6 w-6 text-green-500 shrink-0"/>) : isFailed ? (<XCircle className="h-6 w-6 text-red-500 shrink-0"/>) : null}
</div>
<p className="text-lg text-muted-foreground">{track.artists}</p>
</div>
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<p className="text-xs text-muted-foreground">Album</p>
<p className="font-medium truncate">{track.album_name}</p>
<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>
<p className="text-xs text-muted-foreground">Release Date</p>
<p className="font-medium">{track.release_date}</p>
<div className="space-y-1">
<div>
<p className="text-xs text-muted-foreground">Release Date</p>
<p className="font-medium">{track.release_date}</p>
</div>
{track.copyright && (<div>
<p className="text-xs text-muted-foreground">Copyright</p>
<p className="font-medium truncate" title={track.copyright}>
{track.copyright}
</p>
</div>)}
</div>
</div>
{track.isrc && (
<div className="flex gap-2">
<Button
onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id)}
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Spinner />
) : (
<>
<Download className="h-4 w-4" />
{track.isrc && (<div className="flex gap-2 flex-wrap">
<Button onClick={() => onDownload(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, undefined, track.duration_ms, track.track_number, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} disabled={isDownloading || downloadingTrack === track.isrc}>
{downloadingTrack === track.isrc ? (<Spinner />) : (<>
<Download className="h-4 w-4"/>
Download
</>
)}
</>)}
</Button>
{isDownloaded && (
<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" />
{track.spotify_id && onDownloadLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, track.album_artist, track.release_date, track.disc_number)} variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>)}
{track.images && onDownloadCover && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name, undefined, undefined, track.spotify_id, track.album_artist, track.release_date, track.disc_number)} variant="outline" disabled={downloadingCover}>
{downloadingCover ? (<Spinner />) : skippedCover ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCover ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCover ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} variant="outline" disabled={checkingAvailability}>
{checkingAvailability ? (<Spinner />) : availability ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availability ? (<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>)}
{isDownloaded && (<Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4"/>
Open Folder
</Button>
)}
</div>
)}
</Button>)}
</div>)}
</div>
</div>
</CardContent>
</Card>
);
</Card>);
}
+265 -201
View File
@@ -1,262 +1,326 @@
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle } from "lucide-react";
import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
import type { TrackMetadata } from "@/types/api";
import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip";
import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious, } from "@/components/ui/pagination";
import type { TrackMetadata, TrackAvailability } from "@/types/api";
import { TidalIcon, QobuzIcon, AmazonIcon } from "./PlatformIcons";
interface TrackListProps {
tracks: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
currentPage: number;
itemsPerPage: number;
showCheckboxes?: boolean;
hideAlbumColumn?: boolean;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string) => void;
onPageChange: (page: number) => void;
tracks: TrackMetadata[];
searchQuery: string;
sortBy: string;
selectedTracks: string[];
downloadedTracks: Set<string>;
failedTracks: Set<string>;
skippedTracks: Set<string>;
downloadingTrack: string | null;
isDownloading: boolean;
currentPage: number;
itemsPerPage: number;
showCheckboxes?: boolean;
hideAlbumColumn?: boolean;
folderName?: string;
isArtistDiscography?: boolean;
downloadedLyrics?: Set<string>;
failedLyrics?: Set<string>;
skippedLyrics?: Set<string>;
downloadingLyricsTrack?: string | null;
checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>;
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, durationMs?: number, position?: number, albumArtist?: string, releaseDate?: string, coverUrl?: string, spotifyTrackNumber?: number, spotifyDiscNumber?: number, spotifyTotalTracks?: number, spotifyTotalDiscs?: number, copyright?: string, publisher?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number) => void;
onPageChange: (page: number) => void;
onAlbumClick?: (album: {
id: string;
name: string;
external_urls: string;
}) => void;
onArtistClick?: (artist: {
id: string;
name: string;
external_urls: string;
}) => void;
onTrackClick?: (track: TrackMetadata) => void;
}
export function TrackList({
tracks,
searchQuery,
sortBy,
selectedTracks,
downloadedTracks,
downloadingTrack,
isDownloading,
currentPage,
itemsPerPage,
showCheckboxes = false,
hideAlbumColumn = false,
onToggleTrack,
onToggleSelectAll,
onDownloadTrack,
onPageChange,
}: 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) {
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);
});
}
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">
if (sortBy === "title-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.name.localeCompare(b.name));
}
else if (sortBy === "title-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.name.localeCompare(a.name));
}
else if (sortBy === "artist-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.artists.localeCompare(b.artists));
}
else if (sortBy === "artist-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.artists.localeCompare(a.artists));
}
else if (sortBy === "duration-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => a.duration_ms - b.duration_ms);
}
else if (sortBy === "duration-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => b.duration_ms - a.duration_ms);
}
else if (sortBy === "plays-asc") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
if (isNaN(aPlays))
return 1;
if (isNaN(bPlays))
return -1;
return aPlays - bPlays;
});
}
else if (sortBy === "plays-desc") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aPlays = a.plays ? parseInt(a.plays, 10) : 0;
const bPlays = b.plays ? parseInt(b.plays, 10) : 0;
if (isNaN(aPlays))
return 1;
if (isNaN(bPlays))
return -1;
return bPlays - aPlays;
});
}
else if (sortBy === "downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
return (bDownloaded ? 1 : 0) - (aDownloaded ? 1 : 0);
});
}
else if (sortBy === "not-downloaded") {
filteredTracks = [...filteredTracks].sort((a, b) => {
const aDownloaded = downloadedTracks.has(a.isrc);
const bDownloaded = downloadedTracks.has(b.isrc);
return (aDownloaded ? 1 : 0) - (bDownloaded ? 1 : 0);
});
}
const totalPages = Math.ceil(filteredTracks.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedTracks = filteredTracks.slice(startIndex, endIndex);
const tracksWithIsrc = filteredTracks.filter((track) => track.isrc);
const allSelected = tracksWithIsrc.length > 0 &&
tracksWithIsrc.every((track) => selectedTracks.includes(track.isrc));
const formatDuration = (ms: number) => {
const minutes = Math.floor(ms / 60000);
const seconds = Math.floor((ms % 60000) / 1000);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
const formatPlays = (plays: string | undefined) => {
if (!plays)
return "";
const num = parseInt(plays, 10);
if (isNaN(num))
return plays;
return num.toLocaleString();
};
return (<div className="space-y-4">
<div className="rounded-md border">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b bg-muted/50">
{showCheckboxes && (
<th className="h-12 px-4 text-left align-middle w-12">
<Checkbox
checked={allSelected}
onCheckedChange={() => onToggleSelectAll(filteredTracks)}
/>
</th>
)}
{showCheckboxes && (<th className="h-12 px-4 text-left align-middle w-12">
<Checkbox checked={allSelected} onCheckedChange={() => onToggleSelectAll(filteredTracks)}/>
</th>)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground w-12">
#
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground">
Title
</th>
{!hideAlbumColumn && (
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
{!hideAlbumColumn && (<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden md:table-cell">
Album
</th>
)}
</th>)}
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden lg:table-cell w-24">
Duration
</th>
<th className="h-12 px-4 text-left align-middle font-medium text-muted-foreground hidden xl:table-cell w-32">
Plays
</th>
<th className="h-12 px-4 text-center align-middle font-medium text-muted-foreground w-32">
Actions
</th>
</tr>
</thead>
<tbody>
{paginatedTracks.map((track, index) => (
<tr key={index} className="border-b transition-colors hover:bg-muted/50">
{showCheckboxes && (
<td className="p-4 align-middle">
{track.isrc && (
<Checkbox
checked={selectedTracks.includes(track.isrc)}
onCheckedChange={() => onToggleTrack(track.isrc)}
/>
)}
</td>
)}
{paginatedTracks.map((track, index) => (<tr key={index} className="border-b transition-colors hover:bg-muted/50">
{showCheckboxes && (<td className="p-4 align-middle">
{track.isrc && (<Checkbox checked={selectedTracks.includes(track.isrc)} onCheckedChange={() => onToggleTrack(track.isrc)}/>)}
</td>)}
<td className="p-4 align-middle text-sm text-muted-foreground">
{startIndex + index + 1}
<div className="flex flex-col items-center gap-0.5">
<span>{startIndex + index + 1}</span>
{track.status && (track.status === "UP" || track.status === "DOWN" || track.status === "NEW") && (<span className={`text-xs ${track.status === "UP"
? "text-green-500"
: track.status === "DOWN"
? "text-red-500"
: track.status === "NEW"
? "text-blue-500"
: ""}`}>
{track.status === "NEW" ? "●" : track.status === "UP" ? "▲" : "▼"}
</span>)}
</div>
</td>
<td className="p-4 align-middle">
<div className="flex items-center gap-3">
{track.images && (
<img
src={track.images}
alt={track.name}
className="w-10 h-10 rounded object-cover"
/>
)}
{track.images && (<img src={track.images} alt={track.name} className="w-10 h-10 rounded object-cover"/>)}
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-medium">{track.name}</span>
{downloadedTracks.has(track.isrc) && (
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
)}
{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}
{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">
{track.album_name}
</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">
{track.isrc && (
<Button
onClick={() =>
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name)
}
size="sm"
className="gap-1.5"
disabled={isDownloading || downloadingTrack === track.isrc}
>
{downloadingTrack === track.isrc ? (
<Spinner />
) : (
<>
<Download className="h-4 w-4" />
Download
</>
)}
</Button>
)}
<td className="p-4 align-middle text-sm text-muted-foreground hidden xl:table-cell">
{track.plays ? formatPlays(track.plays) : ""}
</td>
</tr>
))}
<td className="p-4 align-middle text-center">
<div className="flex items-center justify-center gap-1">
{track.isrc && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, track.duration_ms, startIndex + index + 1, track.album_artist, track.release_date, track.images, track.track_number, track.disc_number, track.total_tracks, track.total_discs, track.copyright, track.publisher)} size="sm" disabled={isDownloading || downloadingTrack === track.isrc}>
{downloadingTrack === track.isrc ? (<Spinner />) : skippedTracks.has(track.isrc) ? (<FileCheck className="h-4 w-4"/>) : downloadedTracks.has(track.isrc) ? (<CheckCircle className="h-4 w-4"/>) : failedTracks.has(track.isrc) ? (<XCircle className="h-4 w-4"/>) : (<Download className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{downloadingTrack === track.isrc ? (<p>Downloading...</p>) : skippedTracks.has(track.isrc) ? (<p>Already exists</p>) : downloadedTracks.has(track.isrc) ? (<p>Downloaded</p>) : failedTracks.has(track.isrc) ? (<p>Failed</p>) : (<p>Download Track</p>)}
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onDownloadLyrics && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, track.album_artist, track.release_date, track.disc_number)} size="sm" variant="outline" disabled={downloadingLyricsTrack === track.spotify_id}>
{downloadingLyricsTrack === track.spotify_id ? (<Spinner />) : skippedLyrics?.has(track.spotify_id) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedLyrics?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedLyrics?.has(track.spotify_id) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<FileText className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>)}
{track.images && onDownloadCover && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => {
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId, track.album_artist, track.release_date, track.disc_number);
}} size="sm" variant="outline" disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}>
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (<Spinner />) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<FileCheck className="h-4 w-4 text-yellow-500"/>) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (<XCircle className="h-4 w-4 text-red-500"/>) : (<ImageDown className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>)}
{track.spotify_id && onCheckAvailability && (<Tooltip>
<TooltipTrigger asChild>
<Button onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)} size="sm" variant="outline" disabled={checkingAvailabilityTrack === track.spotify_id}>
{checkingAvailabilityTrack === track.spotify_id ? (<Spinner />) : availabilityMap?.has(track.spotify_id) ? (<CheckCircle className="h-4 w-4 text-green-500"/>) : (<Globe className="h-4 w-4"/>)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availabilityMap?.has(track.spotify_id) ? (<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.tidal ? "text-green-500" : "text-red-500"}`}/>
<QobuzIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.qobuz ? "text-green-500" : "text-red-500"}`}/>
<AmazonIcon className={`w-4 h-4 ${availabilityMap.get(track.spotify_id)?.amazon ? "text-green-500" : "text-red-500"}`}/>
</div>) : (<p>Check Availability</p>)}
</TooltipContent>
</Tooltip>)}
</div>
</td>
</tr>))}
</tbody>
</table>
</div>
</div>
{totalPages > 1 && (
<Pagination>
{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"
}
/>
<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) => {
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (<PaginationItem key={page}>
<PaginationLink href="#" onClick={(e) => {
e.preventDefault();
onPageChange(page);
}}
isActive={currentPage === page}
className="cursor-pointer"
>
}} isActive={currentPage === page} className="cursor-pointer">
{page}
</PaginationLink>
</PaginationItem>
))}
</PaginationItem>))}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e) => {
e.preventDefault();
if (currentPage < totalPages) onPageChange(currentPage + 1);
}}
className={
currentPage === totalPages
? "pointer-events-none opacity-50"
: "cursor-pointer"
}
/>
<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>
);
</Pagination>)}
</div>);
}
+63
View File
@@ -0,0 +1,63 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface ActivityIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface ActivityIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
pathOffset: 0,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
pathOffset: [1, 0],
transition: {
duration: 0.8,
ease: 'easeInOut',
},
},
};
const ActivityIcon = forwardRef<ActivityIconHandle, ActivityIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
ActivityIcon.displayName = 'ActivityIcon';
export { ActivityIcon };
-66
View File
@@ -1,66 +0,0 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
+19 -41
View File
@@ -1,46 +1,24 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const badgeVariants = cva("inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden", {
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
variant: {
default: "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary: "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive: "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
});
function Badge({ className, variant, asChild = false, ...props }: React.ComponentProps<"span"> & VariantProps<typeof badgeVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "span";
return (<Comp data-slot="badge" className={cn(badgeVariants({ variant }), className)} {...props}/>);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+52
View File
@@ -0,0 +1,52 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface BlocksIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface BlocksIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const VARIANTS: Variants = {
normal: { translateX: 0, translateY: 0 },
animate: { translateX: -4, translateY: 4 },
};
const BlocksIcon = forwardRef<BlocksIconHandle, BlocksIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const 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 };
+30 -55
View File
@@ -1,60 +1,35 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva("inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all cursor-pointer disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", {
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
});
function Button({ className, variant, size, asChild = false, ...props }: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button";
return (<Comp data-slot="button" className={cn(buttonVariants({ variant, size, className }))} {...props}/>);
}
export { Button, buttonVariants }
export { Button, buttonVariants };
+10 -78
View File
@@ -1,92 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
return (<div data-slot="card" className={cn("bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm", className)} {...props}/>);
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
return (<div data-slot="card-header" className={cn("@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6", className)} {...props}/>);
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
return (<div data-slot="card-title" className={cn("leading-none font-semibold", className)} {...props}/>);
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
return (<div data-slot="card-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
return (<div data-slot="card-action" className={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)} {...props}/>);
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
return (<div data-slot="card-content" className={cn("px-6", className)} {...props}/>);
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
return (<div data-slot="card-footer" className={cn("flex items-center px-6 [.border-t]:pt-6", className)} {...props}/>);
}
export { Card, CardHeader, CardFooter, CardTitle, CardAction, CardDescription, CardContent, };
+11 -30
View File
@@ -1,32 +1,13 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
"use client";
import * as React from "react";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { CheckIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Checkbox({ className, ...props }: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (<CheckboxPrimitive.Root data-slot="checkbox" className={cn("peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className)} {...props}>
<CheckboxPrimitive.Indicator data-slot="checkbox-indicator" className="grid place-content-center text-current transition-none">
<CheckIcon className="size-3.5"/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
</CheckboxPrimitive.Root>);
}
export { Checkbox }
export { Checkbox };
+66
View File
@@ -0,0 +1,66 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface CoffeeIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface CoffeeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
y: 0,
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 controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ overflow: 'visible' }}>
<motion.path d="M10 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.2}/>
<motion.path d="M14 2v2" animate={controls} variants={PATH_VARIANTS} custom={0.4}/>
<motion.path d="M6 2v2" animate={controls} variants={PATH_VARIANTS} custom={0}/>
<path d="M16 8a1 1 0 0 1 1 1v8a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4V9a1 1 0 0 1 1-1h14a4 4 0 1 1 0 8h-1"/>
</svg>
</div>);
});
CoffeeIcon.displayName = 'CoffeeIcon';
export { CoffeeIcon };
+48 -223
View File
@@ -1,252 +1,77 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
"use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function ContextMenu({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props}/>;
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
function ContextMenuTrigger({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props}/>);
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
function ContextMenuGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props}/>);
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
function ContextMenuPortal({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props}/>);
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
function ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props}/>;
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
function ContextMenuRadioGroup({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (<ContextMenuPrimitive.RadioGroup data-slot="context-menu-radio-group" {...props}/>);
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
function ContextMenuSubTrigger({ className, inset, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
return (<ContextMenuPrimitive.SubTrigger data-slot="context-menu-sub-trigger" data-inset={inset} className={cn("focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
<ChevronRightIcon className="ml-auto"/>
</ContextMenuPrimitive.SubTrigger>);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (<ContextMenuPrimitive.SubContent data-slot="context-menu-sub-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg", className)} {...props}/>);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
function ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content data-slot="context-menu-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", className)} {...props}/>
</ContextMenuPrimitive.Portal>);
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
function ContextMenuItem({ className, inset, variant = "default", ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean;
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
return (<ContextMenuPrimitive.Item data-slot="context-menu-item" data-inset={inset} data-variant={variant} className={cn("focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:text-destructive! [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 data-inset:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}/>);
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
function ContextMenuCheckboxItem({ className, children, checked, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (<ContextMenuPrimitive.CheckboxItem data-slot="context-menu-checkbox-item" className={cn("focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} checked={checked} {...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
<CheckIcon className="size-4"/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
</ContextMenuPrimitive.CheckboxItem>);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
function ContextMenuRadioItem({ className, children, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (<ContextMenuPrimitive.RadioItem data-slot="context-menu-radio-item" className={cn("focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
<CircleIcon className="size-2 fill-current"/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
</ContextMenuPrimitive.RadioItem>);
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
function ContextMenuLabel({ className, inset, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8",
className
)}
{...props}
/>
)
return (<ContextMenuPrimitive.Label data-slot="context-menu-label" data-inset={inset} className={cn("text-foreground px-2 py-1.5 text-sm font-medium data-inset:pl-8", className)} {...props}/>);
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
function ContextMenuSeparator({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (<ContextMenuPrimitive.Separator data-slot="context-menu-separator" className={cn("bg-border -mx-1 my-1 h-px", className)} {...props}/>);
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (<span data-slot="context-menu-shortcut" className={cn("text-muted-foreground ml-auto text-xs tracking-widest", className)} {...props}/>);
}
export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuGroup, ContextMenuPortal, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuRadioGroup, };
+29 -125
View File
@@ -1,143 +1,47 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props}/>;
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props}/>;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props}/>;
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props}/>;
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
function DialogOverlay({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (<DialogPrimitive.Overlay data-slot="dialog-overlay" className={cn("data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50", className)} {...props}/>);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
function DialogContent({ className, children, showCloseButton = true, ...props }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
}) {
return (
<DialogPortal data-slot="dialog-portal">
return (<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
<DialogPrimitive.Content data-slot="dialog-content" className={cn("bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200", className)} {...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
{showCloseButton && (<DialogPrimitive.Close data-slot="dialog-close" className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Close>)}
</DialogPrimitive.Content>
</DialogPortal>
)
</DialogPortal>);
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
return (<div data-slot="dialog-header" className={cn("flex flex-col gap-2 text-center sm:text-left", className)} {...props}/>);
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
return (<div data-slot="dialog-footer" className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)} {...props}/>);
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
function DialogTitle({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (<DialogPrimitive.Title data-slot="dialog-title" className={cn("text-lg leading-none font-semibold", className)} {...props}/>);
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
function DialogDescription({ className, ...props }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (<DialogPrimitive.Description data-slot="dialog-description" className={cn("text-muted-foreground text-sm", className)} {...props}/>);
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogOverlay, DialogPortal, DialogTitle, DialogTrigger, };
+64
View File
@@ -0,0 +1,64 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface FileMusicIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface FileMusicIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const FileMusicIcon = forwardRef<FileMusicIconHandle, FileMusicIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M11.65 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v10.35" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M8 20v-7l3 1.474" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.circle cx="6" cy="20" r="2" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
FileMusicIcon.displayName = 'FileMusicIcon';
export { FileMusicIcon };
+63
View File
@@ -0,0 +1,63 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface FilePenIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface FilePenIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
pathLength: [0, 1],
opacity: [0, 1],
transition: {
duration: 0.6,
ease: 'easeInOut',
},
},
};
const FilePenIcon = forwardRef<FilePenIconHandle, FilePenIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path d="M12.659 22H18a2 2 0 0 0 2-2V8a2.4 2.4 0 0 0-.706-1.706l-3.588-3.588A2.4 2.4 0 0 0 14 2H6a2 2 0 0 0-2 2v9.34" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M14 2v5a1 1 0 0 0 1 1h5" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
<motion.path d="M10.378 12.622a1 1 0 0 1 3 3.003L8.36 20.637a2 2 0 0 1-.854.506l-2.867.837a.5.5 0 0 1-.62-.62l.836-2.869a2 2 0 0 1 .506-.853z" variants={PATH_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
FilePenIcon.displayName = 'FilePenIcon';
export { FilePenIcon };
+102
View File
@@ -0,0 +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';
export interface GithubIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface GithubIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const BODY_VARIANTS: Variants = {
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,
},
},
};
const TAIL_VARIANTS: Variants = {
normal: {
pathLength: 1,
rotate: 0,
transition: {
duration: 0.3,
},
},
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,
},
},
};
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');
},
};
});
const handleMouseEnter = useCallback(async (e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
bodyControls.start('animate');
await tailControls.start('draw');
tailControls.start('wag');
}
else {
onMouseEnter?.(e);
}
}, [bodyControls, onMouseEnter, tailControls]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
bodyControls.start('normal');
tailControls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [bodyControls, tailControls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<motion.path variants={BODY_VARIANTS} initial="normal" animate={bodyControls} d="M15 22v-4a4.8 4.8 0 0 0-1-3.5c3 0 6-2 6-5.5.08-1.25-.27-2.48-1-3.5.28-1.15.28-2.35 0-3.5 0 0-1 0-3 1.5-2.64-.5-5.36-.5-8 0C6 2 5 2 5 2c-.3 1.15-.3 2.35 0 3.5A5.403 5.403 0 0 0 4 9c0 3.5 3 5.5 6 5.5-.39.49-.68 1.05-.85 1.65-.17.6-.22 1.23-.15 1.85v4"/>
<motion.path variants={TAIL_VARIANTS} initial="normal" animate={tailControls} d="M9 18c-4.51 2-5-2-7-2"/>
</svg>
</div>);
});
GithubIcon.displayName = 'GithubIcon';
export { GithubIcon };
+62
View File
@@ -0,0 +1,62 @@
'use client';
import type { Transition, Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface HomeIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface HomeIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const DEFAULT_TRANSITION: Transition = {
duration: 0.6,
opacity: { duration: 0.2 },
};
const PATH_VARIANTS: Variants = {
normal: {
pathLength: 1,
opacity: 1,
},
animate: {
opacity: [0, 1],
pathLength: [0, 1],
},
};
const HomeIcon = forwardRef<HomeIconHandle, HomeIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<motion.path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8" variants={PATH_VARIANTS} transition={DEFAULT_TRANSITION} animate={controls}/>
</svg>
</div>);
});
HomeIcon.displayName = 'HomeIcon';
export { HomeIcon };
+121 -179
View File
@@ -1,214 +1,156 @@
import * as React from "react";
import { Input } from "@/components/ui/input";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@/components/ui/context-menu";
import { Scissors, Copy, Clipboard, Type } from "lucide-react";
export interface InputWithContextProps
extends React.InputHTMLAttributes<HTMLInputElement> {
onValueChange?: (value: string) => void;
export interface InputWithContextProps extends React.InputHTMLAttributes<HTMLInputElement> {
onValueChange?: (value: string) => void;
}
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(
({ className, type, onValueChange, onChange, ...props }, ref) => {
const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProps>(({ className, type, onValueChange, onChange, ...props }, ref) => {
const inputRef = React.useRef<HTMLInputElement>(null);
const [hasSelection, setHasSelection] = React.useState(false);
const [canPaste, setCanPaste] = React.useState(false);
// Combine refs
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
// Check selection state
const updateSelectionState = () => {
const input = inputRef.current;
if (!input) return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
setHasSelection(start !== end);
};
// Check clipboard permission
const checkClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
setCanPaste(text.length > 0);
} catch {
setCanPaste(false);
}
};
React.useEffect(() => {
checkClipboard();
}, []);
const handleCut = async () => {
const input = inputRef.current;
if (!input) return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const selectedText = input.value.substring(start, end);
if (selectedText) {
try {
await navigator.clipboard.writeText(selectedText);
const newValue = input.value.substring(0, start) + input.value.substring(end);
// Update value and trigger change
input.value = newValue;
input.setSelectionRange(start, start);
// Trigger React onChange
if (onChange) {
const event = {
target: input,
currentTarget: input,
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
} catch (err) {
console.error("Failed to cut:", err);
}
}
};
const handleCopy = async () => {
const input = inputRef.current;
if (!input) return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const selectedText = input.value.substring(start, end);
if (selectedText) {
try {
await navigator.clipboard.writeText(selectedText);
input.focus();
} catch (err) {
console.error("Failed to copy:", err);
}
}
};
const handlePaste = async () => {
const input = inputRef.current;
if (!input) return;
try {
const text = await navigator.clipboard.readText();
const input = inputRef.current;
if (!input)
return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const newValue =
input.value.substring(0, start) + text + input.value.substring(end);
// Update value and trigger change
input.value = newValue;
const newPosition = start + text.length;
input.setSelectionRange(newPosition, newPosition);
// Trigger React onChange
if (onChange) {
const event = {
target: input,
currentTarget: input,
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
await checkClipboard();
} catch (err) {
console.error("Failed to paste:", err);
}
setHasSelection(start !== end);
};
const checkClipboard = async () => {
try {
const text = await navigator.clipboard.readText();
setCanPaste(text.length > 0);
}
catch {
setCanPaste(false);
}
};
const handleCut = async () => {
const input = inputRef.current;
if (!input)
return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const selectedText = input.value.substring(start, end);
if (selectedText) {
try {
await navigator.clipboard.writeText(selectedText);
const newValue = input.value.substring(0, start) + input.value.substring(end);
input.value = newValue;
input.setSelectionRange(start, start);
if (onChange) {
const event = {
target: input,
currentTarget: input,
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
}
catch (err) {
console.error("Failed to cut:", err);
}
}
};
const handleCopy = async () => {
const input = inputRef.current;
if (!input)
return;
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const selectedText = input.value.substring(start, end);
if (selectedText) {
try {
await navigator.clipboard.writeText(selectedText);
input.focus();
}
catch (err) {
console.error("Failed to copy:", err);
}
}
};
const handlePaste = async () => {
const input = inputRef.current;
if (!input)
return;
try {
const text = await navigator.clipboard.readText();
const start = input.selectionStart ?? 0;
const end = input.selectionEnd ?? 0;
const newValue = input.value.substring(0, start) + text + input.value.substring(end);
input.value = newValue;
const newPosition = start + text.length;
input.setSelectionRange(newPosition, newPosition);
if (onChange) {
const event = {
target: input,
currentTarget: input,
} as React.ChangeEvent<HTMLInputElement>;
onChange(event);
}
if (onValueChange) {
onValueChange(newValue);
}
input.focus();
await checkClipboard();
}
catch (err) {
console.error("Failed to paste:", err);
}
};
const handleSelectAll = () => {
const input = inputRef.current;
if (!input) return;
input.select();
input.focus();
updateSelectionState();
const input = inputRef.current;
if (!input)
return;
input.select();
input.focus();
updateSelectionState();
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (onChange) {
onChange(e);
}
if (onValueChange) {
onValueChange(e.target.value);
}
if (onChange) {
onChange(e);
}
if (onValueChange) {
onValueChange(e.target.value);
}
};
return (
<ContextMenu onOpenChange={checkClipboard}>
return (<ContextMenu onOpenChange={(open) => {
if (open) {
checkClipboard();
}
}}>
<ContextMenuTrigger asChild>
<Input
ref={inputRef}
type={type}
className={className}
onChange={handleInputChange}
onSelect={updateSelectionState}
onMouseUp={updateSelectionState}
onKeyUp={updateSelectionState}
{...props}
/>
<Input ref={inputRef} type={type} className={className} onChange={handleInputChange} onSelect={updateSelectionState} onMouseUp={updateSelectionState} onKeyUp={updateSelectionState} {...props}/>
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem
onSelect={handleCut}
disabled={!hasSelection || props.disabled || props.readOnly}
>
<Scissors className="mr-2 h-4 w-4" />
<ContextMenuItem onSelect={handleCut} disabled={!hasSelection || props.disabled || props.readOnly}>
<Scissors className="mr-2 h-4 w-4"/>
Cut
<span className="ml-auto text-xs text-muted-foreground">Ctrl+X</span>
</ContextMenuItem>
<ContextMenuItem
onSelect={handleCopy}
disabled={!hasSelection || props.disabled}
>
<Copy className="mr-2 h-4 w-4" />
<ContextMenuItem onSelect={handleCopy} disabled={!hasSelection || props.disabled}>
<Copy className="mr-2 h-4 w-4"/>
Copy
<span className="ml-auto text-xs text-muted-foreground">Ctrl+C</span>
</ContextMenuItem>
<ContextMenuItem
onSelect={handlePaste}
disabled={!canPaste || props.disabled || props.readOnly}
>
<Clipboard className="mr-2 h-4 w-4" />
<ContextMenuItem onSelect={handlePaste} disabled={!canPaste || props.disabled || props.readOnly}>
<Clipboard className="mr-2 h-4 w-4"/>
Paste
<span className="ml-auto text-xs text-muted-foreground">Ctrl+V</span>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={handleSelectAll}
disabled={!inputRef.current?.value || props.disabled}
>
<Type className="mr-2 h-4 w-4" />
<ContextMenuItem onSelect={handleSelectAll} disabled={!inputRef.current?.value || props.disabled}>
<Type className="mr-2 h-4 w-4"/>
Select All
<span className="ml-auto text-xs text-muted-foreground">Ctrl+A</span>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
);
</ContextMenu>);
});
InputWithContext.displayName = "InputWithContext";
export { InputWithContext };
+4 -19
View File
@@ -1,21 +1,6 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from "react";
import { cn } from "@/lib/utils";
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
return (<input type={type} data-slot="input" className={cn("file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]", "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", className)} {...props}/>);
}
export { Input }
export { Input };
+7 -23
View File
@@ -1,24 +1,8 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
"use client";
import * as React from "react";
import * as LabelPrimitive from "@radix-ui/react-label";
import { cn } from "@/lib/utils";
function Label({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (<LabelPrimitive.Root data-slot="label" className={cn("flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", className)} {...props}/>);
}
export { Label }
export { Label };
+26 -112
View File
@@ -1,127 +1,41 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
import * as React from "react";
import { ChevronLeftIcon, ChevronRightIcon, MoreHorizontalIcon, } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button, buttonVariants } from "@/components/ui/button";
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
return (<nav role="navigation" aria-label="pagination" data-slot="pagination" className={cn("mx-auto flex w-full justify-center", className)} {...props}/>);
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
function PaginationContent({ className, ...props }: React.ComponentProps<"ul">) {
return (<ul data-slot="pagination-content" className={cn("flex flex-row items-center gap-1", className)} {...props}/>);
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
return <li data-slot="pagination-item" {...props}/>;
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
isActive?: boolean;
} & Pick<React.ComponentProps<typeof Button>, "size"> & React.ComponentProps<"a">;
function PaginationLink({ className, isActive, size = "icon", ...props }: PaginationLinkProps) {
return (<a aria-current={isActive ? "page" : undefined} data-slot="pagination-link" data-active={isActive} className={cn(buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}), className)} {...props}/>);
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
function PaginationPrevious({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (<PaginationLink aria-label="Go to previous page" size="default" className={cn("gap-1 px-2.5 sm:pl-2.5", className)} {...props}>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
</PaginationLink>);
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
function PaginationNext({ className, ...props }: React.ComponentProps<typeof PaginationLink>) {
return (<PaginationLink aria-label="Go to next page" size="default" className={cn("gap-1 px-2.5 sm:pr-2.5", className)} {...props}>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
</PaginationLink>);
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
function PaginationEllipsis({ className, ...props }: React.ComponentProps<"span">) {
return (<span aria-hidden data-slot="pagination-ellipsis" className={cn("flex size-9 items-center justify-center", className)} {...props}>
<MoreHorizontalIcon className="size-4"/>
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
</span>);
}
export { Pagination, PaginationContent, PaginationLink, PaginationItem, PaginationPrevious, PaginationNext, PaginationEllipsis, };
+9 -30
View File
@@ -1,31 +1,10 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
"use client";
import * as React from "react";
import * as ProgressPrimitive from "@radix-ui/react-progress";
import { cn } from "@/lib/utils";
function Progress({ className, value, ...props }: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (<ProgressPrimitive.Root data-slot="progress" className={cn("bg-primary/20 relative h-2 w-full overflow-hidden rounded-full", className)} {...props}>
<ProgressPrimitive.Indicator data-slot="progress-indicator" className="bg-primary h-full w-full flex-1 transition-all" style={{ transform: `translateX(-${100 - (value || 0)}%)` }}/>
</ProgressPrimitive.Root>);
}
export { Progress }
export { Progress };
@@ -1,45 +0,0 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+39 -161
View File
@@ -1,185 +1,63 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { cn } from "@/lib/utils";
function Select({ ...props }: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props}/>;
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
function SelectGroup({ ...props }: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props}/>;
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
function SelectValue({ ...props }: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props}/>;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
function SelectTrigger({ className, size = "default", children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default";
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
return (<SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={cn("border-input data-placeholder:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className)} {...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
<ChevronDownIcon className="size-4 opacity-50"/>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
</SelectPrimitive.Trigger>);
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
function SelectContent({ className, children, position = "popper", align = "center", ...props }: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (<SelectPrimitive.Portal>
<SelectPrimitive.Content data-slot="select-content" className={cn("bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-32 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md", position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", className)} position={position} align={align} {...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1"
)}
>
<SelectPrimitive.Viewport className={cn("p-1", position === "popper" &&
"h-(--radix-select-trigger-height) w-full min-w-(--radix-select-trigger-width) scroll-my-1")}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
</SelectPrimitive.Portal>);
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (<SelectPrimitive.Label data-slot="select-label" className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)} {...props}/>);
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
function SelectItem({ className, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (<SelectPrimitive.Item data-slot="select-item" className={cn("focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2", className)} {...props}>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
<CheckIcon className="size-4"/>
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
</SelectPrimitive.Item>);
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (<SelectPrimitive.Separator data-slot="select-separator" className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)} {...props}/>);
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (<SelectPrimitive.ScrollUpButton data-slot="select-scroll-up-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronUpIcon className="size-4"/>
</SelectPrimitive.ScrollUpButton>);
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (<SelectPrimitive.ScrollDownButton data-slot="select-scroll-down-button" className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
<ChevronDownIcon className="size-4"/>
</SelectPrimitive.ScrollDownButton>);
}
export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, };
+54
View File
@@ -0,0 +1,54 @@
'use client';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface SettingsIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface SettingsIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const SettingsIcon = forwardRef<SettingsIconHandle, SettingsIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('animate');
}
else {
onMouseEnter?.(e);
}
}, [controls, onMouseEnter]);
const handleMouseLeave = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!isControlledRef.current) {
controls.start('normal');
}
else {
onMouseLeave?.(e);
}
}, [controls, onMouseLeave]);
return (<div className={cn(className)} onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} {...props}>
<motion.svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" transition={{ type: 'spring', stiffness: 50, damping: 10 }} variants={{
normal: {
rotate: 0,
},
animate: {
rotate: 180,
},
}} animate={controls}>
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/>
<circle cx="12" cy="12" r="3"/>
</motion.svg>
</div>);
});
SettingsIcon.displayName = 'SettingsIcon';
export { SettingsIcon };
+26 -45
View File
@@ -1,46 +1,27 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
import { CircleCheckIcon, InfoIcon, Loader2Icon, OctagonXIcon, TriangleAlertIcon, } from "lucide-react";
import { useTheme } from "next-themes";
import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
toastOptions={{
classNames: {
success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500",
error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500",
warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500",
info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500",
},
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
const { theme = "system" } = useTheme();
return (<Sonner theme={theme as ToasterProps["theme"]} className="toaster group" icons={{
success: <CircleCheckIcon className="size-4"/>,
info: <InfoIcon className="size-4"/>,
warning: <TriangleAlertIcon className="size-4"/>,
error: <OctagonXIcon className="size-4"/>,
loading: <Loader2Icon className="size-4 animate-spin"/>,
}} toastOptions={{
classNames: {
success: "border-green-500 bg-green-50 text-green-900 dark:bg-green-950 dark:text-green-100 [&>svg]:text-green-500",
error: "border-red-500 bg-red-50 text-red-900 dark:bg-red-950 dark:text-red-100 [&>svg]:text-red-500",
warning: "border-yellow-500 bg-yellow-50 text-yellow-900 dark:bg-yellow-950 dark:text-yellow-100 [&>svg]:text-yellow-500",
info: "border-blue-500 bg-blue-50 text-blue-900 dark:bg-blue-950 dark:text-blue-100 [&>svg]:text-blue-500",
},
}} style={{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
left: "calc(56px + 1rem)",
} as React.CSSProperties} {...props}/>);
};
export { Toaster };
+4 -13
View File
@@ -1,15 +1,6 @@
import { Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
import { Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
return (<Loader2 role="status" aria-label="Loading" className={cn("size-4 animate-spin", className)} {...props}/>);
}
export { Spinner }
export { Spinner };
+10
View File
@@ -0,0 +1,10 @@
"use client";
import * as React from "react";
import * as SwitchPrimitive from "@radix-ui/react-switch";
import { cn } from "@/lib/utils";
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (<SwitchPrimitive.Root data-slot="switch" className={cn("peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50", className)} {...props}>
<SwitchPrimitive.Thumb data-slot="switch-thumb" className={cn("pointer-events-none block size-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0 data-[state=checked]:bg-primary-foreground data-[state=unchecked]:bg-background")}/>
</SwitchPrimitive.Root>);
}
export { Switch };
-66
View File
@@ -1,66 +0,0 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+59
View File
@@ -0,0 +1,59 @@
'use client';
import type { Variants } from 'motion/react';
import type { HTMLAttributes } from 'react';
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
import { motion, useAnimation } from 'motion/react';
import { cn } from '@/lib/utils';
export interface TerminalIconHandle {
startAnimation: () => void;
stopAnimation: () => void;
}
interface TerminalIconProps extends HTMLAttributes<HTMLDivElement> {
size?: number;
}
const LINE_VARIANTS: Variants = {
normal: { opacity: 1 },
animate: {
opacity: [1, 0, 1],
transition: {
duration: 0.8,
repeat: Infinity,
ease: 'linear',
},
},
};
const TerminalIcon = forwardRef<TerminalIconHandle, TerminalIconProps>(({ onMouseEnter, onMouseLeave, className, size = 28, ...props }, ref) => {
const controls = useAnimation();
const isControlledRef = useRef(false);
useImperativeHandle(ref, () => {
isControlledRef.current = true;
return {
startAnimation: () => controls.start('animate'),
stopAnimation: () => controls.start('normal'),
};
});
const handleMouseEnter = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
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">
<polyline points="4 17 10 11 4 5"/>
<motion.line x1="12" x2="20" y1="19" y2="19" variants={LINE_VARIANTS} animate={controls} initial="normal"/>
</svg>
</div>);
});
TerminalIcon.displayName = 'TerminalIcon';
export { TerminalIcon };
-18
View File
@@ -1,18 +0,0 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,32 @@
"use client";
import * as React from "react";
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants> & {
spacing?: number;
}>({
size: "default",
variant: "default",
spacing: 0,
});
function ToggleGroup({ className, variant, size, spacing = 0, children, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants> & {
spacing?: number;
}) {
return (<ToggleGroupPrimitive.Root data-slot="toggle-group" data-variant={variant} data-size={size} data-spacing={spacing} style={{ "--gap": spacing } as React.CSSProperties} className={cn("group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs", className)} {...props}>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>);
}
function ToggleGroupItem({ className, children, variant, size, ...props }: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (<ToggleGroupPrimitive.Item data-slot="toggle-group-item" data-variant={context.variant || variant} data-size={context.size || size} data-spacing={context.spacing} className={cn(toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}), "w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10", "data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l", className)} {...props}>
{children}
</ToggleGroupPrimitive.Item>);
}
export { ToggleGroup, ToggleGroupItem };
+26
View File
@@ -0,0 +1,26 @@
"use client";
import * as React from "react";
import * as TogglePrimitive from "@radix-ui/react-toggle";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const toggleVariants = cva("inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap", {
variants: {
variant: {
default: "bg-transparent",
outline: "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
});
function Toggle({ className, variant, size, ...props }: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
return (<TogglePrimitive.Root data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props}/>);
}
export { Toggle, toggleVariants };
+18 -55
View File
@@ -1,61 +1,24 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
"use client";
import * as React from "react";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
import { cn } from "@/lib/utils";
function TooltipProvider({ delayDuration = 0, ...props }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (<TooltipPrimitive.Provider data-slot="tooltip-provider" delayDuration={delayDuration} {...props}/>);
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props}/>
</TooltipProvider>);
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props}/>;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
function TooltipContent({ className, sideOffset = 0, children, ...props }: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (<TooltipPrimitive.Portal>
<TooltipPrimitive.Content data-slot="tooltip-content" sideOffset={sideOffset} className={cn("bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance", className)} {...props}>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]" />
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%-2px)] rotate-45 rounded-[2px]"/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
</TooltipPrimitive.Portal>);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
+147
View File
@@ -0,0 +1,147 @@
import { useState, useCallback, useEffect } from "react";
import { AnalyzeTrack } from "../../wailsjs/go/main/App";
import type { AnalysisResult } from "@/types/api";
import { logger } from "@/lib/logger";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { setSpectrumCache, getSpectrumCache, clearSpectrumCache } from "@/lib/spectrum-cache";
const STORAGE_KEY = "spotiflac_audio_analysis_state";
export function useAudioAnalysis() {
const [analyzing, setAnalyzing] = useState(false);
const [result, setResult] = useState<AnalysisResult | null>(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.filePath && parsed.result) {
return {
...parsed.result,
spectrum: undefined,
};
}
}
}
catch (err) {
console.error("Failed to load saved analysis state:", err);
}
return null;
});
const [selectedFilePath, setSelectedFilePath] = useState<string>(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
return parsed.filePath || "";
}
}
catch (err) {
}
return "";
});
const [error, setError] = useState<string | null>(null);
const [spectrumLoading, setSpectrumLoading] = useState(() => {
try {
const saved = sessionStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
if (parsed.filePath && parsed.result) {
return true;
}
}
}
catch (err) {
}
return false;
});
const analyzeFile = useCallback(async (filePath: string) => {
if (!filePath) {
setError("No file path provided");
return null;
}
setAnalyzing(true);
setError(null);
setResult(null);
setSelectedFilePath(filePath);
try {
logger.info(`Analyzing audio file: ${filePath}`);
const startTime = Date.now();
const response = await AnalyzeTrack(filePath);
const analysisResult: AnalysisResult = JSON.parse(response);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
logger.success(`Audio analysis completed in ${elapsed}s`);
if (analysisResult.spectrum) {
setSpectrumCache(filePath, analysisResult.spectrum);
}
const { spectrum, ...detailResult } = analysisResult;
try {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify({
filePath,
result: detailResult,
}));
}
catch (err) {
console.error("Failed to save analysis state:", err);
}
setResult(analysisResult);
setSpectrumLoading(false);
return analysisResult;
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to analyze audio file";
logger.error(`Analysis error: ${errorMessage}`);
setError(errorMessage);
toast.error("Audio Analysis Failed", {
description: errorMessage,
});
return null;
}
finally {
setAnalyzing(false);
}
}, []);
const clearResult = useCallback(() => {
setResult(null);
setError(null);
setSelectedFilePath("");
try {
sessionStorage.removeItem(STORAGE_KEY);
}
catch (err) {
}
clearSpectrumCache();
}, []);
useEffect(() => {
if (!result || !selectedFilePath || result.spectrum || !spectrumLoading) {
return;
}
let rafId: number;
const loadSpectrum = () => {
rafId = requestAnimationFrame(() => {
const cachedSpectrum = getSpectrumCache(selectedFilePath);
if (cachedSpectrum) {
setResult(prev => prev ? { ...prev, spectrum: cachedSpectrum } : null);
setSpectrumLoading(false);
}
else {
setSpectrumLoading(false);
}
});
};
requestAnimationFrame(() => {
requestAnimationFrame(loadSpectrum);
});
return () => {
if (rafId) {
cancelAnimationFrame(rafId);
}
};
}, [result, selectedFilePath, spectrumLoading]);
return {
analyzing,
result,
error,
selectedFilePath,
spectrumLoading,
analyzeFile,
clearResult,
};
}
+60
View File
@@ -0,0 +1,60 @@
import { useState, useCallback } from "react";
import { CheckTrackAvailability } from "../../wailsjs/go/main/App";
import type { TrackAvailability } from "@/types/api";
import { logger } from "@/lib/logger";
export function useAvailability() {
const [checking, setChecking] = useState(false);
const [checkingTrackId, setCheckingTrackId] = useState<string | null>(null);
const [availabilityMap, setAvailabilityMap] = useState<Map<string, TrackAvailability>>(new Map());
const [error, setError] = useState<string | null>(null);
const checkAvailability = useCallback(async (spotifyId: string, isrc?: string) => {
if (!spotifyId) {
setError("No Spotify ID provided");
return null;
}
if (availabilityMap.has(spotifyId)) {
return availabilityMap.get(spotifyId)!;
}
setChecking(true);
setCheckingTrackId(spotifyId);
setError(null);
try {
logger.info(`Checking availability for track: ${spotifyId}`);
const response = await CheckTrackAvailability(spotifyId, isrc || "");
const availability: TrackAvailability = JSON.parse(response);
setAvailabilityMap((prev) => {
const newMap = new Map(prev);
newMap.set(spotifyId, availability);
return newMap;
});
logger.success(`Availability check completed for ${spotifyId}`);
return availability;
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : "Failed to check availability";
logger.error(`Availability check error: ${errorMessage}`);
setError(errorMessage);
return null;
}
finally {
setChecking(false);
setCheckingTrackId(null);
}
}, [availabilityMap]);
const getAvailability = useCallback((spotifyId: string) => {
return availabilityMap.get(spotifyId);
}, [availabilityMap]);
const clearAvailability = useCallback(() => {
setAvailabilityMap(new Map());
setError(null);
}, []);
return {
checking,
checkingTrackId,
availabilityMap,
error,
checkAvailability,
getAvailability,
clearAvailability,
};
}
+208
View File
@@ -0,0 +1,208 @@
import { useState, useRef } from "react";
import { downloadCover } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api";
export function useCover() {
const [downloadingCover, setDownloadingCover] = useState(false);
const [downloadingCoverTrack, setDownloadingCoverTrack] = useState<string | null>(null);
const [downloadedCovers, setDownloadedCovers] = useState<Set<string>>(new Set());
const [failedCovers, setFailedCovers] = useState<Set<string>>(new Set());
const [skippedCovers, setSkippedCovers] = useState<Set<string>>(new Set());
const [isBulkDownloadingCovers, setIsBulkDownloadingCovers] = useState(false);
const [coverDownloadProgress, setCoverDownloadProgress] = useState(0);
const stopBulkDownloadRef = useRef(false);
const handleDownloadCover = async (coverUrl: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, trackId?: string, albumArtist?: string, releaseDate?: string, discNumber?: number, isAlbum?: boolean) => {
if (!coverUrl) {
toast.error("No cover URL found for this track");
return;
}
const id = trackId || `${trackName}-${artistName}`;
logger.info(`downloading cover: ${trackName} - ${artistName}`);
const settings = getSettings();
setDownloadingCover(true);
setDownloadingCoverTrack(id);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: position,
playlist: playlistName?.replace(/\//g, placeholder),
};
if (playlistName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
const response = await downloadCover({
cover_url: coverUrl,
track_name: trackName,
artist_name: artistName,
album_name: albumName || "",
album_artist: albumArtist || "",
release_date: releaseDate || "",
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: position || 0,
disc_number: discNumber || 0,
});
if (response.success) {
if (response.already_exists) {
toast.info("Cover file already exists");
setSkippedCovers((prev) => new Set(prev).add(id));
}
else {
toast.success("Cover downloaded successfully");
setDownloadedCovers((prev) => new Set(prev).add(id));
}
setFailedCovers((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
else {
toast.error(response.error || "Failed to download cover");
setFailedCovers((prev) => new Set(prev).add(id));
}
}
catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to download cover");
setFailedCovers((prev) => new Set(prev).add(id));
}
finally {
setDownloadingCover(false);
setDownloadingCoverTrack(null);
}
};
const handleDownloadAllCovers = async (tracks: TrackMetadata[], playlistName?: string, isAlbum?: boolean) => {
if (tracks.length === 0) {
toast.error("No tracks to download covers");
return;
}
const settings = getSettings();
setIsBulkDownloadingCovers(true);
setCoverDownloadProgress(0);
stopBulkDownloadRef.current = false;
let completed = 0;
let success = 0;
let skipped = 0;
let failed = 0;
for (let i = 0; i < tracks.length; i++) {
if (stopBulkDownloadRef.current) {
toast.info("Cover download stopped");
break;
}
const track = tracks[i];
if (!track.images) {
completed++;
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
continue;
}
const id = track.spotify_id || `${track.name}-${track.artists}`;
setDownloadingCoverTrack(id);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
const placeholder = "__SLASH_PLACEHOLDER__";
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder),
track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder),
};
if (playlistName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
const response = await downloadCover({
cover_url: track.images,
track_name: track.name,
artist_name: track.artists,
album_name: track.album_name,
album_artist: track.album_artist,
release_date: track.release_date,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: trackPosition,
disc_number: track.disc_number,
});
if (response.success) {
if (response.already_exists) {
skipped++;
setSkippedCovers((prev) => new Set(prev).add(id));
}
else {
success++;
setDownloadedCovers((prev) => new Set(prev).add(id));
}
}
else {
failed++;
setFailedCovers((prev) => new Set(prev).add(id));
}
}
catch {
failed++;
setFailedCovers((prev) => new Set(prev).add(id));
}
completed++;
setCoverDownloadProgress(Math.round((completed / tracks.length) * 100));
}
setDownloadingCoverTrack(null);
setIsBulkDownloadingCovers(false);
setCoverDownloadProgress(0);
if (!stopBulkDownloadRef.current) {
toast.success(`Covers: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
}
};
const handleStopCoverDownload = () => {
stopBulkDownloadRef.current = true;
};
const resetCoverState = () => {
setDownloadedCovers(new Set());
setFailedCovers(new Set());
setSkippedCovers(new Set());
};
return {
downloadingCover,
downloadingCoverTrack,
downloadedCovers,
failedCovers,
skippedCovers,
isBulkDownloadingCovers,
coverDownloadProgress,
handleDownloadCover,
handleDownloadAllCovers,
handleStopCoverDownload,
resetCoverState,
};
}
File diff suppressed because it is too large Load Diff
+28 -38
View File
@@ -1,44 +1,34 @@
import { useState, useEffect, useRef } from "react";
import { GetDownloadProgress } from "../../wailsjs/go/main/App";
export interface DownloadProgressInfo {
is_downloading: boolean;
mb_downloaded: number;
speed_mbps: number;
is_downloading: boolean;
mb_downloaded: number;
speed_mbps: number;
}
export function useDownloadProgress() {
const [progress, setProgress] = useState<DownloadProgressInfo>({
is_downloading: false,
mb_downloaded: 0,
speed_mbps: 0,
});
const intervalRef = useRef<number | null>(null);
useEffect(() => {
// Poll progress every 200ms for smooth updates
const pollProgress = async () => {
try {
const progressInfo = await GetDownloadProgress();
setProgress(progressInfo);
} catch (error) {
console.error("Failed to get download progress:", error);
}
};
// Start polling
intervalRef.current = window.setInterval(pollProgress, 200);
// Initial fetch
pollProgress();
// Cleanup
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return progress;
const [progress, setProgress] = useState<DownloadProgressInfo>({
is_downloading: false,
mb_downloaded: 0,
speed_mbps: 0,
});
const intervalRef = useRef<number | null>(null);
useEffect(() => {
const pollProgress = async () => {
try {
const progressInfo = await GetDownloadProgress();
setProgress(progressInfo);
}
catch (error) {
console.error("Failed to get download progress:", error);
}
};
intervalRef.current = window.setInterval(pollProgress, 200);
pollProgress();
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
}
};
}, []);
return progress;
}
@@ -0,0 +1,31 @@
import { useEffect, useState } from "react";
import { GetDownloadQueue } from "../../wailsjs/go/main/App";
import { backend } from "../../wailsjs/go/models";
export function useDownloadQueueData() {
const [queueInfo, setQueueInfo] = useState<backend.DownloadQueueInfo>(new backend.DownloadQueueInfo({
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(() => {
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, 200);
return () => clearInterval(interval);
}, []);
return queueInfo;
}
@@ -0,0 +1,13 @@
import { useState } from "react";
export function useDownloadQueueDialog() {
const [isOpen, setIsOpen] = useState(false);
const openQueue = () => setIsOpen(true);
const closeQueue = () => setIsOpen(false);
const toggleQueue = () => setIsOpen((prev) => !prev);
return {
isOpen,
openQueue,
closeQueue,
toggleQueue,
};
}
+211
View File
@@ -0,0 +1,211 @@
import { useState, useRef } from "react";
import { downloadLyrics } from "@/lib/api";
import { getSettings, parseTemplate, type TemplateData } from "@/lib/settings";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { joinPath, sanitizePath } from "@/lib/utils";
import { logger } from "@/lib/logger";
import type { TrackMetadata } from "@/types/api";
export function useLyrics() {
const [downloadingLyricsTrack, setDownloadingLyricsTrack] = useState<string | null>(null);
const [downloadedLyrics, setDownloadedLyrics] = useState<Set<string>>(new Set());
const [failedLyrics, setFailedLyrics] = useState<Set<string>>(new Set());
const [skippedLyrics, setSkippedLyrics] = useState<Set<string>>(new Set());
const [isBulkDownloadingLyrics, setIsBulkDownloadingLyrics] = useState(false);
const [lyricsDownloadProgress, setLyricsDownloadProgress] = useState(0);
const stopBulkDownloadRef = useRef(false);
const handleDownloadLyrics = async (spotifyId: string, trackName: string, artistName: string, albumName?: string, playlistName?: string, position?: number, albumArtist?: string, releaseDate?: string, discNumber?: number, isAlbum?: boolean) => {
if (!spotifyId) {
toast.error("No Spotify ID found for this track");
return;
}
logger.info(`downloading lyrics: ${trackName} - ${artistName}`);
const settings = getSettings();
setDownloadingLyricsTrack(spotifyId);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
const placeholder = "__SLASH_PLACEHOLDER__";
const templateData: TemplateData = {
artist: artistName?.replace(/\//g, placeholder),
album: albumName?.replace(/\//g, placeholder),
title: trackName?.replace(/\//g, placeholder),
track: position,
playlist: playlistName?.replace(/\//g, placeholder),
};
if (playlistName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const response = await downloadLyrics({
spotify_id: spotifyId,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
album_artist: albumArtist,
release_date: releaseDate,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: position || 0,
use_album_track_number: useAlbumTrackNumber,
disc_number: discNumber,
});
if (response.success) {
if (response.already_exists) {
toast.info("Lyrics file already exists");
setSkippedLyrics((prev) => new Set(prev).add(spotifyId));
}
else {
toast.success("Lyrics downloaded successfully");
setDownloadedLyrics((prev) => new Set(prev).add(spotifyId));
}
setFailedLyrics((prev) => {
const newSet = new Set(prev);
newSet.delete(spotifyId);
return newSet;
});
}
else {
toast.error(response.error || "Failed to download lyrics");
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
}
}
catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to download lyrics");
setFailedLyrics((prev) => new Set(prev).add(spotifyId));
}
finally {
setDownloadingLyricsTrack(null);
}
};
const handleDownloadAllLyrics = async (tracks: TrackMetadata[], playlistName?: string, _isArtistDiscography?: boolean, isAlbum?: boolean) => {
const tracksWithSpotifyId = tracks.filter((track) => track.spotify_id);
if (tracksWithSpotifyId.length === 0) {
toast.error("No tracks with Spotify ID available for lyrics download");
return;
}
const settings = getSettings();
setIsBulkDownloadingLyrics(true);
setLyricsDownloadProgress(0);
stopBulkDownloadRef.current = false;
let completed = 0;
let success = 0;
let failed = 0;
let skipped = 0;
const total = tracksWithSpotifyId.length;
for (let i = 0; i < tracksWithSpotifyId.length; i++) {
const track = tracksWithSpotifyId[i];
if (stopBulkDownloadRef.current) {
toast.info("Lyrics download stopped by user");
break;
}
const id = track.spotify_id!;
setDownloadingLyricsTrack(id);
setLyricsDownloadProgress(Math.round((completed / total) * 100));
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
const placeholder = "__SLASH_PLACEHOLDER__";
const useAlbumTrackNumber = settings.folderTemplate?.includes("{album}") || false;
const trackPosition = useAlbumTrackNumber ? (track.track_number || i + 1) : (i + 1);
const templateData: TemplateData = {
artist: track.artists?.replace(/\//g, placeholder),
album: track.album_name?.replace(/\//g, placeholder),
title: track.name?.replace(/\//g, placeholder),
track: trackPosition,
playlist: playlistName?.replace(/\//g, placeholder),
};
if (playlistName && !isAlbum) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName.replace(/\//g, " "), os));
}
if (settings.folderTemplate) {
const folderPath = parseTemplate(settings.folderTemplate, templateData);
if (folderPath) {
const parts = folderPath.split("/").filter((p: string) => p.trim());
for (const part of parts) {
const sanitizedPart = part.replace(new RegExp(placeholder, "g"), " ");
outputDir = joinPath(os, outputDir, sanitizePath(sanitizedPart, os));
}
}
}
const response = await downloadLyrics({
spotify_id: id,
track_name: track.name,
artist_name: track.artists,
album_name: track.album_name,
album_artist: track.album_artist,
release_date: track.release_date,
output_dir: outputDir,
filename_format: settings.filenameTemplate || "{title}",
track_number: settings.trackNumber,
position: trackPosition,
use_album_track_number: useAlbumTrackNumber,
disc_number: track.disc_number,
});
if (response.success) {
if (response.already_exists) {
skipped++;
setSkippedLyrics((prev) => new Set(prev).add(id));
}
else {
success++;
setDownloadedLyrics((prev) => new Set(prev).add(id));
}
setFailedLyrics((prev) => {
const newSet = new Set(prev);
newSet.delete(id);
return newSet;
});
}
else {
failed++;
setFailedLyrics((prev) => new Set(prev).add(id));
}
}
catch (err) {
failed++;
logger.error(`error downloading lyrics: ${track.name} - ${err}`);
setFailedLyrics((prev) => new Set(prev).add(id));
}
completed++;
}
setDownloadingLyricsTrack(null);
setIsBulkDownloadingLyrics(false);
setLyricsDownloadProgress(0);
if (!stopBulkDownloadRef.current) {
toast.success(`Lyrics: ${success} downloaded, ${skipped} skipped, ${failed} failed`);
}
};
const handleStopLyricsDownload = () => {
logger.info("lyrics download stopped by user");
stopBulkDownloadRef.current = true;
toast.info("Stopping lyrics download...");
};
const resetLyricsState = () => {
setDownloadedLyrics(new Set());
setFailedLyrics(new Set());
setSkippedLyrics(new Set());
};
return {
downloadingLyricsTrack,
downloadedLyrics,
failedLyrics,
skippedLyrics,
isBulkDownloadingLyrics,
lyricsDownloadProgress,
handleDownloadLyrics,
handleDownloadAllLyrics,
handleStopLyricsDownload,
resetLyricsState,
};
}
+211 -110
View File
@@ -1,116 +1,217 @@
import { useState } from "react";
import { fetchSpotifyMetadata } from "@/lib/api";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
import { logger } from "@/lib/logger";
import type { SpotifyMetadataResponse } from "@/types/api";
export function useMetadata() {
const [loading, setLoading] = useState(false);
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
const [timeoutValue, setTimeoutValue] = useState(60);
const [pendingUrl, setPendingUrl] = useState("");
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState<{
id: string;
name: string;
external_urls: string;
} | null>(null);
const fetchMetadataDirectly = async (url: string) => {
setLoading(true);
setMetadata(null);
try {
const data = await fetchSpotifyMetadata(url);
setMetadata(data);
toast.success("Metadata fetched successfully");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
} finally {
setLoading(false);
}
};
const handleFetchMetadata = async (url: string) => {
if (!url.trim()) {
toast.error("Please enter a Spotify URL");
return;
}
let urlToFetch = url.trim();
const isArtistUrl = urlToFetch.includes("/artist/");
if (isArtistUrl && !urlToFetch.includes("/discography")) {
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
}
if (isArtistUrl) {
setPendingUrl(urlToFetch);
setShowTimeoutDialog(true);
} else {
await fetchMetadataDirectly(urlToFetch);
}
return urlToFetch;
};
const handleConfirmFetch = async () => {
setShowTimeoutDialog(false);
setLoading(true);
setMetadata(null);
try {
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
setMetadata(data);
toast.success("Metadata fetched successfully");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to fetch metadata");
} finally {
setLoading(false);
}
};
const handleAlbumClick = (album: {
id: string;
name: string;
external_urls: string;
}) => {
setSelectedAlbum(album);
setShowAlbumDialog(true);
};
const handleConfirmAlbumFetch = async () => {
if (!selectedAlbum) return;
setShowAlbumDialog(false);
setLoading(true);
setMetadata(null);
try {
const data = await fetchSpotifyMetadata(selectedAlbum.external_urls);
setMetadata(data);
toast.success("Album metadata fetched successfully");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to fetch album metadata");
} finally {
setLoading(false);
setSelectedAlbum(null);
}
};
return {
loading,
metadata,
showTimeoutDialog,
setShowTimeoutDialog,
timeoutValue,
setTimeoutValue,
showAlbumDialog,
setShowAlbumDialog,
selectedAlbum,
handleFetchMetadata,
handleConfirmFetch,
handleAlbumClick,
handleConfirmAlbumFetch,
};
const [loading, setLoading] = useState(false);
const [metadata, setMetadata] = useState<SpotifyMetadataResponse | null>(null);
const [showTimeoutDialog, setShowTimeoutDialog] = useState(false);
const [timeoutValue, setTimeoutValue] = useState(60);
const [pendingUrl, setPendingUrl] = useState("");
const [showAlbumDialog, setShowAlbumDialog] = useState(false);
const [selectedAlbum, setSelectedAlbum] = useState<{
id: string;
name: string;
external_urls: string;
} | null>(null);
const [pendingArtistName, setPendingArtistName] = useState<string | null>(null);
const getUrlType = (url: string): string => {
if (url.includes("/track/"))
return "track";
if (url.includes("/album/"))
return "album";
if (url.includes("/playlist/"))
return "playlist";
if (url.includes("/artist/"))
return "artist";
return "unknown";
};
const fetchMetadataDirectly = async (url: string) => {
const urlType = getUrlType(url);
logger.info(`fetching ${urlType} metadata...`);
logger.debug(`url: ${url}`);
setLoading(true);
setMetadata(null);
try {
const startTime = Date.now();
const data = await fetchSpotifyMetadata(url);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
if ("playlist_info" in data) {
const playlistInfo = data.playlist_info;
if (!playlistInfo.owner.name && playlistInfo.tracks.total === 0 && data.track_list.length === 0) {
logger.warning("playlist appears to be empty or private");
toast.error("Playlist not found or may be private");
setMetadata(null);
return;
}
}
else if ("album_info" in data) {
const albumInfo = data.album_info;
if (!albumInfo.name && albumInfo.total_tracks === 0 && data.track_list.length === 0) {
logger.warning("album appears to be empty or not found");
toast.error("Album not found or may be private");
setMetadata(null);
return;
}
}
setMetadata(data);
if ("track" in data) {
logger.success(`fetched track: ${data.track.name} - ${data.track.artists}`);
logger.debug(`isrc: ${data.track.isrc}, duration: ${data.track.duration_ms}ms`);
}
else if ("album_info" in data) {
logger.success(`fetched album: ${data.album_info.name}`);
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
}
else if ("playlist_info" in data) {
logger.success(`fetched playlist: ${data.track_list.length} tracks`);
logger.debug(`by ${data.playlist_info.owner.display_name || data.playlist_info.owner.name}`);
}
else if ("artist_info" in data) {
logger.success(`fetched artist: ${data.artist_info.name}`);
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
}
logger.info(`fetch completed in ${elapsed}s`);
toast.success("Metadata fetched successfully");
}
catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
}
finally {
setLoading(false);
}
};
const handleFetchMetadata = async (url: string) => {
if (!url.trim()) {
logger.warning("empty url provided");
toast.error("Please enter a Spotify URL");
return;
}
let urlToFetch = url.trim();
const isArtistUrl = urlToFetch.includes("/artist/");
if (isArtistUrl && !urlToFetch.includes("/discography")) {
urlToFetch = urlToFetch.replace(/\/$/, "") + "/discography/all";
logger.debug("converted to discography url");
}
if (isArtistUrl) {
logger.info("artist url detected, showing timeout dialog");
setPendingUrl(urlToFetch);
setPendingArtistName(null);
setShowTimeoutDialog(true);
}
else {
await fetchMetadataDirectly(urlToFetch);
}
return urlToFetch;
};
const handleConfirmFetch = async () => {
setShowTimeoutDialog(false);
logger.info(`fetching artist discography (timeout: ${timeoutValue}s)...`);
logger.debug(`url: ${pendingUrl}`);
setLoading(true);
setMetadata(null);
try {
const startTime = Date.now();
const data = await fetchSpotifyMetadata(pendingUrl, true, 1.0, timeoutValue);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
setMetadata(data);
if ("artist_info" in data) {
logger.success(`fetched artist: ${data.artist_info.name}`);
logger.debug(`${data.album_list.length} albums, ${data.track_list.length} tracks`);
}
logger.info(`fetch completed in ${elapsed}s`);
toast.success("Metadata fetched successfully");
}
catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
}
finally {
setLoading(false);
}
};
const handleAlbumClick = (album: {
id: string;
name: string;
external_urls: string;
}) => {
logger.debug(`album clicked: ${album.name}`);
setSelectedAlbum(album);
setShowAlbumDialog(true);
};
const handleArtistClick = async (artist: {
id: string;
name: string;
external_urls: string;
}) => {
logger.debug(`artist clicked: ${artist.name}`);
const artistUrl = artist.external_urls.replace(/\/$/, "") + "/discography/all";
setPendingUrl(artistUrl);
setPendingArtistName(artist.name);
setShowTimeoutDialog(true);
return artistUrl;
};
const handleConfirmAlbumFetch = async () => {
if (!selectedAlbum)
return;
const albumUrl = selectedAlbum.external_urls;
logger.info(`fetching album: ${selectedAlbum.name}...`);
logger.debug(`url: ${albumUrl}`);
setShowAlbumDialog(false);
setLoading(true);
setMetadata(null);
try {
const startTime = Date.now();
const data = await fetchSpotifyMetadata(albumUrl);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
if ("album_info" in data) {
const albumInfo = data.album_info;
if (!albumInfo.name && albumInfo.total_tracks === 0 && data.track_list.length === 0) {
logger.warning("album appears to be empty or not found");
toast.error("Album not found or may be private");
setMetadata(null);
setSelectedAlbum(null);
return albumUrl;
}
}
setMetadata(data);
if ("album_info" in data) {
logger.success(`fetched album: ${data.album_info.name}`);
logger.debug(`${data.track_list.length} tracks, released: ${data.album_info.release_date}`);
}
logger.info(`fetch completed in ${elapsed}s`);
toast.success("Album metadata fetched successfully");
return albumUrl;
}
catch (err) {
const errorMsg = err instanceof Error ? err.message : "Failed to fetch album metadata";
logger.error(`fetch failed: ${errorMsg}`);
toast.error(errorMsg);
}
finally {
setLoading(false);
setSelectedAlbum(null);
}
};
return {
loading,
metadata,
showTimeoutDialog,
setShowTimeoutDialog,
timeoutValue,
setTimeoutValue,
showAlbumDialog,
setShowAlbumDialog,
selectedAlbum,
pendingArtistName,
handleFetchMetadata,
handleConfirmFetch,
handleAlbumClick,
handleConfirmAlbumFetch,
handleArtistClick,
};
}
+102 -41
View File
@@ -26,19 +26,6 @@
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
@@ -61,19 +48,6 @@
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
@@ -95,19 +69,6 @@
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
@@ -116,13 +77,17 @@
}
body {
@apply bg-background text-foreground;
font-family: "Google Sans Flex", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-family: "Google Sans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
code, pre, .font-mono {
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-family: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace !important;
}
}
@theme inline {
--font-mono: "Google Sans Code", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
/* Sonner Toast Styling */
[data-sonner-toast] {
@apply rounded-lg shadow-lg border;
@@ -160,6 +125,54 @@
@apply text-blue-600;
}
/* Ensure description text uses same color as title */
[data-sonner-toast] [data-description],
[data-sonner-toast] [data-description] * {
opacity: 1 !important;
}
/* Specific color for each toast type - match icon color */
[data-sonner-toast][data-type="success"] [data-description],
[data-sonner-toast][data-type="success"] [data-description] * {
color: rgb(22 163 74) !important; /* green-600 - same as icon */
}
[data-sonner-toast][data-type="error"] [data-description],
[data-sonner-toast][data-type="error"] [data-description] * {
color: rgb(220 38 38) !important; /* red-600 - same as icon */
}
[data-sonner-toast][data-type="warning"] [data-description],
[data-sonner-toast][data-type="warning"] [data-description] * {
color: rgb(202 138 4) !important; /* yellow-600 - same as icon */
}
[data-sonner-toast][data-type="info"] [data-description],
[data-sonner-toast][data-type="info"] [data-description] * {
color: rgb(37 99 235) !important; /* blue-600 - same as icon */
}
/* Dark mode - use same icon colors */
.dark [data-sonner-toast][data-type="success"] [data-description],
.dark [data-sonner-toast][data-type="success"] [data-description] * {
color: rgb(22 163 74) !important; /* green-600 */
}
.dark [data-sonner-toast][data-type="error"] [data-description],
.dark [data-sonner-toast][data-type="error"] [data-description] * {
color: rgb(220 38 38) !important; /* red-600 */
}
.dark [data-sonner-toast][data-type="warning"] [data-description],
.dark [data-sonner-toast][data-type="warning"] [data-description] * {
color: rgb(202 138 4) !important; /* yellow-600 */
}
.dark [data-sonner-toast][data-type="info"] [data-description],
.dark [data-sonner-toast][data-type="info"] [data-description] * {
color: rgb(37 99 235) !important; /* blue-600 */
}
/* Dark mode toast styling */
.dark [data-sonner-toast][data-type="success"] {
@apply bg-green-950 border-green-800 text-green-100;
@@ -192,3 +205,51 @@
.dark [data-sonner-toast][data-type="info"] [data-icon] {
@apply text-blue-400;
}
/* Custom Scrollbar - mengikuti primary color theme */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--secondary);
border-radius: 4px;
}
::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 4px;
opacity: 0.7;
}
::-webkit-scrollbar-thumb:hover {
background: var(--primary);
filter: brightness(1.2);
}
/* Firefox scrollbar support */
* {
scrollbar-width: thin;
scrollbar-color: var(--primary) var(--secondary);
}
/* Custom scrollbar class untuk komponen tertentu */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: var(--muted);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
filter: brightness(1.2);
}
+38 -36
View File
@@ -1,41 +1,43 @@
import type {
SpotifyMetadataResponse,
DownloadRequest,
DownloadResponse,
HealthResponse,
} from "@/types/api";
import { GetSpotifyMetadata, DownloadTrack } from "../../wailsjs/go/main/App";
import type { SpotifyMetadataResponse, DownloadRequest, DownloadResponse, HealthResponse, LyricsDownloadRequest, LyricsDownloadResponse, CoverDownloadRequest, CoverDownloadResponse, HeaderDownloadRequest, HeaderDownloadResponse, GalleryImageDownloadRequest, GalleryImageDownloadResponse, AvatarDownloadRequest, AvatarDownloadResponse, } from "@/types/api";
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover, DownloadHeader, DownloadGalleryImage, DownloadAvatar } from "../../wailsjs/go/main/App";
import { main } from "../../wailsjs/go/models";
export async function fetchSpotifyMetadata(
url: string,
batch: boolean = true,
delay: number = 1.0,
timeout: number = 300.0
): Promise<SpotifyMetadataResponse> {
const req = new main.SpotifyMetadataRequest({
url,
batch,
delay,
timeout,
});
const jsonString = await GetSpotifyMetadata(req);
return JSON.parse(jsonString);
export async function fetchSpotifyMetadata(url: string, batch: boolean = true, delay: number = 1.0, timeout: number = 300.0): Promise<SpotifyMetadataResponse> {
const req = new main.SpotifyMetadataRequest({
url,
batch,
delay,
timeout,
});
const jsonString = await GetSpotifyMetadata(req);
return JSON.parse(jsonString);
}
export async function downloadTrack(
request: DownloadRequest
): Promise<DownloadResponse> {
const req = new main.DownloadRequest(request);
return await DownloadTrack(req);
export async function downloadTrack(request: DownloadRequest): Promise<DownloadResponse> {
const req = new main.DownloadRequest(request);
return await DownloadTrack(req);
}
export async function checkHealth(): Promise<HealthResponse> {
// For Wails, we can just return a simple health check
// since the app is running locally
return {
status: "ok",
time: new Date().toISOString(),
};
return {
status: "ok",
time: new Date().toISOString(),
};
}
export async function downloadLyrics(request: LyricsDownloadRequest): Promise<LyricsDownloadResponse> {
const req = new main.LyricsDownloadRequest(request);
return await DownloadLyrics(req);
}
export async function downloadCover(request: CoverDownloadRequest): Promise<CoverDownloadResponse> {
const req = new main.CoverDownloadRequest(request);
return await DownloadCover(req);
}
export async function downloadHeader(request: HeaderDownloadRequest): Promise<HeaderDownloadResponse> {
const req = new main.HeaderDownloadRequest(request);
return await DownloadHeader(req);
}
export async function downloadGalleryImage(request: GalleryImageDownloadRequest): Promise<GalleryImageDownloadResponse> {
const req = new main.GalleryImageDownloadRequest(request);
return await DownloadGalleryImage(req);
}
export async function downloadAvatar(request: AvatarDownloadRequest): Promise<AvatarDownloadResponse> {
const req = new main.AvatarDownloadRequest(request);
return await DownloadAvatar(req);
}
+62 -97
View File
@@ -1,106 +1,71 @@
// Audio utility for toast notifications using Web Audio API
class AudioManager {
private audioContext: AudioContext | null = null;
private getAudioContext(): AudioContext {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
private audioContext: AudioContext | null = null;
private getAudioContext(): AudioContext {
if (!this.audioContext) {
this.audioContext = new (window.AudioContext || (window as any).webkitAudioContext)();
}
return this.audioContext;
}
return this.audioContext;
}
// Generate a simple tone using oscillator
private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) {
try {
const ctx = this.getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.frequency.value = frequency;
oscillator.type = type;
gainNode.gain.setValueAtTime(volume, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + duration);
} catch (error) {
console.error('Error playing audio:', error);
private playTone(frequency: number, duration: number, type: OscillatorType = 'sine', volume: number = 0.3) {
try {
const ctx = this.getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.frequency.value = frequency;
oscillator.type = type;
gainNode.gain.setValueAtTime(volume, ctx.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + duration);
oscillator.start(ctx.currentTime);
oscillator.stop(ctx.currentTime + duration);
}
catch (error) {
console.error('Error playing audio:', error);
}
}
}
// Success sound - pleasant ascending tones
playSuccess() {
const ctx = this.getAudioContext();
const now = ctx.currentTime;
// First tone
this.playToneAt(523.25, 0.08, 'sine', 0.2, now); // C5
// Second tone
this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08); // E5
// Third tone
this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16); // G5
}
// Error sound - descending tones
playError() {
const ctx = this.getAudioContext();
const now = ctx.currentTime;
// First tone
this.playToneAt(392.00, 0.1, 'square', 0.15, now); // G4
// Second tone
this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1); // E4
}
// Warning sound - alternating tones
playWarning() {
const ctx = this.getAudioContext();
const now = ctx.currentTime;
// First tone
this.playToneAt(440.00, 0.1, 'triangle', 0.2, now); // A4
// Second tone
this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12); // B4
}
// Info sound - single soft tone
playInfo() {
this.playTone(523.25, 0.15, 'sine', 0.15); // C5
}
// Helper method to play tone at specific time
private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) {
try {
const ctx = this.getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.frequency.value = frequency;
oscillator.type = type;
gainNode.gain.setValueAtTime(volume, startTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
oscillator.start(startTime);
oscillator.stop(startTime + duration);
} catch (error) {
console.error('Error playing audio:', error);
playSuccess() {
const ctx = this.getAudioContext();
const now = ctx.currentTime;
this.playToneAt(523.25, 0.08, 'sine', 0.2, now);
this.playToneAt(659.25, 0.08, 'sine', 0.2, now + 0.08);
this.playToneAt(783.99, 0.15, 'sine', 0.25, now + 0.16);
}
playError() {
const ctx = this.getAudioContext();
const now = ctx.currentTime;
this.playToneAt(392.00, 0.1, 'square', 0.15, now);
this.playToneAt(329.63, 0.2, 'square', 0.2, now + 0.1);
}
playWarning() {
const ctx = this.getAudioContext();
const now = ctx.currentTime;
this.playToneAt(440.00, 0.1, 'triangle', 0.2, now);
this.playToneAt(493.88, 0.1, 'triangle', 0.2, now + 0.12);
}
playInfo() {
this.playTone(523.25, 0.15, 'sine', 0.15);
}
private playToneAt(frequency: number, duration: number, type: OscillatorType, volume: number, startTime: number) {
try {
const ctx = this.getAudioContext();
const oscillator = ctx.createOscillator();
const gainNode = ctx.createGain();
oscillator.connect(gainNode);
gainNode.connect(ctx.destination);
oscillator.frequency.value = frequency;
oscillator.type = type;
gainNode.gain.setValueAtTime(volume, startTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, startTime + duration);
oscillator.start(startTime);
oscillator.stop(startTime + duration);
}
catch (error) {
console.error('Error playing audio:', error);
}
}
}
}
// Export singleton instance
export const audioManager = new AudioManager();
// Helper functions for easy use
export const playSuccessSound = () => audioManager.playSuccess();
export const playErrorSound = () => audioManager.playError();
export const playWarningSound = () => audioManager.playWarning();
+53
View File
@@ -0,0 +1,53 @@
export type LogLevel = "info" | "success" | "warning" | "error" | "debug";
export interface LogEntry {
timestamp: Date;
level: LogLevel;
message: string;
}
class Logger {
private logs: LogEntry[] = [];
private maxLogs = 500;
private listeners: Set<() => void> = new Set();
private addLog(level: LogLevel, message: string) {
const entry: LogEntry = {
timestamp: new Date(),
level,
message: message.toLowerCase(),
};
this.logs.push(entry);
if (this.logs.length > this.maxLogs) {
this.logs.shift();
}
this.notifyListeners();
}
info(message: string) {
this.addLog("info", message);
}
success(message: string) {
this.addLog("success", message);
}
warning(message: string) {
this.addLog("warning", message);
}
error(message: string) {
this.addLog("error", message);
}
debug(message: string) {
this.addLog("debug", message);
}
getLogs(): LogEntry[] {
return [...this.logs];
}
clear() {
this.logs = [];
this.notifyListeners();
}
subscribe(listener: () => void) {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private notifyListeners() {
this.listeners.forEach((listener) => listener());
}
}
export const logger = new Logger();

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