Compare commits

...

4 Commits

Author SHA1 Message Date
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
43 changed files with 3441 additions and 1066 deletions
+2 -2
View File
@@ -16,7 +16,7 @@ Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music — no
## Screenshot ## Screenshot
![Image](https://github.com/user-attachments/assets/7aff07fa-abaa-4f88-96eb-c8b6794d206e) ![Image](https://github.com/user-attachments/assets/4da39b61-dcf8-4018-83e4-b8750a671245)
## Lossless Audio Checker ## Lossless Audio Checker
@@ -30,7 +30,7 @@ A simple utility for verifying the authenticity of FLAC files.
![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05) ![image](https://github.com/user-attachments/assets/7649e6e1-d5d1-49b3-b83f-965d44651d05)
## Other projects ## Other project
### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader) ### [SpotiDownloader](https://github.com/afkarxyz/SpotiDownloader)
+136 -11
View File
@@ -53,6 +53,7 @@ type DownloadRequest struct {
SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID SpotifyID string `json:"spotify_id,omitempty"` // Spotify track ID
ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call ServiceURL string `json:"service_url,omitempty"` // Direct service URL (Tidal/Deezer/Amazon) to skip song.link API call
Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching Duration int `json:"duration,omitempty"` // Track duration in seconds for better matching
ItemID string `json:"item_id,omitempty"` // Optional queue item ID for multi-service fallback tracking
} }
// DownloadResponse represents the response structure for download operations // DownloadResponse represents the response structure for download operations
@@ -62,6 +63,7 @@ type DownloadResponse struct {
File string `json:"file,omitempty"` File string `json:"file,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
AlreadyExists bool `json:"already_exists,omitempty"` AlreadyExists bool `json:"already_exists,omitempty"`
ItemID string `json:"item_id,omitempty"` // Queue item ID for tracking
} }
// GetStreamingURLs fetches all streaming URLs from song.link API // GetStreamingURLs fetches all streaming URLs from song.link API
@@ -143,14 +145,30 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
req.FilenameFormat = "title-artist" req.FilenameFormat = "title-artist"
} }
// ItemID should always be provided by frontend (created via AddToDownloadQueue)
// If not provided, generate one for backwards compatibility
itemID := req.ItemID
if itemID == "" {
itemID = fmt.Sprintf("%s-%d", req.ISRC, time.Now().UnixNano())
// Add to queue if no ItemID was provided (legacy support)
backend.AddToQueue(itemID, req.TrackName, req.ArtistName, req.AlbumName, req.ISRC)
}
// Mark item as downloading immediately
backend.SetDownloading(true)
backend.StartDownloadItem(itemID)
defer backend.SetDownloading(false)
// Early check: Check if file with same ISRC already exists // Early check: Check if file with same ISRC already exists
if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists { if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile) fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile)
backend.SkipDownloadItem(itemID, existingFile)
return DownloadResponse{ return DownloadResponse{
Success: true, Success: true,
Message: "File with same ISRC already exists", Message: "File with same ISRC already exists",
File: existingFile, File: existingFile,
AlreadyExists: true, AlreadyExists: true,
ItemID: itemID,
}, nil }, nil
} }
@@ -159,20 +177,28 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.FilenameFormat, req.TrackNumber, req.Position, req.UseAlbumTrackNumber) expectedFilename := backend.BuildExpectedFilename(req.TrackName, req.ArtistName, req.FilenameFormat, req.TrackNumber, req.Position, req.UseAlbumTrackNumber)
expectedPath := filepath.Join(req.OutputDir, expectedFilename) expectedPath := filepath.Join(req.OutputDir, expectedFilename)
if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 { if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
return DownloadResponse{ // Validate the file by checking if it has valid ISRC metadata
Success: true, if fileISRC, readErr := backend.ReadISRCFromFile(expectedPath); readErr == nil && fileISRC != "" {
Message: "File already exists", // File exists and has valid metadata - skip download
File: expectedPath, backend.SkipDownloadItem(itemID, expectedPath)
AlreadyExists: true, return DownloadResponse{
}, nil Success: true,
Message: "File already exists",
File: expectedPath,
AlreadyExists: true,
ItemID: itemID,
}, nil
} else {
// File exists but has no valid ISRC metadata - it's corrupted, delete it
fmt.Printf("Removing corrupted file (no valid ISRC metadata): %s\n", expectedPath)
if removeErr := os.Remove(expectedPath); removeErr != nil {
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", expectedPath, removeErr)
}
}
} }
} }
// Set downloading state
backend.SetDownloading(true)
defer backend.SetDownloading(false)
switch req.Service { switch req.Service {
case "amazon": case "amazon":
downloader := backend.NewAmazonDownloader() downloader := backend.NewAmazonDownloader()
@@ -243,9 +269,23 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
} }
if err != nil { if err != nil {
// Clean up any partial/corrupted file that was created during failed download
if filename != "" && !strings.HasPrefix(filename, "EXISTS:") {
// Check if file exists and delete it
if _, statErr := os.Stat(filename); statErr == nil {
fmt.Printf("Removing corrupted/partial file after failed download: %s\n", filename)
if removeErr := os.Remove(filename); removeErr != nil {
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filename, removeErr)
}
}
}
// Don't mark as failed in backend - let the frontend handle it
// Frontend will call MarkDownloadItemFailed after all services are tried
return DownloadResponse{ return DownloadResponse{
Success: false, Success: false,
Error: fmt.Sprintf("Download failed: %v", err), Error: fmt.Sprintf("Download failed: %v", err),
ItemID: itemID,
}, err }, err
} }
@@ -259,6 +299,16 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
message := "Download completed successfully" message := "Download completed successfully"
if alreadyExists { if alreadyExists {
message = "File already exists" message = "File already exists"
backend.SkipDownloadItem(itemID, filename)
} else {
// Get file size for completed download
if fileInfo, statErr := os.Stat(filename); statErr == nil {
finalSize := float64(fileInfo.Size()) / (1024 * 1024) // Convert to MB
backend.CompleteDownloadItem(itemID, filename, finalSize)
} else {
// Fallback: mark as completed without size
backend.CompleteDownloadItem(itemID, filename, 0)
}
} }
return DownloadResponse{ return DownloadResponse{
@@ -266,6 +316,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
Message: message, Message: message,
File: filename, File: filename,
AlreadyExists: alreadyExists, AlreadyExists: alreadyExists,
ItemID: itemID,
}, nil }, nil
} }
@@ -305,6 +356,38 @@ func (a *App) GetDownloadProgress() backend.ProgressInfo {
return backend.GetDownloadProgress() return backend.GetDownloadProgress()
} }
// GetDownloadQueue returns the complete download queue state
func (a *App) GetDownloadQueue() backend.DownloadQueueInfo {
return backend.GetDownloadQueue()
}
// ClearCompletedDownloads clears completed, failed, and skipped items from the queue
func (a *App) ClearCompletedDownloads() {
backend.ClearDownloadQueue()
}
// ClearAllDownloads clears the entire queue and resets session stats
func (a *App) ClearAllDownloads() {
backend.ClearAllDownloads()
}
// AddToDownloadQueue adds a new item to the download queue and returns its ID
func (a *App) AddToDownloadQueue(isrc, trackName, artistName, albumName string) string {
itemID := fmt.Sprintf("%s-%d", isrc, time.Now().UnixNano())
backend.AddToQueue(itemID, trackName, artistName, albumName, isrc)
return itemID
}
// MarkDownloadItemFailed marks a download item as failed
func (a *App) MarkDownloadItemFailed(itemID, errorMsg string) {
backend.FailDownloadItem(itemID, errorMsg)
}
// CancelAllQueuedItems marks all queued items as cancelled/skipped
func (a *App) CancelAllQueuedItems() {
backend.CancelAllQueuedItems()
}
// Quit closes the application // Quit closes the application
func (a *App) Quit() { func (a *App) Quit() {
// You can add cleanup logic here if needed // You can add cleanup logic here if needed
@@ -399,6 +482,48 @@ func (a *App) DownloadLyrics(req LyricsDownloadRequest) (backend.LyricsDownloadR
return *resp, nil return *resp, nil
} }
// CoverDownloadRequest represents the request structure for downloading cover art
type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
}
// DownloadCover downloads cover art for a single track
func (a *App) DownloadCover(req CoverDownloadRequest) (backend.CoverDownloadResponse, error) {
if req.CoverURL == "" {
return backend.CoverDownloadResponse{
Success: false,
Error: "Cover URL is required",
}, fmt.Errorf("cover URL is required")
}
client := backend.NewCoverClient()
backendReq := backend.CoverDownloadRequest{
CoverURL: req.CoverURL,
TrackName: req.TrackName,
ArtistName: req.ArtistName,
OutputDir: req.OutputDir,
FilenameFormat: req.FilenameFormat,
TrackNumber: req.TrackNumber,
Position: req.Position,
}
resp, err := client.DownloadCover(backendReq)
if err != nil {
return backend.CoverDownloadResponse{
Success: false,
Error: err.Error(),
}, err
}
return *resp, nil
}
// CheckTrackAvailability checks the availability of a track on different streaming platforms // CheckTrackAvailability checks the availability of a track on different streaming platforms
func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) { func (a *App) CheckTrackAvailability(spotifyTrackID string, isrc string) (string, error) {
if spotifyTrackID == "" { if spotifyTrackID == "" {
+176
View File
@@ -0,0 +1,176 @@
package backend
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const (
// Spotify image size codes
spotifySize640 = "ab67616d0000b273" // 640x640
spotifySizeMax = "ab67616d000082c1" // Max resolution
)
// CoverDownloadRequest represents a request to download cover art
type CoverDownloadRequest struct {
CoverURL string `json:"cover_url"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
OutputDir string `json:"output_dir"`
FilenameFormat string `json:"filename_format"`
TrackNumber bool `json:"track_number"`
Position int `json:"position"`
}
// CoverDownloadResponse represents the response from cover download
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"`
}
// CoverClient handles cover art downloading
type CoverClient struct {
httpClient *http.Client
}
// NewCoverClient creates a new cover client
func NewCoverClient() *CoverClient {
return &CoverClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
}
}
// buildCoverFilename builds the cover filename based on settings (same as track filename)
func buildCoverFilename(trackName, artistName, filenameFormat string, includeTrackNumber bool, position int) string {
safeTitle := sanitizeFilename(trackName)
safeArtist := sanitizeFilename(artistName)
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)
}
// Add track number prefix if enabled
if includeTrackNumber && position > 0 {
filename = fmt.Sprintf("%02d. %s", position, filename)
}
return filename + ".jpg"
}
// getMaxResolutionURL converts a Spotify cover URL to max resolution
// Falls back to original URL if max resolution is not available
func (c *CoverClient) getMaxResolutionURL(coverURL string) string {
// Try to convert to max resolution
if strings.Contains(coverURL, spotifySize640) {
maxURL := strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1)
// Check if max resolution URL is available
resp, err := c.httpClient.Head(maxURL)
if err == nil && resp.StatusCode == http.StatusOK {
return maxURL
}
}
// Return original URL as fallback
return coverURL
}
// DownloadCover downloads cover art for a single track
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")
}
// Create output directory if it doesn't exist
outputDir := req.OutputDir
if outputDir == "" {
outputDir = GetDefaultMusicPath()
}
if err := os.MkdirAll(outputDir, 0755); err != nil {
return &CoverDownloadResponse{
Success: false,
Error: fmt.Sprintf("failed to create output directory: %v", err),
}, err
}
// Generate filename using same format as track
filenameFormat := req.FilenameFormat
if filenameFormat == "" {
filenameFormat = "title-artist" // default
}
filename := buildCoverFilename(req.TrackName, req.ArtistName, filenameFormat, req.TrackNumber, req.Position)
filePath := filepath.Join(outputDir, filename)
// Check if file already exists
if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
return &CoverDownloadResponse{
Success: true,
Message: "Cover file already exists",
File: filePath,
AlreadyExists: true,
}, nil
}
// Try to get max resolution URL, fallback to original
downloadURL := c.getMaxResolutionURL(req.CoverURL)
// Download cover image
resp, err := c.httpClient.Get(downloadURL)
if err != nil {
return &CoverDownloadResponse{
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)
}
// Create file
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()
// Write content to file
_, 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
}
+6 -1
View File
@@ -168,9 +168,14 @@ func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
filepath := fmt.Sprintf("%s/%s", outputDir, filename) filepath := fmt.Sprintf("%s/%s", outputDir, filename)
// Read ISRC from file // Read ISRC from file (this will fail for corrupted files)
isrc, err := ReadISRCFromFile(filepath) isrc, err := ReadISRCFromFile(filepath)
if err != nil { if err != nil {
// File is corrupted or unreadable, delete it
fmt.Printf("Removing corrupted/unreadable file: %s (error: %v)\n", filepath, err)
if removeErr := os.Remove(filepath); removeErr != nil {
fmt.Printf("Warning: Failed to remove corrupted file %s: %v\n", filepath, removeErr)
}
continue continue
} }
+318 -1
View File
@@ -7,6 +7,34 @@ import (
"time" "time"
) )
// DownloadStatus represents the status of a download item
type DownloadStatus string
const (
StatusQueued DownloadStatus = "queued"
StatusDownloading DownloadStatus = "downloading"
StatusCompleted DownloadStatus = "completed"
StatusFailed DownloadStatus = "failed"
StatusSkipped DownloadStatus = "skipped"
)
// DownloadItem represents a single item in the download queue
type DownloadItem struct {
ID string `json:"id"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
AlbumName string `json:"album_name"`
ISRC string `json:"isrc"`
Status DownloadStatus `json:"status"`
Progress float64 `json:"progress"` // MB downloaded
TotalSize float64 `json:"total_size"` // MB total (if known)
Speed float64 `json:"speed"` // MB/s
StartTime int64 `json:"start_time"` // Unix timestamp
EndTime int64 `json:"end_time"` // Unix timestamp
ErrorMessage string `json:"error_message"` // If failed
FilePath string `json:"file_path"` // Final file path
}
// Global progress tracker // Global progress tracker
var ( var (
currentProgress float64 currentProgress float64
@@ -15,6 +43,16 @@ var (
downloadingLock sync.RWMutex downloadingLock sync.RWMutex
currentSpeed float64 currentSpeed float64
speedLock sync.RWMutex speedLock sync.RWMutex
// Download queue tracking
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 // ProgressInfo represents download progress information
@@ -24,6 +62,19 @@ type ProgressInfo struct {
SpeedMBps float64 `json:"speed_mbps"` SpeedMBps float64 `json:"speed_mbps"`
} }
// DownloadQueueInfo represents the complete download queue state
type DownloadQueueInfo struct {
IsDownloading bool `json:"is_downloading"`
Queue []DownloadItem `json:"queue"`
CurrentSpeed float64 `json:"current_speed"` // MB/s
TotalDownloaded float64 `json:"total_downloaded"` // MB this session
SessionStartTime int64 `json:"session_start_time"` // Unix timestamp
QueuedCount int `json:"queued_count"`
CompletedCount int `json:"completed_count"`
FailedCount int `json:"failed_count"`
SkippedCount int `json:"skipped_count"`
}
// GetDownloadProgress returns current download progress // GetDownloadProgress returns current download progress
func GetDownloadProgress() ProgressInfo { func GetDownloadProgress() ProgressInfo {
downloadingLock.RLock() downloadingLock.RLock()
@@ -80,6 +131,7 @@ type ProgressWriter struct {
startTime int64 startTime int64
lastTime int64 lastTime int64
lastBytes int64 lastBytes int64
itemID string // Track which download item this belongs to
} }
func NewProgressWriter(writer io.Writer) *ProgressWriter { func NewProgressWriter(writer io.Writer) *ProgressWriter {
@@ -91,9 +143,17 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter {
startTime: now, startTime: now,
lastTime: now, lastTime: now,
lastBytes: 0, lastBytes: 0,
itemID: "",
} }
} }
// NewProgressWriterWithID creates a progress writer with an item ID for queue tracking
func NewProgressWriterWithID(writer io.Writer, itemID string) *ProgressWriter {
pw := NewProgressWriter(writer)
pw.itemID = itemID
return pw
}
func getCurrentTimeMillis() int64 { func getCurrentTimeMillis() int64 {
return time.Now().UnixMilli() return time.Now().UnixMilli()
} }
@@ -111,8 +171,9 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
bytesDiff := float64(pw.total - pw.lastBytes) bytesDiff := float64(pw.total - pw.lastBytes)
var speedMBps float64
if timeDiff > 0 { if timeDiff > 0 {
speedMBps := (bytesDiff / (1024 * 1024)) / timeDiff speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
SetDownloadSpeed(speedMBps) SetDownloadSpeed(speedMBps)
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps) fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
} else { } else {
@@ -122,6 +183,11 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
// Update global progress // Update global progress
SetDownloadProgress(mbDownloaded) SetDownloadProgress(mbDownloaded)
// Update individual item progress if we have an item ID
if pw.itemID != "" {
UpdateItemProgress(pw.itemID, mbDownloaded, speedMBps)
}
pw.lastPrinted = pw.total pw.lastPrinted = pw.total
pw.lastTime = now pw.lastTime = now
pw.lastBytes = pw.total pw.lastBytes = pw.total
@@ -133,3 +199,254 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
func (pw *ProgressWriter) GetTotal() int64 { func (pw *ProgressWriter) GetTotal() int64 {
return pw.total return pw.total
} }
// Queue management functions
// AddToQueue adds a new item to the download queue
func AddToQueue(id, trackName, artistName, albumName, isrc string) {
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)
// Initialize session start time if this is the first item
sessionStartLock.Lock()
if sessionStartTime == 0 {
sessionStartTime = time.Now().Unix()
}
sessionStartLock.Unlock()
}
// StartDownloadItem marks an item as currently downloading
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()
}
// UpdateItemProgress updates the progress of the current download item
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
}
}
}
// CompleteDownloadItem marks an item as completed
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
// Add to total downloaded
totalDownloadedLock.Lock()
totalDownloaded += finalSize
totalDownloadedLock.Unlock()
break
}
}
}
// FailDownloadItem marks an item as failed
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
}
}
}
// SkipDownloadItem marks an item as skipped (already exists)
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
}
}
}
// GetDownloadQueue returns the complete download queue state
func GetDownloadQueue() DownloadQueueInfo {
// Auto-reset session if all downloads are complete
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()
// Count statuses
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++
}
}
// Create a copy of the queue
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,
}
}
// ClearDownloadQueue clears all completed, failed, and skipped items from the queue
func ClearDownloadQueue() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
// Keep only queued and downloading items
newQueue := make([]DownloadItem, 0)
for _, item := range downloadQueue {
if item.Status == StatusQueued || item.Status == StatusDownloading {
newQueue = append(newQueue, item)
}
}
downloadQueue = newQueue
}
// ClearAllDownloads clears the entire queue and resets session stats
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()
// Reset current progress and speed
SetDownloadProgress(0)
SetDownloadSpeed(0)
}
// CancelAllQueuedItems marks all queued items as skipped (cancelled)
// This is called when user stops a download or when batch download completes
func CancelAllQueuedItems() {
downloadQueueLock.Lock()
defer downloadQueueLock.Unlock()
for i := range downloadQueue {
if downloadQueue[i].Status == StatusQueued {
downloadQueue[i].Status = StatusSkipped
downloadQueue[i].EndTime = time.Now().Unix()
downloadQueue[i].ErrorMessage = "Cancelled"
}
}
}
// ResetSessionIfComplete resets session stats if no active or queued downloads
// Note: Does NOT clear the queue - items remain visible for history
func ResetSessionIfComplete() {
downloadQueueLock.RLock()
hasActiveOrQueued := false
for _, item := range downloadQueue {
if item.Status == StatusQueued || item.Status == StatusDownloading {
hasActiveOrQueued = true
break
}
}
downloadQueueLock.RUnlock()
// If no active or queued items, reset session stats
// But keep the queue items for history visibility
if !hasActiveOrQueued {
sessionStartLock.Lock()
sessionStartTime = 0
sessionStartLock.Unlock()
totalDownloadedLock.Lock()
totalDownloaded = 0
totalDownloadedLock.Unlock()
}
}
+1 -1
View File
@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=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=Google+Sans+Code:ital,wght@0,300..800;1,300..800&family=Google+Sans+Flex:opsz,wght@6..144,1..1000&family=Inter:wght@300..800&family=Manrope:wght@300..800&family=Plus+Jakarta+Sans:wght@300..800&family=Poppins:wght@300;400;500;600;700;800&family=Roboto:wght@300;400;500;700;900&family=Space+Grotesk:wght@300..700&display=swap" rel="stylesheet">
<title>SpotiFLAC</title> <title>SpotiFLAC</title>
</head> </head>
<body> <body>
+6 -4
View File
@@ -18,14 +18,16 @@
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.1.3",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.1.17",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.554.0", "lucide-react": "^0.555.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -36,7 +38,7 @@
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.1", "@eslint/js": "^9.39.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.6", "@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1", "eslint": "^9.39.1",
@@ -46,7 +48,7 @@
"sharp": "^0.34.5", "sharp": "^0.34.5",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"typescript-eslint": "^8.47.0", "typescript-eslint": "^8.48.0",
"vite": "^7.2.4" "vite": "^7.2.6"
} }
} }
+1 -1
View File
@@ -1 +1 @@
e92e100705a0bb90f6783cd4074df1a7 b7a549e463d5f6a2fad25f5ce939cdd7
+429 -486
View File
File diff suppressed because it is too large Load Diff
+206 -128
View File
@@ -11,52 +11,65 @@ import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Search, X } from "lucide-react"; import { Search, X } from "lucide-react";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { getSettings, applyThemeMode } from "@/lib/settings"; import { getSettings, applyThemeMode, applyFont } from "@/lib/settings";
import { applyTheme } from "@/lib/themes"; import { applyTheme } from "@/lib/themes";
import { OpenFolder } from "../wailsjs/go/main/App"; import { OpenFolder } from "../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound"; import { toastWithSound as toast } from "@/lib/toast-with-sound";
// Components // Components
import { TitleBar } from "@/components/TitleBar"; import { TitleBar } from "@/components/TitleBar";
import { Sidebar, type PageType } from "@/components/Sidebar";
import { Header } from "@/components/Header"; import { Header } from "@/components/Header";
import { SearchBar } from "@/components/SearchBar"; import { SearchBar } from "@/components/SearchBar";
import { TrackInfo } from "@/components/TrackInfo"; import { TrackInfo } from "@/components/TrackInfo";
import { AlbumInfo } from "@/components/AlbumInfo"; import { AlbumInfo } from "@/components/AlbumInfo";
import { PlaylistInfo } from "@/components/PlaylistInfo"; import { PlaylistInfo } from "@/components/PlaylistInfo";
import { ArtistInfo } from "@/components/ArtistInfo"; import { ArtistInfo } from "@/components/ArtistInfo";
import { DownloadQueue } from "@/components/DownloadQueue";
import { DownloadProgressToast } from "@/components/DownloadProgressToast"; import { DownloadProgressToast } from "@/components/DownloadProgressToast";
import { AudioAnalysisPage } from "@/components/AudioAnalysisPage";
import { SettingsPage } from "@/components/SettingsPage";
import { DebugLoggerPage } from "@/components/DebugLoggerPage";
import type { HistoryItem } from "@/components/FetchHistory"; import type { HistoryItem } from "@/components/FetchHistory";
// Hooks // Hooks
import { useDownload } from "@/hooks/useDownload"; import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata"; import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics"; import { useLyrics } from "@/hooks/useLyrics";
import { useCover } from "@/hooks/useCover";
import { useAvailability } from "@/hooks/useAvailability"; import { useAvailability } from "@/hooks/useAvailability";
import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
const HISTORY_KEY = "spotiflac_fetch_history"; const HISTORY_KEY = "spotiflac_fetch_history";
const MAX_HISTORY = 5; const MAX_HISTORY = 5;
function App() { function App() {
const [currentPage, setCurrentPage] = useState<PageType>("main");
const [spotifyUrl, setSpotifyUrl] = useState(""); const [spotifyUrl, setSpotifyUrl] = useState("");
const [selectedTracks, setSelectedTracks] = useState<string[]>([]); const [selectedTracks, setSelectedTracks] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState(""); const [searchQuery, setSearchQuery] = useState("");
const [sortBy, setSortBy] = useState<string>("default"); const [sortBy, setSortBy] = useState<string>("default");
const [currentPage, setCurrentPage] = useState(1); const [currentListPage, setCurrentListPage] = useState(1);
const [hasUpdate, setHasUpdate] = useState(false); const [hasUpdate, setHasUpdate] = useState(false);
const [releaseDate, setReleaseDate] = useState<string | null>(null);
const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]); const [fetchHistory, setFetchHistory] = useState<HistoryItem[]>([]);
const ITEMS_PER_PAGE = 50; const ITEMS_PER_PAGE = 50;
const CURRENT_VERSION = "6.4"; const CURRENT_VERSION = "6.6";
const download = useDownload(); const download = useDownload();
const metadata = useMetadata(); const metadata = useMetadata();
const lyrics = useLyrics(); const lyrics = useLyrics();
const cover = useCover();
const availability = useAvailability(); const availability = useAvailability();
const downloadQueue = useDownloadQueueDialog();
useEffect(() => { useEffect(() => {
const settings = getSettings(); const settings = getSettings();
applyThemeMode(settings.themeMode); applyThemeMode(settings.themeMode);
applyTheme(settings.theme); applyTheme(settings.theme);
applyFont(settings.fontFamily);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => { const handleChange = () => {
@@ -81,9 +94,10 @@ function App() {
setSearchQuery(""); setSearchQuery("");
download.resetDownloadedTracks(); download.resetDownloadedTracks();
lyrics.resetLyricsState(); lyrics.resetLyricsState();
cover.resetCoverState();
availability.clearAvailability(); availability.clearAvailability();
setSortBy("default"); setSortBy("default");
setCurrentPage(1); setCurrentListPage(1);
}, [metadata.metadata]); }, [metadata.metadata]);
const checkForUpdates = async () => { const checkForUpdates = async () => {
@@ -92,9 +106,12 @@ function App() {
"https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest" "https://api.github.com/repos/afkarxyz/SpotiFLAC/releases/latest"
); );
const data = await response.json(); const data = await response.json();
// tag_name format: "v6.1" -> extract "6.1"
const latestVersion = data.tag_name?.replace(/^v/, "") || ""; const latestVersion = data.tag_name?.replace(/^v/, "") || "";
if (data.published_at) {
setReleaseDate(data.published_at);
}
if (latestVersion && latestVersion > CURRENT_VERSION) { if (latestVersion && latestVersion > CURRENT_VERSION) {
setHasUpdate(true); setHasUpdate(true);
} }
@@ -159,7 +176,6 @@ function App() {
} }
}; };
// Add to history when metadata is successfully fetched
useEffect(() => { useEffect(() => {
if (!metadata.metadata || !spotifyUrl) return; if (!metadata.metadata || !spotifyUrl) return;
@@ -210,7 +226,7 @@ function App() {
const handleSearchChange = (value: string) => { const handleSearchChange = (value: string) => {
setSearchQuery(value); setSearchQuery(value);
setCurrentPage(1); setCurrentListPage(1);
}; };
const toggleTrackSelection = (isrc: string) => { const toggleTrackSelection = (isrc: string) => {
@@ -243,6 +259,7 @@ function App() {
} }
}; };
const renderMetadata = () => { const renderMetadata = () => {
if (!metadata.metadata) return null; if (!metadata.metadata) return null;
@@ -255,15 +272,18 @@ function App() {
downloadingTrack={download.downloadingTrack} downloadingTrack={download.downloadingTrack}
isDownloaded={download.downloadedTracks.has(track.isrc)} isDownloaded={download.downloadedTracks.has(track.isrc)}
isFailed={download.failedTracks.has(track.isrc)} isFailed={download.failedTracks.has(track.isrc)}
isSkipped={download.skippedTracks.has(track.isrc)}
downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")} downloadedLyrics={lyrics.downloadedLyrics.has(track.spotify_id || "")}
failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")} failedLyrics={lyrics.failedLyrics.has(track.spotify_id || "")}
skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")} skippedLyrics={lyrics.skippedLyrics.has(track.spotify_id || "")}
checkingAvailability={availability.checkingTrackId === track.spotify_id} checkingAvailability={availability.checkingTrackId === track.spotify_id}
availability={availability.getAvailability(track.spotify_id || "")} availability={availability.getAvailability(track.spotify_id || "")}
downloadingCover={cover.downloadingCover}
onDownload={download.handleDownloadTrack} onDownload={download.handleDownloadTrack}
onDownloadLyrics={lyrics.handleDownloadLyrics} onDownloadLyrics={lyrics.handleDownloadLyrics}
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadCover={cover.handleDownloadCover}
onOpenFolder={handleOpenFolder} onOpenFolder={handleOpenFolder}
/> />
); );
@@ -286,7 +306,7 @@ function App() {
bulkDownloadType={download.bulkDownloadType} bulkDownloadType={download.bulkDownloadType}
downloadProgress={download.downloadProgress} downloadProgress={download.downloadProgress}
currentDownloadInfo={download.currentDownloadInfo} currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage} currentPage={currentListPage}
itemsPerPage={ITEMS_PER_PAGE} itemsPerPage={ITEMS_PER_PAGE}
downloadedLyrics={lyrics.downloadedLyrics} downloadedLyrics={lyrics.downloadedLyrics}
failedLyrics={lyrics.failedLyrics} failedLyrics={lyrics.failedLyrics}
@@ -294,6 +314,11 @@ function App() {
downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
checkingAvailabilityTrack={availability.checkingTrackId} checkingAvailabilityTrack={availability.checkingTrackId}
availabilityMap={availability.availabilityMap} availabilityMap={availability.availabilityMap}
downloadedCovers={cover.downloadedCovers}
failedCovers={cover.failedCovers}
skippedCovers={cover.skippedCovers}
downloadingCoverTrack={cover.downloadingCoverTrack}
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onSortChange={setSortBy} onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
@@ -302,14 +327,18 @@ function App() {
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, false, position) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, album_info.name, false, position)
} }
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, album_info.name, false, position, trackId)
}
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, album_info.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)} onDownloadAll={() => download.handleDownloadAll(track_list, album_info.name)}
onDownloadSelected={() => onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, album_info.name) download.handleDownloadSelected(selectedTracks, track_list, album_info.name)
} }
onStopDownload={download.handleStopDownload} onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder} onOpenFolder={handleOpenFolder}
onPageChange={setCurrentPage} onPageChange={setCurrentListPage}
onArtistClick={async (artist) => { onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
if (artistUrl) { if (artistUrl) {
@@ -343,7 +372,7 @@ function App() {
bulkDownloadType={download.bulkDownloadType} bulkDownloadType={download.bulkDownloadType}
downloadProgress={download.downloadProgress} downloadProgress={download.downloadProgress}
currentDownloadInfo={download.currentDownloadInfo} currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage} currentPage={currentListPage}
itemsPerPage={ITEMS_PER_PAGE} itemsPerPage={ITEMS_PER_PAGE}
downloadedLyrics={lyrics.downloadedLyrics} downloadedLyrics={lyrics.downloadedLyrics}
failedLyrics={lyrics.failedLyrics} failedLyrics={lyrics.failedLyrics}
@@ -351,6 +380,11 @@ function App() {
downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
checkingAvailabilityTrack={availability.checkingTrackId} checkingAvailabilityTrack={availability.checkingTrackId}
availabilityMap={availability.availabilityMap} availabilityMap={availability.availabilityMap}
downloadedCovers={cover.downloadedCovers}
failedCovers={cover.failedCovers}
skippedCovers={cover.skippedCovers}
downloadingCoverTrack={cover.downloadingCoverTrack}
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onSortChange={setSortBy} onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
@@ -359,7 +393,11 @@ function App() {
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, _isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, false, position) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, playlist_info.owner.name, false, position)
} }
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, _isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, playlist_info.owner.name, false, position, trackId)
}
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, playlist_info.owner.name)}
onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)} onDownloadAll={() => download.handleDownloadAll(track_list, playlist_info.owner.name)}
onDownloadSelected={() => onDownloadSelected={() =>
download.handleDownloadSelected( download.handleDownloadSelected(
@@ -370,7 +408,7 @@ function App() {
} }
onStopDownload={download.handleStopDownload} onStopDownload={download.handleStopDownload}
onOpenFolder={handleOpenFolder} onOpenFolder={handleOpenFolder}
onPageChange={setCurrentPage} onPageChange={setCurrentListPage}
onAlbumClick={metadata.handleAlbumClick} onAlbumClick={metadata.handleAlbumClick}
onArtistClick={async (artist) => { onArtistClick={async (artist) => {
const artistUrl = await metadata.handleArtistClick(artist); const artistUrl = await metadata.handleArtistClick(artist);
@@ -406,7 +444,7 @@ function App() {
bulkDownloadType={download.bulkDownloadType} bulkDownloadType={download.bulkDownloadType}
downloadProgress={download.downloadProgress} downloadProgress={download.downloadProgress}
currentDownloadInfo={download.currentDownloadInfo} currentDownloadInfo={download.currentDownloadInfo}
currentPage={currentPage} currentPage={currentListPage}
itemsPerPage={ITEMS_PER_PAGE} itemsPerPage={ITEMS_PER_PAGE}
downloadedLyrics={lyrics.downloadedLyrics} downloadedLyrics={lyrics.downloadedLyrics}
failedLyrics={lyrics.failedLyrics} failedLyrics={lyrics.failedLyrics}
@@ -414,6 +452,11 @@ function App() {
downloadingLyricsTrack={lyrics.downloadingLyricsTrack} downloadingLyricsTrack={lyrics.downloadingLyricsTrack}
checkingAvailabilityTrack={availability.checkingTrackId} checkingAvailabilityTrack={availability.checkingTrackId}
availabilityMap={availability.availabilityMap} availabilityMap={availability.availabilityMap}
downloadedCovers={cover.downloadedCovers}
failedCovers={cover.failedCovers}
skippedCovers={cover.skippedCovers}
downloadingCoverTrack={cover.downloadingCoverTrack}
isBulkDownloadingCovers={cover.isBulkDownloadingCovers}
onSearchChange={handleSearchChange} onSearchChange={handleSearchChange}
onSortChange={setSortBy} onSortChange={setSortBy}
onToggleTrack={toggleTrackSelection} onToggleTrack={toggleTrackSelection}
@@ -422,7 +465,11 @@ function App() {
onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, isArtistDiscography, position) => onDownloadLyrics={(spotifyId, name, artists, albumName, _folderName, isArtistDiscography, position) =>
lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, isArtistDiscography, position) lyrics.handleDownloadLyrics(spotifyId, name, artists, albumName, artist_info.name, isArtistDiscography, position)
} }
onDownloadCover={(coverUrl, trackName, artistName, albumName, _folderName, isArtistDiscography, position, trackId) =>
cover.handleDownloadCover(coverUrl, trackName, artistName, albumName, artist_info.name, isArtistDiscography, position, trackId)
}
onCheckAvailability={availability.checkAvailability} onCheckAvailability={availability.checkAvailability}
onDownloadAllCovers={() => cover.handleDownloadAllCovers(track_list, artist_info.name, true)}
onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)} onDownloadAll={() => download.handleDownloadAll(track_list, artist_info.name, true)}
onDownloadSelected={() => onDownloadSelected={() =>
download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true) download.handleDownloadSelected(selectedTracks, track_list, artist_info.name, true)
@@ -436,7 +483,7 @@ function App() {
setSpotifyUrl(artistUrl); setSpotifyUrl(artistUrl);
} }
}} }}
onPageChange={setCurrentPage} onPageChange={setCurrentListPage}
onTrackClick={async (track) => { onTrackClick={async (track) => {
if (track.external_urls) { if (track.external_urls) {
setSpotifyUrl(track.external_urls); setSpotifyUrl(track.external_urls);
@@ -450,128 +497,159 @@ function App() {
return null; return null;
}; };
const renderPage = () => {
switch (currentPage) {
case "settings":
return <SettingsPage />;
case "debug":
return <DebugLoggerPage />;
case "audio-analysis":
return <AudioAnalysisPage />;
default:
return (
<>
<Header
version={CURRENT_VERSION}
hasUpdate={hasUpdate}
releaseDate={releaseDate}
/>
{/* Timeout Dialog */}
<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>
{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>
<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] 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>
{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}
history={fetchHistory}
onHistorySelect={handleHistorySelect}
onHistoryRemove={removeFromHistory}
hasResult={!!metadata.metadata}
/>
{metadata.metadata && renderMetadata()}
</>
);
}
};
return ( return (
<TooltipProvider> <TooltipProvider>
<div className="min-h-screen bg-background flex flex-col"> <div className="min-h-screen bg-background flex flex-col">
<TitleBar /> <TitleBar />
<div className="flex-1 p-4 md:p-8"> <Sidebar currentPage={currentPage} onPageChange={setCurrentPage} />
{/* Main content area with sidebar offset */}
<div className="flex-1 ml-14 mt-10 p-4 md:p-8">
<div className="max-w-4xl mx-auto space-y-6"> <div className="max-w-4xl mx-auto space-y-6">
<Header version={CURRENT_VERSION} hasUpdate={hasUpdate} /> {renderPage()}
{/* Download Progress Toast */}
<DownloadProgressToast />
{/* Timeout Dialog */}
<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>
{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>
<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] 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>
{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}
history={fetchHistory}
onHistorySelect={handleHistorySelect}
onHistoryRemove={removeFromHistory}
hasResult={!!metadata.metadata}
/>
{metadata.metadata && renderMetadata()}
</div> </div>
</div> </div>
{/* Download Progress Toast - Bottom Left */}
<DownloadProgressToast onClick={downloadQueue.openQueue} />
{/* Download Queue Dialog */}
<DownloadQueue
isOpen={downloadQueue.isOpen}
onClose={downloadQueue.closeQueue}
/>
</div> </div>
</TooltipProvider> </TooltipProvider>
); );
+38 -1
View File
@@ -1,7 +1,8 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react"; import { Download, FolderOpen, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort"; import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList"; import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress"; import { DownloadProgress } from "./DownloadProgress";
@@ -39,13 +40,21 @@ interface AlbumInfoProps {
// Availability props // Availability props
checkingAvailabilityTrack?: string | null; checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>; availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
onCheckAvailability?: (spotifyId: string) => void; onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void; onDownloadAll: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
onStopDownload: () => void; onStopDownload: () => void;
@@ -77,13 +86,20 @@ export function AlbumInfo({
downloadingLyricsTrack, downloadingLyricsTrack,
checkingAvailabilityTrack, checkingAvailabilityTrack,
availabilityMap, availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
isBulkDownloadingCovers,
onSearchChange, onSearchChange,
onSortChange, onSortChange,
onToggleTrack, onToggleTrack,
onToggleSelectAll, onToggleSelectAll,
onDownloadTrack, onDownloadTrack,
onDownloadLyrics, onDownloadLyrics,
onDownloadCover,
onCheckAvailability, onCheckAvailability,
onDownloadAllCovers,
onDownloadAll, onDownloadAll,
onDownloadSelected, onDownloadSelected,
onStopDownload, onStopDownload,
@@ -154,6 +170,22 @@ export function AlbumInfo({
Download Selected ({selectedTracks.length}) Download Selected ({selectedTracks.length})
</Button> </Button>
)} )}
{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 && ( {downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline"> <Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
@@ -204,6 +236,11 @@ export function AlbumInfo({
onToggleSelectAll={onToggleSelectAll} onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack} onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics} onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onCheckAvailability={onCheckAvailability} onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange} onPageChange={onPageChange}
onTrackClick={onTrackClick} onTrackClick={onTrackClick}
+39 -1
View File
@@ -1,7 +1,8 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react"; import { Download, FolderOpen, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort"; import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList"; import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress"; import { DownloadProgress } from "./DownloadProgress";
@@ -44,13 +45,21 @@ interface ArtistInfoProps {
// Availability props // Availability props
checkingAvailabilityTrack?: string | null; checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>; availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
onCheckAvailability?: (spotifyId: string) => void; onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void; onDownloadAll: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
onStopDownload: () => void; onStopDownload: () => void;
@@ -84,13 +93,20 @@ export function ArtistInfo({
downloadingLyricsTrack, downloadingLyricsTrack,
checkingAvailabilityTrack, checkingAvailabilityTrack,
availabilityMap, availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
isBulkDownloadingCovers,
onSearchChange, onSearchChange,
onSortChange, onSortChange,
onToggleTrack, onToggleTrack,
onToggleSelectAll, onToggleSelectAll,
onDownloadTrack, onDownloadTrack,
onDownloadLyrics, onDownloadLyrics,
onDownloadCover,
onCheckAvailability, onCheckAvailability,
onDownloadAllCovers,
onDownloadAll, onDownloadAll,
onDownloadSelected, onDownloadSelected,
onStopDownload, onStopDownload,
@@ -196,6 +212,23 @@ export function ArtistInfo({
Download Selected ({selectedTracks.length}) Download Selected ({selectedTracks.length})
</Button> </Button>
)} )}
{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 && ( {downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} size="sm" variant="outline"> <Button onClick={onOpenFolder} size="sm" variant="outline">
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
@@ -243,6 +276,11 @@ export function ArtistInfo({
onToggleSelectAll={onToggleSelectAll} onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack} onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics} onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onCheckAvailability={onCheckAvailability} onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange} onPageChange={onPageChange}
onAlbumClick={onAlbumClick} onAlbumClick={onAlbumClick}
@@ -1,199 +0,0 @@
import { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Activity, Upload, X } 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";
import { useEffect } from "react";
export function AudioAnalysisDialog() {
const [open, setOpen] = useState(false);
const { analyzing, result, analyzeFile, clearResult } = useAudioAnalysis();
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
const [isDragging, setIsDragging] = useState(false);
const handleSelectFile = async () => {
try {
const filePath = await SelectFile();
if (filePath) {
setSelectedFilePath(filePath);
await analyzeFile(filePath);
}
} catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select file",
});
}
};
const handleFileDrop = useCallback(async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0) return;
const filePath = paths[0];
// Check if it's a FLAC file
if (!filePath.toLowerCase().endsWith('.flac')) {
toast.error("Invalid File Type", {
description: "Please drop a FLAC file for analysis",
});
return;
}
setSelectedFilePath(filePath);
await analyzeFile(filePath);
}, [analyzeFile]);
// Register drag and drop handlers when dialog is open
useEffect(() => {
if (open) {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}
}, [open, handleFileDrop]);
const handleClose = () => {
setOpen(false);
setTimeout(() => {
clearResult();
setSelectedFilePath("");
}, 200);
};
return (
<Dialog open={open} onOpenChange={(isOpen) => {
if (!isOpen) {
handleClose();
} else {
setOpen(true);
}
}}>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button variant="outline" size="icon">
<Activity className="h-5 w-5" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent side="left">
<p>Audio Quality Analyzer</p>
</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-[900px] max-h-[90vh] overflow-y-auto flex flex-col p-6 [&>button]:hidden custom-scrollbar" aria-describedby={undefined}>
<div className="absolute right-4 top-4">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-70 hover:opacity-100"
onClick={handleClose}
>
<X className="h-4 w-4" />
</Button>
</div>
<DialogTitle className="text-sm font-medium">Audio Quality Analyzer</DialogTitle>
<div className="space-y-4">
{/* File Selection */}
{!result && !analyzing && (
<div
className={`flex flex-col items-center justify-center py-12 border-2 border-dashed rounded-lg transition-colors ${
isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30 hover:border-muted-foreground/50"
}`}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}}
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
>
<Activity className={`h-16 w-16 mb-4 transition-colors ${isDragging ? "text-primary" : "text-muted-foreground/50"}`} />
<h3 className="text-lg font-medium mb-2">Analyze FLAC Audio Quality</h3>
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
{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>
)}
{/* Analysis Results */}
{result && (
<div className="space-y-4">
{/* File Info */}
<div className="p-3 bg-muted/30 rounded-lg">
<p className="text-xs text-muted-foreground">Analyzing file:</p>
<p className="text-sm font-mono truncate">{selectedFilePath}</p>
</div>
{/* Spectrum Visualization */}
<SpectrumVisualization
sampleRate={result.sample_rate}
bitsPerSample={result.bits_per_sample}
duration={result.duration}
spectrumData={result.spectrum}
/>
{/* Detailed Analysis */}
<AudioAnalysis
result={result}
analyzing={analyzing}
showAnalyzeButton={false}
/>
{/* Actions */}
<div className="flex gap-2 justify-end pt-2">
<Button onClick={handleSelectFile} variant="outline">
<Upload className="h-4 w-4" />
Analyze Another File
</Button>
</div>
</div>
)}
{/* Loading State */}
{analyzing && !result && (
<div className="flex flex-col items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,159 @@
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Activity, Upload, ArrowLeft } 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 } = useAudioAnalysis();
const [selectedFilePath, setSelectedFilePath] = useState<string>("");
const [isDragging, setIsDragging] = useState(false);
const handleSelectFile = async () => {
try {
const filePath = await SelectFile();
if (filePath) {
setSelectedFilePath(filePath);
await analyzeFile(filePath);
}
} catch (err) {
toast.error("File Selection Failed", {
description: err instanceof Error ? err.message : "Failed to select file",
});
}
};
const handleFileDrop = useCallback(
async (_x: number, _y: number, paths: string[]) => {
setIsDragging(false);
if (paths.length === 0) return;
const filePath = paths[0];
if (!filePath.toLowerCase().endsWith(".flac")) {
toast.error("Invalid File Type", {
description: "Please drop a FLAC file for analysis",
});
return;
}
setSelectedFilePath(filePath);
await analyzeFile(filePath);
},
[analyzeFile]
);
useEffect(() => {
OnFileDrop((x, y, paths) => {
handleFileDrop(x, y, paths);
}, true);
return () => {
OnFileDropOff();
};
}, [handleFileDrop]);
const handleAnalyzeAnother = () => {
clearResult();
setSelectedFilePath("");
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
{onBack && (
<Button variant="ghost" size="icon" onClick={onBack}>
<ArrowLeft className="h-5 w-5" />
</Button>
)}
<h1 className="text-2xl font-bold">Audio Quality Analyzer</h1>
</div>
{/* File Selection */}
{!result && !analyzing && (
<div
className={`flex flex-col items-center justify-center py-24 border-2 border-dashed rounded-lg transition-colors ${
isDragging
? "border-primary bg-primary/10"
: "border-muted-foreground/30 hover:border-muted-foreground/50"
}`}
onDragOver={(e) => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={(e) => {
e.preventDefault();
setIsDragging(false);
}}
onDrop={(e) => {
e.preventDefault();
setIsDragging(false);
}}
style={{ "--wails-drop-target": "drop" } as React.CSSProperties}
>
<Activity
className={`h-20 w-20 mb-4 transition-colors ${isDragging ? "text-primary" : "text-muted-foreground/50"}`}
/>
<h3 className="text-xl font-medium mb-2">Analyze FLAC Audio Quality</h3>
<p className="text-sm text-muted-foreground mb-6 text-center max-w-md">
{isDragging
? "Drop your FLAC file here"
: "Drag and drop a FLAC file here, or click the button below to select"}
</p>
<Button onClick={handleSelectFile} size="lg">
<Upload className="h-5 w-5" />
Select FLAC File
</Button>
</div>
)}
{/* Loading State */}
{analyzing && !result && (
<div className="flex flex-col items-center justify-center py-16">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mb-4"></div>
<p className="text-sm text-muted-foreground">Analyzing audio file...</p>
</div>
)}
{/* Analysis Results */}
{result && (
<div className="space-y-4">
{/* File Info */}
<div className="p-3 bg-muted/30 rounded-lg">
<p className="text-xs text-muted-foreground">Analyzing file:</p>
<p className="text-sm font-mono truncate">{selectedFilePath}</p>
</div>
{/* Spectrum Visualization */}
<SpectrumVisualization
sampleRate={result.sample_rate}
bitsPerSample={result.bits_per_sample}
duration={result.duration}
spectrumData={result.spectrum}
/>
{/* Detailed Analysis */}
<AudioAnalysis result={result} analyzing={analyzing} showAnalyzeButton={false} />
{/* Actions */}
<div className="flex gap-2 justify-end pt-2">
<Button onClick={handleAnalyzeAnother} variant="outline">
<Upload className="h-4 w-4" />
Analyze Another File
</Button>
</div>
</div>
)}
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
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>
);
}
@@ -1,18 +1,35 @@
import { useDownloadProgress } from "@/hooks/useDownloadProgress"; import { useDownloadProgress } from "@/hooks/useDownloadProgress";
import { Download } from "lucide-react"; import { useDownloadQueueData } from "@/hooks/useDownloadQueueData";
import { Download, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
export function DownloadProgressToast() { interface DownloadProgressToastProps {
onClick: () => void;
}
export function DownloadProgressToast({ onClick }: DownloadProgressToastProps) {
const progress = useDownloadProgress(); const progress = useDownloadProgress();
const queueInfo = useDownloadQueueData();
if (!progress.is_downloading) { // Show indicator if there are any queued or downloading items
// Don't show for completed/failed/skipped only
const hasActiveDownloads = queueInfo.queue.some(
item => item.status === "queued" || item.status === "downloading"
);
if (!hasActiveDownloads) {
return null; return null;
} }
return ( 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="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">
<div className="bg-background border rounded-lg shadow-lg p-3"> <Button
variant="outline"
className="bg-background border rounded-lg shadow-lg p-3 h-auto hover:bg-muted/50 transition-colors cursor-pointer"
onClick={onClick}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Download className="h-4 w-4 text-primary animate-bounce" /> <Download className={`h-4 w-4 text-primary ${progress.is_downloading ? 'animate-bounce' : ''}`} />
<div className="flex flex-col min-w-[80px]"> <div className="flex flex-col min-w-[80px]">
<p className="text-sm font-medium font-mono tabular-nums"> <p className="text-sm font-medium font-mono tabular-nums">
{progress.mb_downloaded.toFixed(2)} MB {progress.mb_downloaded.toFixed(2)} MB
@@ -23,8 +40,9 @@ export function DownloadProgressToast() {
</p> </p>
)} )}
</div> </div>
<ChevronRight className="h-4 w-4 text-muted-foreground ml-1" />
</div> </div>
</div> </Button>
</div> </div>
); );
} }
+287
View File
@@ -0,0 +1,287 @@
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);
}
};
// Initial fetch
fetchQueue();
// Poll every 500ms when dialog is open
const interval = setInterval(fetchQueue, 500);
return () => clearInterval(interval);
}, [isOpen]);
const handleClearHistory = async () => {
try {
await ClearCompletedDownloads();
// Refetch immediately to update UI
const info = await GetDownloadQueue();
setQueueInfo(info);
} catch (error) {
console.error("Failed to clear history:", error);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case "downloading":
return <Download className="h-4 w-4 text-blue-500 animate-bounce" />;
case "completed":
return <CheckCircle2 className="h-4 w-4 text-green-500" />;
case "failed":
return <XCircle className="h-4 w-4 text-red-500" />;
case "skipped":
return <FileCheck className="h-4 w-4 text-yellow-500" />;
case "queued":
return <Clock className="h-4 w-4 text-muted-foreground" />;
default:
return null;
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
downloading: "default",
completed: "outline",
failed: "destructive",
skipped: "secondary",
queued: "outline",
};
return (
<Badge variant={variants[status] || "outline"} className="text-xs">
{status}
</Badge>
);
};
// Format session duration
const formatDuration = (startTimestamp: number) => {
if (startTimestamp === 0) return "—";
const now = Math.floor(Date.now() / 1000);
const durationSeconds = now - startTimestamp;
const hours = Math.floor(durationSeconds / 3600);
const minutes = Math.floor((durationSeconds % 3600) / 60);
const seconds = durationSeconds % 60;
if (hours > 0) {
return `${hours}h ${minutes}m ${seconds}s`;
} else if (minutes > 0) {
return `${minutes}m ${seconds}s`;
} else {
return `${seconds}s`;
}
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-[1200px] w-[95vw] max-h-[80vh] flex flex-col p-0 gap-0 [&>button]:hidden">
<DialogHeader className="px-6 pt-6 pb-4 border-b space-y-0">
<div className="flex items-center justify-between mb-4 pr-8">
<DialogTitle className="text-lg font-semibold">Download Queue</DialogTitle>
<div className="flex items-center gap-2">
{(queueInfo.completed_count > 0 || queueInfo.failed_count > 0 || queueInfo.skipped_count > 0) && (
<Button
variant="ghost"
size="sm"
className="h-7 text-xs gap-1.5"
onClick={handleClearHistory}
>
<Trash2 className="h-3 w-3" />
Clear History
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 rounded-full hover:bg-muted"
onClick={onClose}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Queue Status */}
<div className="flex items-center gap-4 text-sm">
<div className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Queued:</span>
<span className="font-semibold">{queueInfo.queued_count}</span>
</div>
<div className="flex items-center gap-1.5">
<CheckCircle2 className="h-3.5 w-3.5 text-green-500" />
<span className="text-muted-foreground">Completed:</span>
<span className="font-semibold">{queueInfo.completed_count}</span>
</div>
<div className="flex items-center gap-1.5">
<FileCheck className="h-3.5 w-3.5 text-yellow-500" />
<span className="text-muted-foreground">Skipped:</span>
<span className="font-semibold">{queueInfo.skipped_count}</span>
</div>
<div className="flex items-center gap-1.5">
<XCircle className="h-3.5 w-3.5 text-red-500" />
<span className="text-muted-foreground">Failed:</span>
<span className="font-semibold">{queueInfo.failed_count}</span>
</div>
</div>
{/* Session Stats */}
<div className="flex items-center gap-4 text-sm pt-3 mt-3 border-t">
<div className="flex items-center gap-1.5">
<HardDrive className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Downloaded:</span>
<span className="font-semibold font-mono">
{queueInfo.total_downloaded > 0 ? `${queueInfo.total_downloaded.toFixed(2)} MB` : "0.00 MB"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Speed:</span>
<span className="font-semibold font-mono">
{queueInfo.current_speed > 0 && queueInfo.is_downloading
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>
<div className="flex items-center gap-1.5">
<Timer className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-muted-foreground">Duration:</span>
<span className="font-semibold font-mono">
{queueInfo.session_start_time > 0 ? formatDuration(queueInfo.session_start_time) : "—"}
</span>
</div>
</div>
</DialogHeader>
{/* Download Queue List */}
<div className="flex-1 overflow-y-auto px-6 custom-scrollbar">
<div className="space-y-2 py-4">
{queueInfo.queue.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<Download className="h-12 w-12 mx-auto mb-3 opacity-20" />
<p>No downloads in queue</p>
</div>
) : (
queueInfo.queue.map((item) => (
<div
key={item.id}
className="border rounded-lg p-3 hover:bg-muted/30 transition-colors"
>
<div className="flex items-start gap-3">
<div className="mt-1">{getStatusIcon(item.status)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.track_name}</p>
<p className="text-sm text-muted-foreground truncate">
{item.artist_name}
{item.album_name && `${item.album_name}`}
</p>
</div>
{getStatusBadge(item.status)}
</div>
{/* Info for downloading items */}
{item.status === "downloading" && (
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground font-mono">
<span>
{item.progress > 0
? `${item.progress.toFixed(2)} MB`
: queueInfo.is_downloading && queueInfo.current_speed > 0
? "Downloading..."
: "Starting..."}
</span>
<span>
{item.speed > 0
? `${item.speed.toFixed(2)} MB/s`
: queueInfo.current_speed > 0
? `${queueInfo.current_speed.toFixed(2)} MB/s`
: "—"}
</span>
</div>
)}
{/* Completed info */}
{item.status === "completed" && (
<div className="flex items-center gap-3 mt-1.5 text-xs text-muted-foreground">
<span className="font-mono">{item.progress.toFixed(2)} MB</span>
</div>
)}
{/* Skipped info */}
{item.status === "skipped" && (
<div className="mt-1.5 text-xs text-muted-foreground">
File already exists
</div>
)}
{/* Error message */}
{item.status === "failed" && item.error_message && (
<div className="mt-1.5 text-xs text-red-500 bg-red-50 dark:bg-red-950/20 rounded px-2 py-1">
{item.error_message}
</div>
)}
{/* File path for completed/skipped */}
{(item.status === "completed" || item.status === "skipped") && item.file_path && (
<div className="mt-1.5 text-xs text-muted-foreground truncate font-mono">
{item.file_path}
</div>
)}
</div>
</div>
</div>
))
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}
+22 -37
View File
@@ -1,19 +1,19 @@
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Settings } from "@/components/Settings";
import { AudioAnalysisDialog } from "@/components/AudioAnalysisDialog";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { openExternal } from "@/lib/utils";
import { formatRelativeTime } from "@/lib/relative-time";
interface HeaderProps { interface HeaderProps {
version: string; version: string;
hasUpdate: boolean; hasUpdate: boolean;
releaseDate?: string | null;
} }
export function Header({ version, hasUpdate }: HeaderProps) { export function Header({ version, hasUpdate, releaseDate }: HeaderProps) {
return ( return (
<div className="relative"> <div className="relative">
<div className="text-center space-y-2"> <div className="text-center space-y-2">
@@ -31,16 +31,24 @@ export function Header({ version, hasUpdate }: HeaderProps) {
SpotiFLAC SpotiFLAC
</h1> </h1>
<div className="relative"> <div className="relative">
<Badge variant="default" asChild> <Tooltip>
<a <TooltipTrigger asChild>
href="https://github.com/afkarxyz/SpotiFLAC/releases" <Badge variant="default" asChild>
target="_blank" <button
rel="noopener noreferrer" type="button"
className="cursor-pointer hover:opacity-80 transition-opacity" onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/releases")}
> className="cursor-pointer hover:opacity-80 transition-opacity"
v{version} >
</a> v{version}
</Badge> </button>
</Badge>
</TooltipTrigger>
{hasUpdate && releaseDate && (
<TooltipContent>
<p>{formatRelativeTime(releaseDate)}</p>
</TooltipContent>
)}
</Tooltip>
{hasUpdate && ( {hasUpdate && (
<span className="absolute -top-1 -right-1 flex h-3 w-3"> <span className="absolute -top-1 -right-1 flex h-3 w-3">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75"></span>
@@ -53,29 +61,6 @@ export function Header({ version, hasUpdate }: HeaderProps) {
Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music no account required. Get Spotify tracks in true FLAC from Tidal, Deezer, Qobuz & Amazon Music no account required.
</p> </p>
</div> </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>
<AudioAnalysisDialog />
<Settings />
</div>
</div> </div>
); );
} }
+38 -1
View File
@@ -1,7 +1,8 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen } from "lucide-react"; import { Download, FolderOpen, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { SearchAndSort } from "./SearchAndSort"; import { SearchAndSort } from "./SearchAndSort";
import { TrackList } from "./TrackList"; import { TrackList } from "./TrackList";
import { DownloadProgress } from "./DownloadProgress"; import { DownloadProgress } from "./DownloadProgress";
@@ -43,13 +44,21 @@ interface PlaylistInfoProps {
// Availability props // Availability props
checkingAvailabilityTrack?: string | null; checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>; availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
isBulkDownloadingCovers?: boolean;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onSortChange: (value: string) => void; onSortChange: (value: string) => void;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
onCheckAvailability?: (spotifyId: string) => void; onCheckAvailability?: (spotifyId: string) => void;
onDownloadAllCovers?: () => void;
onDownloadAll: () => void; onDownloadAll: () => void;
onDownloadSelected: () => void; onDownloadSelected: () => void;
onStopDownload: () => void; onStopDownload: () => void;
@@ -82,13 +91,20 @@ export function PlaylistInfo({
downloadingLyricsTrack, downloadingLyricsTrack,
checkingAvailabilityTrack, checkingAvailabilityTrack,
availabilityMap, availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
isBulkDownloadingCovers,
onSearchChange, onSearchChange,
onSortChange, onSortChange,
onToggleTrack, onToggleTrack,
onToggleSelectAll, onToggleSelectAll,
onDownloadTrack, onDownloadTrack,
onDownloadLyrics, onDownloadLyrics,
onDownloadCover,
onCheckAvailability, onCheckAvailability,
onDownloadAllCovers,
onDownloadAll, onDownloadAll,
onDownloadSelected, onDownloadSelected,
onStopDownload, onStopDownload,
@@ -145,6 +161,22 @@ export function PlaylistInfo({
Download Selected ({selectedTracks.length}) Download Selected ({selectedTracks.length})
</Button> </Button>
)} )}
{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 && ( {downloadedTracks.size > 0 && (
<Button onClick={onOpenFolder} variant="outline"> <Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
@@ -191,10 +223,15 @@ export function PlaylistInfo({
downloadingLyricsTrack={downloadingLyricsTrack} downloadingLyricsTrack={downloadingLyricsTrack}
checkingAvailabilityTrack={checkingAvailabilityTrack} checkingAvailabilityTrack={checkingAvailabilityTrack}
availabilityMap={availabilityMap} availabilityMap={availabilityMap}
downloadedCovers={downloadedCovers}
failedCovers={failedCovers}
skippedCovers={skippedCovers}
downloadingCoverTrack={downloadingCoverTrack}
onToggleTrack={onToggleTrack} onToggleTrack={onToggleTrack}
onToggleSelectAll={onToggleSelectAll} onToggleSelectAll={onToggleSelectAll}
onDownloadTrack={onDownloadTrack} onDownloadTrack={onDownloadTrack}
onDownloadLyrics={onDownloadLyrics} onDownloadLyrics={onDownloadLyrics}
onDownloadCover={onDownloadCover}
onCheckAvailability={onCheckAvailability} onCheckAvailability={onCheckAvailability}
onPageChange={onPageChange} onPageChange={onPageChange}
onAlbumClick={onAlbumClick} onAlbumClick={onAlbumClick}
+20 -4
View File
@@ -19,7 +19,8 @@ import {
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X } from "lucide-react"; import { Settings as SettingsIcon, FolderOpen, Save, RotateCcw, Info, X, Volume2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings"; import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, type Settings as SettingsType } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes"; import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App"; import { SelectFolder } from "../../wailsjs/go/main/App";
@@ -281,7 +282,7 @@ export function Settings() {
{/* Theme Mode Selection */} {/* Theme Mode Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="theme-mode">Theme</Label> <Label htmlFor="theme-mode">Mode</Label>
<Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}> <Select value={tempSettings.themeMode} onValueChange={handleThemeModeChange}>
<SelectTrigger id="theme-mode"> <SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode" /> <SelectValue placeholder="Select theme mode" />
@@ -294,9 +295,9 @@ export function Settings() {
</Select> </Select>
</div> </div>
{/* Theme Color Selection */} {/* Accent Selection */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="theme">Theme Color</Label> <Label htmlFor="theme">Accent</Label>
<Select value={tempSettings.theme} onValueChange={handleThemeChange}> <Select value={tempSettings.theme} onValueChange={handleThemeChange}>
<SelectTrigger id="theme"> <SelectTrigger id="theme">
<SelectValue placeholder="Select a theme" /> <SelectValue placeholder="Select a theme" />
@@ -400,6 +401,21 @@ export function Settings() {
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div className="border-t" />
{/* Sound Effects */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Volume2 className="h-4 w-4 text-muted-foreground" />
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
</div>
<Switch
id="sfx-enabled"
checked={tempSettings.sfxEnabled}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
/>
</div>
</div> </div>
</div> </div>
<DialogFooter className="gap-2 sm:justify-between"> <DialogFooter className="gap-2 sm:justify-between">
+345
View File
@@ -0,0 +1,345 @@
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 {
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 { FolderOpen, Save, RotateCcw, Info, Volume2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { getSettings, getSettingsWithDefaults, saveSettings, resetToDefaultSettings, applyThemeMode, applyFont, FONT_OPTIONS, type Settings as SettingsType, type FontFamily } from "@/lib/settings";
import { themes, applyTheme } from "@/lib/themes";
import { SelectFolder } from "../../wailsjs/go/main/App";
import { toastWithSound as toast } from "@/lib/toast-with-sound";
// Service Icons
const TidalIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M4.022 4.5 0 8.516l3.997 3.99 3.997-3.984L4.022 4.5Zm7.956 0L7.994 8.522l4.003 3.984L16 8.484 11.978 4.5Zm8.007 0L24 8.528l-4.003 3.978L16 8.484 19.985 4.5Z"></path>
<path d="m8.012 16.534 3.991 3.966L16 16.49l-4.003-3.984-3.985 4.028Z"></path>
</svg>
);
const DeezerIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M18.77 5.55c.19-1.07.46-1.75.76-1.75.56 0 1.02 2.34 1.02 5.23 0 2.89-.46 5.23-1.02 5.23-.23 0-.44-.4-.61-1.06-.27 2.43-.83 4.11-1.48 4.11-.5 0-.96-1-1.26-2.6-.2 3.03-.73 5.17-1.33 5.17-.39 0-.73-.85-.99-2.23-.31 2.85-1.03 4.85-1.86 4.85-.83 0-1.55-2-1.86-4.85-.25 1.38-.6 2.23-.99 2.23-.6 0-1.12-2.14-1.33-5.16-.3 1.58-.75 2.6-1.26 2.6-.65 0-1.2-1.68-1.48-4.12-.17.66-.38 1.06-.61 1.06-.56 0-1.02-2.34-1.02-5.23 0-2.89.46-5.23 1.02-5.23.3 0 .57.68.76 1.75C5.53 3.7 6 2.5 6.56 2.5c.66 0 1.22 1.7 1.49 4.17.26-1.8.66-2.94 1.1-2.94.63 0 1.16 2.25 1.36 5.4.36-1.62.9-2.63 1.5-2.63.58 0 1.12 1.01 1.49 2.62.2-3.14.72-5.4 1.35-5.4.44 0 .84 1.15 1.1 2.95.27-2.47.84-4.17 1.49-4.17.55 0 1.03 1.2 1.33 3.05ZM2 8.52c0-1.3.26-2.34.58-2.34.32 0 .57 1.05.57 2.34 0 1.29-.25 2.34-.57 2.34-.32 0-.58-1.05-.58-2.34Zm18.85 0c0-1.3.25-2.34.57-2.34.32 0 .58 1.05.58 2.34 0 1.29-.26 2.34-.58 2.34-.32 0-.57-1.05-.57-2.34Z"></path>
</svg>
);
const QobuzIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path d="M21.744 9.815C19.836 1.261 8.393-1 3.55 6.64-.618 13.214 4 22 11.988 22c2.387 0 4.63-.83 6.394-2.304l2.252 2.252 1.224-1.224-2.252-2.253c1.983-2.407 2.823-5.586 2.138-8.656Zm-3.508 7.297L16.4 15.275c-.786-.787-2.017.432-1.224 1.225L17 18.326C10.29 23.656.5 16 5.16 7.667c3.502-6.264 13.172-4.348 14.707 2.574.529 2.385-.06 4.987-1.63 6.87Z"></path>
<path d="M13.4 8.684a3.59 3.59 0 0 0-4.712 1.9 3.59 3.59 0 0 0 1.9 4.712 3.594 3.594 0 0 0 4.711-1.89 3.598 3.598 0 0 0-1.9-4.722Zm-.737 3.591a.727.727 0 0 1-.965.384.727.727 0 0 1-.384-.965.727.727 0 0 1 .965-.384.73.73 0 0 1 .384.965Z"></path>
</svg>
);
const AmazonIcon = () => (
<svg viewBox="0 0 24 24" className="inline-block w-[1.1em] h-[1.1em] mr-2 fill-muted-foreground">
<path fillRule="evenodd" d="M15.62 11.13c-.15.1-.37.18-.64.18-.42 0-.82-.05-1.21-.18l-.22-.04c-.08 0-.1.04-.1.14v.25c0 .08.02.12.05.17.02.03.07.08.15.1.4.18.84.25 1.33.25.52 0 .91-.12 1.24-.37.32-.25.47-.57.47-.99 0-.3-.08-.52-.23-.72-.15-.17-.4-.34-.74-.47l-.7-.27c-.26-.1-.46-.2-.53-.3a.47.47 0 0 1-.15-.36c0-.38.27-.57.84-.57.32 0 .64.05.94.15l.2.04c.07 0 .12-.04.12-.14v-.25c0-.08-.03-.12-.05-.17-.03-.05-.08-.08-.15-.1-.37-.13-.74-.2-1.11-.2-.47 0-.87.12-1.16.35-.3.22-.45.54-.45.91 0 .57.32.99.97 1.24l.74.27c.24.1.4.17.5.27.09.1.12.2.12.35 0 .2-.08.37-.23.46Zm-3.88-3.55v3.28c-.42.28-.84.42-1.26.42-.27 0-.47-.07-.6-.22-.11-.15-.16-.37-.16-.7V7.59c0-.13-.05-.18-.18-.18h-.52c-.12 0-.17.05-.17.18v3.06c0 .42.1.77.32.99.22.22.55.35.97.35.56 0 1.13-.2 1.68-.6l.05.3c0 .07.02.1.07.12.02.03.07.03.15.03h.37c.12 0 .17-.05.17-.18V7.58c0-.13-.05-.18-.17-.18h-.52c-.15 0-.2.08-.2.18Zm-4.69 4.27h.52c.12 0 .17-.05.17-.17v-3.1c0-.41-.1-.73-.32-.95a1.25 1.25 0 0 0-.94-.35c-.57 0-1.16.2-1.73.62-.2-.42-.57-.62-1.11-.62-.55 0-1.1.2-1.64.57l-.04-.27c0-.08-.03-.1-.08-.13-.02-.02-.07-.02-.12-.02h-.4c-.12 0-.17.05-.17.17v4.1c0 .13.05.18.17.18h.52c.12 0 .17-.05.17-.18V8.37c.42-.25.84-.4 1.29-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.17.17h.52c.13 0 .18-.05.18-.17V8.37c.44-.27.86-.4 1.28-.4.25 0 .42.08.52.22.1.15.17.35.17.65v2.84c0 .12.05.17.18.17Zm13.47 3.29a21.8 21.8 0 0 1-8.3 1.7c-3.96 0-7.8-1.08-10.88-2.89a.35.35 0 0 0-.15-.05c-.17 0-.27.2-.1.37a16.11 16.11 0 0 0 10.87 4.16c3.02 0 6.5-.94 8.9-2.72.42-.3.08-.74-.34-.57Zm-.08-6.74c.22-.26.57-.38 1.06-.38.25 0 .5.03.72.1l.15.02c.07 0 .12-.04.12-.17v-.25c0-.07-.02-.14-.05-.17a.54.54 0 0 0-.12-.1c-.32-.07-.64-.15-.94-.15-.7 0-1.21.2-1.6.62-.38.4-.57 1-.57 1.73 0 .74.17 1.31.54 1.7.37.4.89.6 1.58.6.37 0 .72-.05.99-.17.07-.03.12-.05.15-.1.02-.03.02-.1.02-.17v-.25c0-.13-.05-.17-.12-.17-.03 0-.07 0-.12.02-.28.07-.55.12-.8.12-.46 0-.81-.12-1.03-.37-.23-.24-.32-.64-.32-1.16v-.12c.02-.55.12-.94.34-1.19Z" clipRule="evenodd"></path>
<path fillRule="evenodd" d="M21.55 17.46c1.29-1.09 1.64-3.33 1.36-3.68-.12-.15-.71-.3-1.45-.3-.8 0-1.73.18-2.45.67-.22.15-.17.35.05.32.76-.1 2.5-.3 2.82.1.3.4-.35 2.03-.65 2.74-.07.23.1.3.32.15ZM18.12 7.4h-.52c-.12 0-.17.05-.17.18v4.1c0 .12.05.17.17.17h.52c.12 0 .17-.05.17-.17v-4.1c0-.1-.05-.18-.17-.18Zm.15-1.68a.58.58 0 0 0-.42-.15c-.18 0-.3.05-.4.15a.5.5 0 0 0-.15.37c0 .15.05.3.15.37.1.1.22.15.4.15.17 0 .3-.05.4-.15a.5.5 0 0 0 .14-.37c0-.15-.02-.3-.12-.37Z" clipRule="evenodd"></path>
</svg>
);
export function SettingsPage() {
const [savedSettings, setSavedSettings] = useState<SettingsType>(getSettings());
const [tempSettings, setTempSettings] = useState<SettingsType>(savedSettings);
const [isDark, setIsDark] = useState(document.documentElement.classList.contains('dark'));
useEffect(() => {
applyThemeMode(savedSettings.themeMode);
applyTheme(savedSettings.theme);
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (savedSettings.themeMode === "auto") {
applyThemeMode("auto");
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);
}
};
loadDefaults();
}, []);
const handleSave = () => {
saveSettings(tempSettings);
setSavedSettings(tempSettings);
toast.success("Settings saved");
};
const handleReset = async () => {
const defaultSettings = await resetToDefaultSettings();
setTempSettings(defaultSettings);
setSavedSettings(defaultSettings);
applyThemeMode(defaultSettings.themeMode);
applyTheme(defaultSettings.theme);
applyFont(defaultSettings.fontFamily);
toast.success("Settings reset to default");
};
const handleBrowseFolder = async () => {
try {
const selectedPath = await SelectFolder(tempSettings.downloadPath || "");
if (selectedPath && selectedPath.trim() !== "") {
setTempSettings((prev) => ({ ...prev, downloadPath: selectedPath }));
}
} catch (error) {
console.error("Error selecting folder:", error);
toast.error(`Error selecting folder: ${error}`);
}
};
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Settings</h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Left Column */}
<div className="space-y-4">
{/* Download Path */}
<div className="space-y-2">
<Label htmlFor="download-path">Download Path</Label>
<div className="flex gap-2">
<InputWithContext
id="download-path"
value={tempSettings.downloadPath}
onChange={(e) => setTempSettings((prev) => ({ ...prev, downloadPath: e.target.value }))}
placeholder="C:\Users\YourUsername\Music"
/>
<Button type="button" onClick={handleBrowseFolder} className="gap-1.5">
<FolderOpen className="h-4 w-4" />
Browse
</Button>
</div>
</div>
{/* Source Selection */}
<div className="space-y-2">
<Label htmlFor="downloader">Source</Label>
<Select
value={tempSettings.downloader}
onValueChange={(value: "auto" | "deezer" | "tidal" | "qobuz" | "amazon") => setTempSettings((prev) => ({ ...prev, downloader: value }))}
>
<SelectTrigger id="downloader">
<SelectValue placeholder="Select a source" />
</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 */}
<div className="space-y-2">
<Label htmlFor="theme-mode">Mode</Label>
<Select
value={tempSettings.themeMode}
onValueChange={(value: "auto" | "light" | "dark") => setTempSettings((prev) => ({ ...prev, themeMode: value }))}
>
<SelectTrigger id="theme-mode">
<SelectValue placeholder="Select theme mode" />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
</SelectContent>
</Select>
</div>
{/* Accent */}
<div className="space-y-2">
<Label htmlFor="theme">Accent</Label>
<Select
value={tempSettings.theme}
onValueChange={(value) => setTempSettings((prev) => ({ ...prev, theme: value }))}
>
<SelectTrigger id="theme">
<SelectValue placeholder="Select a theme" />
</SelectTrigger>
<SelectContent>
{themes.map((theme) => (
<SelectItem key={theme.name} value={theme.name}>
<span className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full border border-border"
style={{
backgroundColor: isDark ? theme.cssVars.dark.primary : theme.cssVars.light.primary
}}
/>
{theme.label}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Font */}
<div className="space-y-2">
<Label htmlFor="font">Font</Label>
<Select
value={tempSettings.fontFamily}
onValueChange={(value: FontFamily) => setTempSettings((prev) => ({ ...prev, fontFamily: value }))}
>
<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>
{/* 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 pt-4" />
{/* 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</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</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">Playlist only</p>
</TooltipContent>
</Tooltip>
</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</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">Playlist & Discography</p>
</TooltipContent>
</Tooltip>
</div>
</div>
<div className="border-t pt-4" />
{/* Sound Effects */}
<div className="flex items-center gap-3">
<Volume2 className="h-4 w-4 text-muted-foreground" />
<Label htmlFor="sfx-enabled" className="cursor-pointer text-sm">Sound Effects</Label>
<Switch
id="sfx-enabled"
checked={tempSettings.sfxEnabled}
onCheckedChange={(checked) => setTempSettings(prev => ({ ...prev, sfxEnabled: checked }))}
/>
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2 justify-between pt-4 border-t">
<Button variant="outline" onClick={handleReset} className="gap-1.5">
<RotateCcw className="h-4 w-4" />
Reset to Default
</Button>
<Button onClick={handleSave} className="gap-1.5">
<Save className="h-4 w-4" />
Save Changes
</Button>
</div>
</div>
);
}
+86
View File
@@ -0,0 +1,86 @@
import { Home, Settings, Bug, Activity, LayoutGrid } from "lucide-react";
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";
interface SidebarProps {
currentPage: PageType;
onPageChange: (page: PageType) => void;
}
export function Sidebar({ currentPage, onPageChange }: SidebarProps) {
const navItems = [
{ id: "main" as PageType, icon: Home, label: "Home" },
{ id: "settings" as PageType, icon: Settings, label: "Settings" },
{ id: "audio-analysis" as PageType, icon: Activity, label: "Audio Quality Analyzer" },
{ id: "debug" as PageType, icon: Bug, label: "Debug Logs" },
];
return (
<div className="fixed left-0 top-0 h-full w-14 bg-card border-r border-border flex flex-col items-center py-14 z-30">
<div className="flex flex-col gap-2 flex-1">
{navItems.map((item) => (
<Tooltip key={item.id} delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant={currentPage === item.id ? "secondary" : "ghost"}
size="icon"
className="h-10 w-10"
onClick={() => onPageChange(item.id)}
>
<item.icon className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>{item.label}</p>
</TooltipContent>
</Tooltip>
))}
</div>
{/* GitHub - below debug */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://github.com/afkarxyz/SpotiFLAC/issues")}
>
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Report Bug</p>
</TooltipContent>
</Tooltip>
{/* Other Projects at bottom */}
<div className="mt-auto">
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-10 w-10"
onClick={() => openExternal("https://exyezed.cc/")}
>
<LayoutGrid className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>Other Projects</p>
</TooltipContent>
</Tooltip>
</div>
</div>
);
}
+16 -31
View File
@@ -1,10 +1,7 @@
import { useState } from "react"; import { X, Minus, Maximize } from "lucide-react";
import { X, Minus, Square } from "lucide-react";
import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime"; import { WindowMinimise, WindowToggleMaximise, Quit } from "../../wailsjs/runtime/runtime";
export function TitleBar() { export function TitleBar() {
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
const handleMinimize = () => { const handleMinimize = () => {
WindowMinimise(); WindowMinimise();
}; };
@@ -21,48 +18,36 @@ export function TitleBar() {
<> <>
{/* Draggable area */} {/* Draggable area */}
<div <div
className="fixed top-0 left-0 right-0 h-12 z-40" 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} style={{ "--wails-draggable": "drag" } as React.CSSProperties}
onDoubleClick={handleMaximize} onDoubleClick={handleMaximize}
/> />
{/* Window control buttons */} {/* Window control buttons - Windows style, right side */}
<div className="fixed top-4 left-4 z-50 flex gap-2"> <div className="fixed top-1.5 right-2 z-50 flex h-7 gap-0.5">
<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} />
)}
</button>
<button <button
onClick={handleMinimize} onClick={handleMinimize}
onMouseEnter={() => setHoveredButton("minimize")} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
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} style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Minimize" aria-label="Minimize"
> >
{hoveredButton === "minimize" && ( <Minus className="w-3.5 h-3.5" />
<Minus className="w-2 h-2 text-yellow-900" strokeWidth={3} />
)}
</button> </button>
<button <button
onClick={handleMaximize} onClick={handleMaximize}
onMouseEnter={() => setHoveredButton("maximize")} className="w-8 h-7 flex items-center justify-center hover:bg-muted transition-colors rounded"
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} style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Maximize" aria-label="Maximize"
> >
{hoveredButton === "maximize" && ( <Maximize className="w-3.5 h-3.5" />
<Square className="w-1.5 h-1.5 text-green-900" strokeWidth={3} /> </button>
)} <button
onClick={handleClose}
className="w-8 h-7 flex items-center justify-center hover:bg-destructive hover:text-white transition-colors rounded"
style={{ "--wails-draggable": "no-drag" } as React.CSSProperties}
aria-label="Close"
>
<X className="w-3.5 h-3.5" />
</button> </button>
</div> </div>
</> </>
+79 -77
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { Download, FolderOpen, CheckCircle, XCircle, FileText, SkipForward, Globe } from "lucide-react"; import { Download, FolderOpen, CheckCircle, XCircle, FileText, FileCheck, Globe, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import {
Tooltip, Tooltip,
@@ -16,15 +17,18 @@ interface TrackInfoProps {
downloadingTrack: string | null; downloadingTrack: string | null;
isDownloaded: boolean; isDownloaded: boolean;
isFailed: boolean; isFailed: boolean;
isSkipped: boolean;
downloadingLyricsTrack?: string | null; downloadingLyricsTrack?: string | null;
downloadedLyrics?: boolean; downloadedLyrics?: boolean;
failedLyrics?: boolean; failedLyrics?: boolean;
skippedLyrics?: boolean; skippedLyrics?: boolean;
checkingAvailability?: boolean; checkingAvailability?: boolean;
availability?: TrackAvailability; availability?: TrackAvailability;
downloadingCover?: boolean;
onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void; onDownload: (isrc: string, name: string, artists: string, albumName?: string, spotifyId?: string) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName?: string) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void; onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName?: string) => void;
onOpenFolder: () => void; onOpenFolder: () => void;
} }
@@ -34,85 +38,72 @@ export function TrackInfo({
downloadingTrack, downloadingTrack,
isDownloaded, isDownloaded,
isFailed, isFailed,
isSkipped,
downloadingLyricsTrack, downloadingLyricsTrack,
downloadedLyrics, downloadedLyrics,
failedLyrics, failedLyrics,
skippedLyrics, skippedLyrics,
checkingAvailability, checkingAvailability,
availability, availability,
downloadingCover,
onDownload, onDownload,
onDownloadLyrics, onDownloadLyrics,
onCheckAvailability, onCheckAvailability,
onDownloadCover,
onOpenFolder, onOpenFolder,
}: TrackInfoProps) { }: TrackInfoProps) {
const [isHoveringCover, setIsHoveringCover] = useState(false);
return ( return (
<Card> <Card>
<CardContent className="px-6"> <CardContent className="px-6">
<div className="flex gap-6 items-start"> <div className="flex gap-6 items-start">
<div className="shrink-0"> <div
className="shrink-0 relative"
onMouseEnter={() => setIsHoveringCover(true)}
onMouseLeave={() => setIsHoveringCover(false)}
>
{track.images && ( {track.images && (
<img <>
src={track.images} <img
alt={track.name} src={track.images}
className="w-48 h-48 rounded-md shadow-lg object-cover" alt={track.name}
/> className="w-48 h-48 rounded-md shadow-lg object-cover"
)} />
{/* Availability Icons - below cover art */} {isHoveringCover && onDownloadCover && (
{availability && ( <div className="absolute inset-0 bg-black/50 rounded-md flex items-center justify-center">
<div className="flex items-center justify-center gap-2 mt-3"> <Tooltip>
<Tooltip> <TooltipTrigger asChild>
<TooltipTrigger> <Button
<div className={`${availability.tidal ? "text-green-500" : "text-red-500"}`}> size="icon"
<TidalIcon className="w-5 h-5" /> variant="secondary"
</div> className="cursor-pointer"
</TooltipTrigger> onClick={() => onDownloadCover(track.images, track.name, track.artists, track.album_name)}
<TooltipContent> disabled={downloadingCover}
<p>Tidal</p> >
</TooltipContent> {downloadingCover ? <Spinner /> : <ImageDown className="h-5 w-5" />}
</Tooltip> </Button>
<Tooltip> </TooltipTrigger>
<TooltipTrigger> <TooltipContent>
<div className={`${availability.deezer ? "text-green-500" : "text-red-500"}`}> <p>Download Cover</p>
<DeezerIcon className="w-5 h-5" /> </TooltipContent>
</div> </Tooltip>
</TooltipTrigger> </div>
<TooltipContent> )}
<p>Deezer</p> </>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<div className={`${availability.amazon ? "text-green-500" : "text-red-500"}`}>
<AmazonIcon className="w-5 h-5" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Amazon Music</p>
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<div className={`${availability.qobuz ? "text-green-500" : "text-red-500"}`}>
<QobuzIcon className="w-5 h-5" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Qobuz</p>
</TooltipContent>
</Tooltip>
</div>
)} )}
</div> </div>
<div className="flex-1 space-y-4 min-w-0"> <div className="flex-1 space-y-4 min-w-0">
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1> <h1 className="text-3xl font-bold wrap-break-word">{track.name}</h1>
{isDownloaded && ( {isSkipped ? (
<FileCheck className="h-6 w-6 text-yellow-500 shrink-0" />
) : isDownloaded ? (
<CheckCircle className="h-6 w-6 text-green-500 shrink-0" /> <CheckCircle className="h-6 w-6 text-green-500 shrink-0" />
)} ) : isFailed ? (
{isFailed && (
<XCircle className="h-6 w-6 text-red-500 shrink-0" /> <XCircle className="h-6 w-6 text-red-500 shrink-0" />
)} ) : null}
</div> </div>
<p className="text-lg text-muted-foreground">{track.artists}</p> <p className="text-lg text-muted-foreground">{track.artists}</p>
</div> </div>
@@ -141,26 +132,6 @@ export function TrackInfo({
</> </>
)} )}
</Button> </Button>
{track.spotify_id && onCheckAvailability && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
variant="outline"
disabled={checkingAvailability}
>
{checkingAvailability ? (
<Spinner />
) : (
<Globe className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Check Availability</p>
</TooltipContent>
</Tooltip>
)}
{track.spotify_id && onDownloadLyrics && ( {track.spotify_id && onDownloadLyrics && (
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
@@ -172,7 +143,7 @@ export function TrackInfo({
{downloadingLyricsTrack === track.spotify_id ? ( {downloadingLyricsTrack === track.spotify_id ? (
<Spinner /> <Spinner />
) : skippedLyrics ? ( ) : skippedLyrics ? (
<SkipForward className="h-4 w-4 text-yellow-500" /> <FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedLyrics ? ( ) : downloadedLyrics ? (
<CheckCircle className="h-4 w-4 text-green-500" /> <CheckCircle className="h-4 w-4 text-green-500" />
) : failedLyrics ? ( ) : failedLyrics ? (
@@ -187,6 +158,37 @@ export function TrackInfo({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
{track.spotify_id && onCheckAvailability && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => onCheckAvailability(track.spotify_id!, track.isrc)}
variant="outline"
disabled={checkingAvailability}
>
{checkingAvailability ? (
<Spinner />
) : availability ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : (
<Globe className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{availability ? (
<div className="flex items-center gap-2">
<TidalIcon className={`w-4 h-4 ${availability.tidal ? "text-green-500" : "text-red-500"}`} />
<DeezerIcon className={`w-4 h-4 ${availability.deezer ? "text-green-500" : "text-red-500"}`} />
<QobuzIcon className={`w-4 h-4 ${availability.qobuz ? "text-green-500" : "text-red-500"}`} />
<AmazonIcon className={`w-4 h-4 ${availability.amazon ? "text-green-500" : "text-red-500"}`} />
</div>
) : (
<p>Check Availability</p>
)}
</TooltipContent>
</Tooltip>
)}
{isDownloaded && ( {isDownloaded && (
<Button onClick={onOpenFolder} variant="outline"> <Button onClick={onOpenFolder} variant="outline">
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
+108 -48
View File
@@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { Download, CheckCircle, XCircle, SkipForward, FileText, Globe } from "lucide-react"; import { Download, CheckCircle, XCircle, FileCheck, FileText, Globe, ImageDown } from "lucide-react";
import { Spinner } from "@/components/ui/spinner"; import { Spinner } from "@/components/ui/spinner";
import { import {
Tooltip, Tooltip,
@@ -42,11 +42,17 @@ interface TrackListProps {
// Availability props // Availability props
checkingAvailabilityTrack?: string | null; checkingAvailabilityTrack?: string | null;
availabilityMap?: Map<string, TrackAvailability>; availabilityMap?: Map<string, TrackAvailability>;
// Cover props
downloadedCovers?: Set<string>;
failedCovers?: Set<string>;
skippedCovers?: Set<string>;
downloadingCoverTrack?: string | null;
onToggleTrack: (isrc: string) => void; onToggleTrack: (isrc: string) => void;
onToggleSelectAll: (tracks: TrackMetadata[]) => void; onToggleSelectAll: (tracks: TrackMetadata[]) => void;
onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void; onDownloadTrack: (isrc: string, name: string, artists: string, albumName: string, spotifyId?: string, folderName?: string, isArtistDiscography?: boolean) => void;
onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void; onDownloadLyrics?: (spotifyId: string, name: string, artists: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number) => void;
onCheckAvailability?: (spotifyId: string, isrc?: string) => void; onCheckAvailability?: (spotifyId: string, isrc?: string) => void;
onDownloadCover?: (coverUrl: string, trackName: string, artistName: string, albumName: string, folderName?: string, isArtistDiscography?: boolean, position?: number, trackId?: string) => void;
onPageChange: (page: number) => void; onPageChange: (page: number) => void;
onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void; onAlbumClick?: (album: { id: string; name: string; external_urls: string }) => void;
onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void; onArtistClick?: (artist: { id: string; name: string; external_urls: string }) => void;
@@ -75,11 +81,16 @@ export function TrackList({
downloadingLyricsTrack, downloadingLyricsTrack,
checkingAvailabilityTrack, checkingAvailabilityTrack,
availabilityMap, availabilityMap,
downloadedCovers,
failedCovers,
skippedCovers,
downloadingCoverTrack,
onToggleTrack, onToggleTrack,
onToggleSelectAll, onToggleSelectAll,
onDownloadTrack, onDownloadTrack,
onDownloadLyrics, onDownloadLyrics,
onCheckAvailability, onCheckAvailability,
onDownloadCover,
onPageChange, onPageChange,
onAlbumClick, onAlbumClick,
onArtistClick, onArtistClick,
@@ -210,7 +221,7 @@ export function TrackList({
<span className="font-medium">{track.name}</span> <span className="font-medium">{track.name}</span>
)} )}
{skippedTracks.has(track.isrc) ? ( {skippedTracks.has(track.isrc) ? (
<SkipForward className="h-4 w-4 text-yellow-500 shrink-0" /> <FileCheck className="h-4 w-4 text-yellow-500 shrink-0" />
) : downloadedTracks.has(track.isrc) ? ( ) : downloadedTracks.has(track.isrc) ? (
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" /> <CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
) : failedTracks.has(track.isrc) ? ( ) : failedTracks.has(track.isrc) ? (
@@ -286,23 +297,101 @@ export function TrackList({
<td className="p-4 align-middle text-center"> <td className="p-4 align-middle text-center">
<div className="flex items-center justify-center gap-1"> <div className="flex items-center justify-center gap-1">
{track.isrc && ( {track.isrc && (
<Button <Tooltip>
onClick={() => <TooltipTrigger asChild>
onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography) <Button
} onClick={() =>
size="sm" onDownloadTrack(track.isrc, track.name, track.artists, track.album_name, track.spotify_id, folderName, isArtistDiscography)
className="gap-1.5" }
disabled={isDownloading || downloadingTrack === track.isrc} size="sm"
> disabled={isDownloading || downloadingTrack === track.isrc}
{downloadingTrack === track.isrc ? ( >
<Spinner /> {downloadingTrack === track.isrc ? (
) : ( <Spinner />
<> ) : skippedTracks.has(track.isrc) ? (
<Download className="h-4 w-4" /> <FileCheck className="h-4 w-4" />
Download ) : downloadedTracks.has(track.isrc) ? (
</> <CheckCircle className="h-4 w-4" />
)} ) : failedTracks.has(track.isrc) ? (
</Button> <XCircle className="h-4 w-4" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
{downloadingTrack === track.isrc ? (
<p>Downloading...</p>
) : skippedTracks.has(track.isrc) ? (
<p>Already exists</p>
) : downloadedTracks.has(track.isrc) ? (
<p>Downloaded</p>
) : failedTracks.has(track.isrc) ? (
<p>Failed</p>
) : (
<p>Download Track</p>
)}
</TooltipContent>
</Tooltip>
)}
{track.spotify_id && onDownloadLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() =>
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1)
}
size="sm"
variant="outline"
disabled={downloadingLyricsTrack === track.spotify_id}
>
{downloadingLyricsTrack === track.spotify_id ? (
<Spinner />
) : skippedLyrics?.has(track.spotify_id) ? (
<FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedLyrics?.has(track.spotify_id) ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : failedLyrics?.has(track.spotify_id) ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<FileText className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Lyric</p>
</TooltipContent>
</Tooltip>
)}
{track.images && onDownloadCover && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() => {
const trackId = track.spotify_id || `${track.name}-${track.artists}`;
onDownloadCover(track.images, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1, trackId);
}}
size="sm"
variant="outline"
disabled={downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`)}
>
{downloadingCoverTrack === (track.spotify_id || `${track.name}-${track.artists}`) ? (
<Spinner />
) : skippedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
<FileCheck className="h-4 w-4 text-yellow-500" />
) : downloadedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
<CheckCircle className="h-4 w-4 text-green-500" />
) : failedCovers?.has(track.spotify_id || `${track.name}-${track.artists}`) ? (
<XCircle className="h-4 w-4 text-red-500" />
) : (
<ImageDown className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Download Cover</p>
</TooltipContent>
</Tooltip>
)} )}
{track.spotify_id && onCheckAvailability && ( {track.spotify_id && onCheckAvailability && (
<Tooltip> <Tooltip>
@@ -336,35 +425,6 @@ export function TrackList({
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
)} )}
{track.spotify_id && onDownloadLyrics && (
<Tooltip>
<TooltipTrigger asChild>
<Button
onClick={() =>
onDownloadLyrics(track.spotify_id!, track.name, track.artists, track.album_name, folderName, isArtistDiscography, startIndex + index + 1)
}
size="sm"
variant="outline"
disabled={downloadingLyricsTrack === track.spotify_id}
>
{downloadingLyricsTrack === track.spotify_id ? (
<Spinner />
) : skippedLyrics?.has(track.spotify_id) ? (
<SkipForward 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>
)}
</div> </div>
</td> </td>
</tr> </tr>
+1 -1
View File
@@ -60,7 +60,7 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( 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", "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 className
)} )}
{...props} {...props}
@@ -32,7 +32,7 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
setHasSelection(start !== end); setHasSelection(start !== end);
}; };
// Check clipboard permission // Check clipboard permission when user explicitly opens the context menu.
const checkClipboard = async () => { const checkClipboard = async () => {
try { try {
const text = await navigator.clipboard.readText(); const text = await navigator.clipboard.readText();
@@ -42,10 +42,6 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
} }
}; };
React.useEffect(() => {
checkClipboard();
}, []);
const handleCut = async () => { const handleCut = async () => {
const input = inputRef.current; const input = inputRef.current;
if (!input) return; if (!input) return;
@@ -156,7 +152,13 @@ const InputWithContext = React.forwardRef<HTMLInputElement, InputWithContextProp
}; };
return ( return (
<ContextMenu onOpenChange={checkClipboard}> <ContextMenu
onOpenChange={(open) => {
if (open) {
checkClipboard();
}
}}
>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<Input <Input
ref={inputRef} ref={inputRef}
@@ -0,0 +1,46 @@
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
const ScrollArea = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
>(({ className, children, ...props }, ref) => (
<ScrollAreaPrimitive.Root
ref={ref}
className={cn("relative overflow-hidden", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
))
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName
const ScrollBar = React.forwardRef<
React.ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
>(({ className, orientation = "vertical", ...props }, ref) => (
<ScrollAreaPrimitive.ScrollAreaScrollbar
ref={ref}
orientation={orientation}
className={cn(
"flex touch-none select-none transition-colors",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent p-[1px]",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent p-[1px]",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
</ScrollAreaPrimitive.ScrollAreaScrollbar>
))
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName
export { ScrollArea, ScrollBar }
+1
View File
@@ -36,6 +36,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
"--normal-text": "var(--popover-foreground)", "--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)", "--normal-border": "var(--border)",
"--border-radius": "var(--radius)", "--border-radius": "var(--radius)",
left: "calc(56px + 1rem)",
} as React.CSSProperties } as React.CSSProperties
} }
{...props} {...props}
+31
View File
@@ -0,0 +1,31 @@
"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 }
+218
View File
@@ -0,0 +1,218 @@
import { useState, useRef } from "react";
import { downloadCover } from "@/lib/api";
import { getSettings } 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,
isArtistDiscography?: boolean,
position?: number,
trackId?: string
) => {
if (!coverUrl) {
toast.error("No cover URL found for this track");
return;
}
const id = trackId || `${trackName}-${artistName}`;
logger.info(`downloading cover: ${trackName} - ${artistName}`);
const settings = getSettings();
setDownloadingCover(true);
setDownloadingCoverTrack(id);
try {
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
// Build output path similar to audio download
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
}
}
}
const response = await downloadCover({
cover_url: coverUrl,
track_name: trackName,
artist_name: artistName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position: position || 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,
isArtistDiscography?: 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;
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
if (isArtistDiscography) {
if (settings.albumSubfolder && track.album_name) {
outputDir = joinPath(os, outputDir, sanitizePath(track.album_name, os));
}
} else {
if (settings.artistSubfolder && track.artists) {
outputDir = joinPath(os, outputDir, sanitizePath(track.artists, os));
}
if (settings.albumSubfolder && track.album_name) {
outputDir = joinPath(os, outputDir, sanitizePath(track.album_name, os));
}
}
}
const response = await downloadCover({
cover_url: track.images,
track_name: track.name,
artist_name: track.artists,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position: i + 1,
});
if (response.success) {
if (response.already_exists) {
skipped++;
setSkippedCovers((prev) => new Set(prev).add(id));
} else {
success++;
setDownloadedCovers((prev) => new Set(prev).add(id));
}
} else {
failed++;
setFailedCovers((prev) => new Set(prev).add(id));
}
} catch {
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,
};
}
+288 -6
View File
@@ -60,6 +60,10 @@ export function useDownload() {
} }
} }
// Always add item to queue before downloading
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemID = await AddToDownloadQueue(isrc, trackName || "", artistName || "", albumName || "");
if (service === "auto") { if (service === "auto") {
// Get all streaming URLs once from song.link API // Get all streaming URLs once from song.link API
let streamingURLs: any = null; let streamingURLs: any = null;
@@ -95,6 +99,7 @@ export function useDownload() {
spotify_id: spotifyId, spotify_id: spotifyId,
service_url: streamingURLs.tidal_url, service_url: streamingURLs.tidal_url,
duration: durationSeconds, duration: durationSeconds,
item_id: itemID, // Pass the same itemID through all attempts
}); });
if (tidalResponse.success) { if (tidalResponse.success) {
@@ -125,6 +130,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId, spotify_id: spotifyId,
service_url: streamingURLs.deezer_url, service_url: streamingURLs.deezer_url,
item_id: itemID,
}); });
if (deezerResponse.success) { if (deezerResponse.success) {
@@ -155,6 +161,7 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId, spotify_id: spotifyId,
service_url: streamingURLs.amazon_url, service_url: streamingURLs.amazon_url,
item_id: itemID,
}); });
if (amazonResponse.success) { if (amazonResponse.success) {
@@ -169,13 +176,37 @@ export function useDownload() {
// Try Qobuz as last fallback // Try Qobuz as last fallback
logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`); logger.debug(`trying qobuz (fallback) for: ${trackName} - ${artistName}`);
service = "qobuz"; const qobuzResponse = await downloadTrack({
isrc,
service: "qobuz",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
item_id: itemID,
});
// If Qobuz also failed, mark the item as failed
if (!qobuzResponse.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed");
}
return qobuzResponse;
} }
// Convert duration from ms to seconds for backend (if not already done above) // Single service download (not auto-fallback)
// Convert duration from ms to seconds for backend
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined; const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
return await downloadTrack({ const singleServiceResponse = await downloadTrack({
isrc, isrc,
service: service as "deezer" | "tidal" | "qobuz" | "amazon", service: service as "deezer" | "tidal" | "qobuz" | "amazon",
query, query,
@@ -189,7 +220,213 @@ export function useDownload() {
use_album_track_number: useAlbumTrackNumber, use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId, spotify_id: spotifyId,
duration: durationSecondsForFallback, duration: durationSecondsForFallback,
item_id: itemID, // Pass itemID for tracking
}); });
// Mark as failed if download failed for single-service attempt
if (!singleServiceResponse.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
}
return singleServiceResponse;
};
const downloadWithItemID = async (
isrc: string,
settings: any,
itemID: string,
trackName?: string,
artistName?: string,
albumName?: string,
playlistName?: string,
isArtistDiscography?: boolean,
position?: number,
spotifyId?: string,
durationMs?: number
) => {
let service = settings.downloader;
const query = trackName && artistName ? `${trackName} ${artistName}` : undefined;
const os = settings.operatingSystem;
let outputDir = settings.downloadPath;
let useAlbumTrackNumber = false;
if (playlistName) {
outputDir = joinPath(os, outputDir, sanitizePath(playlistName, os));
if (isArtistDiscography) {
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true;
}
} else {
if (settings.artistSubfolder && artistName) {
outputDir = joinPath(os, outputDir, sanitizePath(artistName, os));
}
if (settings.albumSubfolder && albumName) {
outputDir = joinPath(os, outputDir, sanitizePath(albumName, os));
useAlbumTrackNumber = true;
}
}
}
if (service === "auto") {
// Get all streaming URLs once from song.link API
let streamingURLs: any = null;
if (spotifyId) {
try {
const { GetStreamingURLs } = await import("../../wailsjs/go/main/App");
const urlsJson = await GetStreamingURLs(spotifyId);
streamingURLs = JSON.parse(urlsJson);
} catch (err) {
console.error("Failed to get streaming URLs:", err);
}
}
const durationSeconds = durationMs ? Math.round(durationMs / 1000) : undefined;
// Try Tidal first
if (streamingURLs?.tidal_url) {
try {
const tidalResponse = await downloadTrack({
isrc,
service: "tidal",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.tidal_url,
duration: durationSeconds,
item_id: itemID,
});
if (tidalResponse.success) {
return tidalResponse;
}
} catch (tidalErr) {
console.error("Tidal error:", tidalErr);
}
}
// Try Deezer second
if (streamingURLs?.deezer_url) {
try {
const deezerResponse = await downloadTrack({
isrc,
service: "deezer",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.deezer_url,
item_id: itemID,
});
if (deezerResponse.success) {
return deezerResponse;
}
} catch (deezerErr) {
console.error("Deezer error:", deezerErr);
}
}
// Try Amazon third
if (streamingURLs?.amazon_url) {
try {
const amazonResponse = await downloadTrack({
isrc,
service: "amazon",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
service_url: streamingURLs.amazon_url,
item_id: itemID,
});
if (amazonResponse.success) {
return amazonResponse;
}
} catch (amazonErr) {
console.error("Amazon error:", amazonErr);
}
}
// Try Qobuz as last fallback
const qobuzResponse = await downloadTrack({
isrc,
service: "qobuz",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
duration: durationMs ? Math.round(durationMs / 1000) : undefined,
item_id: itemID,
});
// If Qobuz also failed, mark the item as failed
if (!qobuzResponse.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, qobuzResponse.error || "All services failed");
}
return qobuzResponse;
}
// Single service download
const durationSecondsForFallback = durationMs ? Math.round(durationMs / 1000) : undefined;
const singleServiceResponse = await downloadTrack({
isrc,
service: service as "deezer" | "tidal" | "qobuz" | "amazon",
query,
track_name: trackName,
artist_name: artistName,
album_name: albumName,
output_dir: outputDir,
filename_format: settings.filenameFormat,
track_number: settings.trackNumber,
position,
use_album_track_number: useAlbumTrackNumber,
spotify_id: spotifyId,
duration: durationSecondsForFallback,
item_id: itemID,
});
// Mark as failed if download failed for single-service attempt
if (!singleServiceResponse.success) {
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, singleServiceResponse.error || "Download failed");
}
return singleServiceResponse;
}; };
const handleDownloadTrack = async ( const handleDownloadTrack = async (
@@ -268,6 +505,20 @@ export function useDownload() {
setBulkDownloadType("selected"); setBulkDownloadType("selected");
setDownloadProgress(0); setDownloadProgress(0);
// Pre-add ALL tracks to the queue before starting downloads
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = [];
for (const isrc of selectedTracks) {
const track = allTracks.find((t) => t.isrc === isrc);
const itemID = await AddToDownloadQueue(
isrc,
track?.name || "",
track?.artists || "",
track?.album_name || ""
);
itemIDs.push(itemID);
}
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
let skippedCount = 0; let skippedCount = 0;
@@ -283,6 +534,7 @@ export function useDownload() {
const isrc = selectedTracks[i]; const isrc = selectedTracks[i];
const track = allTracks.find((t) => t.isrc === isrc); const track = allTracks.find((t) => t.isrc === isrc);
const itemID = itemIDs[i];
setDownloadingTrack(isrc); setDownloadingTrack(isrc);
@@ -291,10 +543,11 @@ export function useDownload() {
} }
try { try {
// Use sequential numbering (1, 2, 3...) for selected tracks // Download with pre-created itemID
const response = await downloadWithAutoFallback( const response = await downloadWithItemID(
isrc, isrc,
settings, settings,
itemID,
track?.name, track?.name,
track?.artists, track?.artists,
track?.album_name, track?.album_name,
@@ -329,6 +582,9 @@ export function useDownload() {
errorCount++; errorCount++;
logger.error(`error: ${track?.name} - ${err}`); logger.error(`error: ${track?.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(isrc)); setFailedTracks((prev) => new Set(prev).add(isrc));
// Mark item as failed in queue
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
} }
setDownloadProgress(Math.round(((i + 1) / total) * 100)); setDownloadProgress(Math.round(((i + 1) / total) * 100));
@@ -340,6 +596,10 @@ export function useDownload() {
setBulkDownloadType(null); setBulkDownloadType(null);
shouldStopDownloadRef.current = false; shouldStopDownloadRef.current = false;
// Cancel any remaining queued items
const { CancelAllQueuedItems } = await import("../../wailsjs/go/main/App");
await CancelAllQueuedItems();
// Build summary message // Build summary message
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`); logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
if (errorCount === 0 && skippedCount === 0) { if (errorCount === 0 && skippedCount === 0) {
@@ -378,6 +638,19 @@ export function useDownload() {
setBulkDownloadType("all"); setBulkDownloadType("all");
setDownloadProgress(0); setDownloadProgress(0);
// Pre-add ALL tracks to the queue before starting downloads
const { AddToDownloadQueue } = await import("../../wailsjs/go/main/App");
const itemIDs: string[] = [];
for (const track of tracksWithIsrc) {
const itemID = await AddToDownloadQueue(
track.isrc,
track.name,
track.artists,
track.album_name || ""
);
itemIDs.push(itemID);
}
let successCount = 0; let successCount = 0;
let errorCount = 0; let errorCount = 0;
let skippedCount = 0; let skippedCount = 0;
@@ -392,14 +665,16 @@ export function useDownload() {
} }
const track = tracksWithIsrc[i]; const track = tracksWithIsrc[i];
const itemID = itemIDs[i];
setDownloadingTrack(track.isrc); setDownloadingTrack(track.isrc);
setCurrentDownloadInfo({ name: track.name, artists: track.artists }); setCurrentDownloadInfo({ name: track.name, artists: track.artists });
try { try {
const response = await downloadWithAutoFallback( const response = await downloadWithItemID(
track.isrc, track.isrc,
settings, settings,
itemID,
track.name, track.name,
track.artists, track.artists,
track.album_name, track.album_name,
@@ -434,6 +709,9 @@ export function useDownload() {
errorCount++; errorCount++;
logger.error(`error: ${track.name} - ${err}`); logger.error(`error: ${track.name} - ${err}`);
setFailedTracks((prev) => new Set(prev).add(track.isrc)); setFailedTracks((prev) => new Set(prev).add(track.isrc));
// Mark item as failed in queue
const { MarkDownloadItemFailed } = await import("../../wailsjs/go/main/App");
await MarkDownloadItemFailed(itemID, err instanceof Error ? err.message : String(err));
} }
setDownloadProgress(Math.round(((i + 1) / total) * 100)); setDownloadProgress(Math.round(((i + 1) / total) * 100));
@@ -445,6 +723,10 @@ export function useDownload() {
setBulkDownloadType(null); setBulkDownloadType(null);
shouldStopDownloadRef.current = false; shouldStopDownloadRef.current = false;
// Cancel any remaining queued items
const { CancelAllQueuedItems: CancelQueued } = await import("../../wailsjs/go/main/App");
await CancelQueued();
// Build summary message // Build summary message
logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`); logger.info(`batch complete: ${successCount} downloaded, ${skippedCount} skipped, ${errorCount} failed`);
if (errorCount === 0 && skippedCount === 0) { if (errorCount === 0 && skippedCount === 0) {
@@ -0,0 +1,40 @@
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);
}
};
// Initial fetch
fetchQueue();
// Poll every 200ms
const interval = setInterval(fetchQueue, 200);
return () => clearInterval(interval);
}, []);
return queueInfo;
}
@@ -0,0 +1,16 @@
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,
};
}
+10 -1
View File
@@ -5,8 +5,10 @@ import type {
HealthResponse, HealthResponse,
LyricsDownloadRequest, LyricsDownloadRequest,
LyricsDownloadResponse, LyricsDownloadResponse,
CoverDownloadRequest,
CoverDownloadResponse,
} from "@/types/api"; } from "@/types/api";
import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics } from "../../wailsjs/go/main/App"; import { GetSpotifyMetadata, DownloadTrack, DownloadLyrics, DownloadCover } from "../../wailsjs/go/main/App";
import { main } from "../../wailsjs/go/models"; import { main } from "../../wailsjs/go/models";
export async function fetchSpotifyMetadata( export async function fetchSpotifyMetadata(
@@ -48,3 +50,10 @@ export async function downloadLyrics(
const req = new main.LyricsDownloadRequest(request); const req = new main.LyricsDownloadRequest(request);
return await DownloadLyrics(req); return await DownloadLyrics(req);
} }
export async function downloadCover(
request: CoverDownloadRequest
): Promise<CoverDownloadResponse> {
const req = new main.CoverDownloadRequest(request);
return await DownloadCover(req);
}
+59
View File
@@ -0,0 +1,59 @@
/**
* Format a date to relative time string with max 2 units
* e.g., "23 hours 32 minutes ago", "1 day 14 hours ago"
*/
export function formatRelativeTime(date: Date | string | number): string {
const now = new Date();
const target = new Date(date);
const diffMs = now.getTime() - target.getTime();
if (diffMs < 0) return "just now";
const seconds = Math.floor(diffMs / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const weeks = Math.floor(days / 7);
const months = Math.floor(days / 30);
const years = Math.floor(days / 365);
const parts: string[] = [];
if (years > 0) {
parts.push(`${years} ${years === 1 ? "year" : "years"}`);
const remainingMonths = Math.floor((days % 365) / 30);
if (remainingMonths > 0) {
parts.push(`${remainingMonths} ${remainingMonths === 1 ? "month" : "months"}`);
}
} else if (months > 0) {
parts.push(`${months} ${months === 1 ? "month" : "months"}`);
const remainingDays = days % 30;
if (remainingDays > 0) {
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
}
} else if (weeks > 0) {
parts.push(`${weeks} ${weeks === 1 ? "week" : "weeks"}`);
const remainingDays = days % 7;
if (remainingDays > 0) {
parts.push(`${remainingDays} ${remainingDays === 1 ? "day" : "days"}`);
}
} else if (days > 0) {
parts.push(`${days} ${days === 1 ? "day" : "days"}`);
const remainingHours = hours % 24;
if (remainingHours > 0) {
parts.push(`${remainingHours} ${remainingHours === 1 ? "hour" : "hours"}`);
}
} else if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? "hour" : "hours"}`);
const remainingMinutes = minutes % 60;
if (remainingMinutes > 0) {
parts.push(`${remainingMinutes} ${remainingMinutes === 1 ? "minute" : "minutes"}`);
}
} else if (minutes > 0) {
parts.push(`${minutes} ${minutes === 1 ? "minute" : "minutes"}`);
} else {
return "just now";
}
return "Released " + parts.slice(0, 2).join(" ") + " ago";
}
+25
View File
@@ -1,14 +1,18 @@
import { GetDefaults } from "../../wailsjs/go/main/App"; import { GetDefaults } from "../../wailsjs/go/main/App";
export type FontFamily = "google-sans" | "inter" | "poppins" | "roboto" | "dm-sans" | "plus-jakarta-sans" | "manrope" | "space-grotesk";
export interface Settings { export interface Settings {
downloadPath: string; downloadPath: string;
downloader: "auto" | "deezer" | "tidal" | "qobuz" | "amazon"; downloader: "auto" | "deezer" | "tidal" | "qobuz" | "amazon";
theme: string; theme: string;
themeMode: "auto" | "light" | "dark"; themeMode: "auto" | "light" | "dark";
fontFamily: FontFamily;
filenameFormat: "title-artist" | "artist-title" | "title"; filenameFormat: "title-artist" | "artist-title" | "title";
artistSubfolder: boolean; artistSubfolder: boolean;
albumSubfolder: boolean; albumSubfolder: boolean;
trackNumber: boolean; trackNumber: boolean;
sfxEnabled: boolean;
operatingSystem: "Windows" | "linux/MacOS" operatingSystem: "Windows" | "linux/MacOS"
} }
@@ -26,13 +30,34 @@ export const DEFAULT_SETTINGS: Settings = {
downloader: "auto", downloader: "auto",
theme: "yellow", theme: "yellow",
themeMode: "auto", themeMode: "auto",
fontFamily: "google-sans",
filenameFormat: "title-artist", filenameFormat: "title-artist",
artistSubfolder: false, artistSubfolder: false,
albumSubfolder: false, albumSubfolder: false,
trackNumber: false, trackNumber: false,
sfxEnabled: true,
operatingSystem: detectOS() operatingSystem: detectOS()
}; };
export const FONT_OPTIONS: { value: FontFamily; label: string; fontFamily: string }[] = [
{ value: "google-sans", label: "Google Sans Flex", fontFamily: '"Google Sans Flex", system-ui, sans-serif' },
{ value: "inter", label: "Inter", fontFamily: '"Inter", system-ui, sans-serif' },
{ value: "poppins", label: "Poppins", fontFamily: '"Poppins", system-ui, sans-serif' },
{ value: "roboto", label: "Roboto", fontFamily: '"Roboto", system-ui, sans-serif' },
{ value: "dm-sans", label: "DM Sans", fontFamily: '"DM Sans", system-ui, sans-serif' },
{ value: "plus-jakarta-sans", label: "Plus Jakarta Sans", fontFamily: '"Plus Jakarta Sans", system-ui, sans-serif' },
{ value: "manrope", label: "Manrope", fontFamily: '"Manrope", system-ui, sans-serif' },
{ value: "space-grotesk", label: "Space Grotesk", fontFamily: '"Space Grotesk", system-ui, sans-serif' },
];
export function applyFont(fontFamily: FontFamily): void {
const font = FONT_OPTIONS.find(f => f.value === fontFamily);
if (font) {
document.documentElement.style.setProperty('--font-sans', font.fontFamily);
document.body.style.fontFamily = font.fontFamily;
}
}
async function fetchDefaultPath(): Promise<string> { async function fetchDefaultPath(): Promise<string> {
try { try {
const data = await GetDefaults(); const data = await GetDefaults();
+9 -5
View File
@@ -6,38 +6,42 @@ import {
playInfoSound, playInfoSound,
} from "./audio"; } from "./audio";
import { logger } from "./logger"; import { logger } from "./logger";
import { getSettings } from "./settings";
const toastStyle = { const toastStyle = {
className: "font-mono lowercase", className: "font-mono lowercase",
}; };
// Helper to check if SFX is enabled
const isSfxEnabled = () => getSettings().sfxEnabled;
// Wrapper functions for toast with sound effects // Wrapper functions for toast with sound effects
export const toastWithSound = { export const toastWithSound = {
success: (message: string, data?: any) => { success: (message: string, data?: any) => {
const msg = message.toLowerCase(); const msg = message.toLowerCase();
logger.success(msg); logger.success(msg);
playSuccessSound(); if (isSfxEnabled()) playSuccessSound();
return toast.success(msg, { ...toastStyle, ...data }); return toast.success(msg, { ...toastStyle, ...data });
}, },
error: (message: string, data?: any) => { error: (message: string, data?: any) => {
const msg = message.toLowerCase(); const msg = message.toLowerCase();
logger.error(msg); logger.error(msg);
playErrorSound(); if (isSfxEnabled()) playErrorSound();
return toast.error(msg, { ...toastStyle, ...data }); return toast.error(msg, { ...toastStyle, ...data });
}, },
warning: (message: string, data?: any) => { warning: (message: string, data?: any) => {
const msg = message.toLowerCase(); const msg = message.toLowerCase();
logger.warning(msg); logger.warning(msg);
playWarningSound(); if (isSfxEnabled()) playWarningSound();
return toast.warning(msg, { ...toastStyle, ...data }); return toast.warning(msg, { ...toastStyle, ...data });
}, },
info: (message: string, data?: any) => { info: (message: string, data?: any) => {
const msg = message.toLowerCase(); const msg = message.toLowerCase();
logger.info(msg); logger.info(msg);
playInfoSound(); if (isSfxEnabled()) playInfoSound();
return toast.info(msg, { ...toastStyle, ...data }); return toast.info(msg, { ...toastStyle, ...data });
}, },
@@ -45,7 +49,7 @@ export const toastWithSound = {
message: (message: string, data?: any) => { message: (message: string, data?: any) => {
const msg = message.toLowerCase(); const msg = message.toLowerCase();
logger.info(msg); logger.info(msg);
playInfoSound(); if (isSfxEnabled()) playInfoSound();
return toast(msg, { ...toastStyle, ...data }); return toast(msg, { ...toastStyle, ...data });
}, },
}; };
+12
View File
@@ -1,5 +1,6 @@
import { clsx, type ClassValue } from "clsx" import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge" import { twMerge } from "tailwind-merge"
import { BrowserOpenURL } from "../../wailsjs/runtime/runtime"
import type { Settings } from "./settings"; import type { Settings } from "./settings";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
@@ -44,4 +45,15 @@ export function buildOutputPath(settings: Settings, folder?: string) {
const sanitized = folder ? sanitizePath(folder, os) : undefined; const sanitized = folder ? sanitizePath(folder, os) : undefined;
return sanitized ? joinPath(os, base, sanitized) : base; return sanitized ? joinPath(os, base, sanitized) : base;
}
export function openExternal(url: string) {
if (!url) return;
try {
BrowserOpenURL(url);
} catch (error) {
if (typeof window !== "undefined") {
window.open(url, "_blank", "noopener,noreferrer");
}
}
} }
-4
View File
@@ -3,14 +3,10 @@ import { createRoot } from "react-dom/client";
import "./index.css"; import "./index.css";
import App from "./App.tsx"; import App from "./App.tsx";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { DebugLogger } from "@/components/DebugLogger";
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<App /> <App />
<Toaster position="bottom-left" duration={1000} /> <Toaster position="bottom-left" duration={1000} />
<div className="fixed bottom-2 left-2 z-50">
<DebugLogger />
</div>
</StrictMode> </StrictMode>
); );
+20
View File
@@ -125,6 +125,7 @@ export interface DownloadRequest {
spotify_id?: string; spotify_id?: string;
service_url?: string; service_url?: string;
duration?: number; // Track duration in seconds for better matching duration?: number; // Track duration in seconds for better matching
item_id?: string; // Optional queue item ID for multi-service fallback tracking
} }
export interface DownloadResponse { export interface DownloadResponse {
@@ -133,6 +134,7 @@ export interface DownloadResponse {
file?: string; file?: string;
error?: string; error?: string;
already_exists?: boolean; already_exists?: boolean;
item_id?: string; // Queue item ID for tracking
} }
export interface HealthResponse { export interface HealthResponse {
@@ -198,4 +200,22 @@ export interface TrackAvailability {
qobuz_url?: string; qobuz_url?: string;
} }
export interface CoverDownloadRequest {
cover_url: string;
track_name: string;
artist_name: string;
output_dir?: string;
filename_format?: string;
track_number?: boolean;
position?: number;
}
export interface CoverDownloadResponse {
success: boolean;
message: string;
file?: string;
error?: string;
already_exists?: boolean;
}
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
"version": "6.2" "version": "6.6"
} }
+1 -1
View File
@@ -12,7 +12,7 @@
}, },
"info": { "info": {
"productName": "SpotiFLAC", "productName": "SpotiFLAC",
"productVersion": "6.4" "productVersion": "6.6"
}, },
"wailsjsdir": "./frontend", "wailsjsdir": "./frontend",
"assetdir": "./frontend/dist", "assetdir": "./frontend/dist",