From 2653586eea606bcc49f8fd523d533986de02459c Mon Sep 17 00:00:00 2001
From: Lukas <69511007+Lukas200301@users.noreply.github.com>
Date: Sat, 29 Nov 2025 11:36:58 +0100
Subject: [PATCH] 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.
---
app.go | 105 +++++-
backend/metadata.go | 7 +-
backend/progress.go | 319 +++++++++++++++++-
frontend/package.json | 1 +
frontend/pnpm-lock.yaml | 33 ++
frontend/src/App.tsx | 20 +-
.../src/components/DownloadProgressToast.tsx | 30 +-
frontend/src/components/DownloadQueue.tsx | 290 ++++++++++++++++
frontend/src/components/ui/dialog.tsx | 2 +-
frontend/src/components/ui/scroll-area.tsx | 46 +++
frontend/src/hooks/useDownload.ts | 294 +++++++++++++++-
frontend/src/hooks/useDownloadQueueData.ts | 40 +++
frontend/src/hooks/useDownloadQueueDialog.ts | 16 +
frontend/src/types/api.ts | 2 +
14 files changed, 1175 insertions(+), 30 deletions(-)
create mode 100644 frontend/src/components/DownloadQueue.tsx
create mode 100644 frontend/src/components/ui/scroll-area.tsx
create mode 100644 frontend/src/hooks/useDownloadQueueData.ts
create mode 100644 frontend/src/hooks/useDownloadQueueDialog.ts
diff --git a/app.go b/app.go
index 58c0fc9..7b139ce 100644
--- a/app.go
+++ b/app.go
@@ -53,6 +53,7 @@ type DownloadRequest struct {
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
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
@@ -62,6 +63,7 @@ type DownloadResponse struct {
File string `json:"file,omitempty"`
Error string `json:"error,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
@@ -143,14 +145,30 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
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
if existingFile, exists := backend.CheckISRCExists(req.OutputDir, req.ISRC); exists {
fmt.Printf("File with ISRC %s already exists: %s\n", req.ISRC, existingFile)
+ backend.SkipDownloadItem(itemID, existingFile)
return DownloadResponse{
Success: true,
Message: "File with same ISRC already exists",
File: existingFile,
AlreadyExists: true,
+ ItemID: itemID,
}, 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)
expectedPath := filepath.Join(req.OutputDir, expectedFilename)
- if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 0 {
- return DownloadResponse{
- Success: true,
- Message: "File already exists",
- File: expectedPath,
- AlreadyExists: true,
- }, nil
+ if fileInfo, err := os.Stat(expectedPath); err == nil && fileInfo.Size() > 100*1024 {
+ // Validate the file by checking if it has valid ISRC metadata
+ if fileISRC, readErr := backend.ReadISRCFromFile(expectedPath); readErr == nil && fileISRC != "" {
+ // File exists and has valid metadata - skip download
+ backend.SkipDownloadItem(itemID, expectedPath)
+ return DownloadResponse{
+ 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 {
case "amazon":
downloader := backend.NewAmazonDownloader()
@@ -243,9 +269,23 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
}
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{
Success: false,
Error: fmt.Sprintf("Download failed: %v", err),
+ ItemID: itemID,
}, err
}
@@ -259,6 +299,16 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
message := "Download completed successfully"
if alreadyExists {
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{
@@ -266,6 +316,7 @@ func (a *App) DownloadTrack(req DownloadRequest) (DownloadResponse, error) {
Message: message,
File: filename,
AlreadyExists: alreadyExists,
+ ItemID: itemID,
}, nil
}
@@ -305,6 +356,38 @@ func (a *App) GetDownloadProgress() backend.ProgressInfo {
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
func (a *App) Quit() {
// You can add cleanup logic here if needed
diff --git a/backend/metadata.go b/backend/metadata.go
index f143338..00ce5a4 100644
--- a/backend/metadata.go
+++ b/backend/metadata.go
@@ -168,9 +168,14 @@ func CheckISRCExists(outputDir string, targetISRC string) (string, bool) {
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)
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
}
diff --git a/backend/progress.go b/backend/progress.go
index e9f8598..3915197 100644
--- a/backend/progress.go
+++ b/backend/progress.go
@@ -7,6 +7,34 @@ import (
"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
var (
currentProgress float64
@@ -15,6 +43,16 @@ var (
downloadingLock sync.RWMutex
currentSpeed float64
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
@@ -24,6 +62,19 @@ type ProgressInfo struct {
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
func GetDownloadProgress() ProgressInfo {
downloadingLock.RLock()
@@ -80,6 +131,7 @@ type ProgressWriter struct {
startTime int64
lastTime int64
lastBytes int64
+ itemID string // Track which download item this belongs to
}
func NewProgressWriter(writer io.Writer) *ProgressWriter {
@@ -91,9 +143,17 @@ func NewProgressWriter(writer io.Writer) *ProgressWriter {
startTime: now,
lastTime: now,
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 {
return time.Now().UnixMilli()
}
@@ -111,8 +171,9 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
timeDiff := float64(now-pw.lastTime) / 1000.0 // seconds
bytesDiff := float64(pw.total - pw.lastBytes)
+ var speedMBps float64
if timeDiff > 0 {
- speedMBps := (bytesDiff / (1024 * 1024)) / timeDiff
+ speedMBps = (bytesDiff / (1024 * 1024)) / timeDiff
SetDownloadSpeed(speedMBps)
fmt.Printf("\rDownloaded: %.2f MB (%.2f MB/s)", mbDownloaded, speedMBps)
} else {
@@ -122,6 +183,11 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
// Update global progress
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.lastTime = now
pw.lastBytes = pw.total
@@ -133,3 +199,254 @@ func (pw *ProgressWriter) Write(p []byte) (int, error) {
func (pw *ProgressWriter) GetTotal() int64 {
return pw.total
}
+
+// Queue management functions
+
+// AddToQueue adds a new item to the download queue
+func AddToQueue(id, trackName, artistName, albumName, isrc string) {
+ downloadQueueLock.Lock()
+ defer downloadQueueLock.Unlock()
+
+ 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()
+ }
+}
diff --git a/frontend/package.json b/frontend/package.json
index 11aa3c2..bd29d97 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -18,6 +18,7 @@
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
+ "@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml
index 7ef570e..8b18aa0 100644
--- a/frontend/pnpm-lock.yaml
+++ b/frontend/pnpm-lock.yaml
@@ -26,6 +26,9 @@ importers:
'@radix-ui/react-radio-group':
specifier: ^1.3.8
version: 1.3.8(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-scroll-area':
+ specifier: ^1.2.10
+ version: 1.2.10(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
@@ -871,6 +874,19 @@ packages:
'@types/react-dom':
optional: true
+ '@radix-ui/react-scroll-area@1.2.10':
+ resolution: {integrity: sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-select@2.2.6':
resolution: {integrity: sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==}
peerDependencies:
@@ -2735,6 +2751,23 @@ snapshots:
'@types/react': 19.2.6
'@types/react-dom': 19.2.3(@types/react@19.2.6)
+ '@radix-ui/react-scroll-area@1.2.10(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
+ dependencies:
+ '@radix-ui/number': 1.1.1
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-context': 1.1.2(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-direction': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
+ '@radix-ui/react-use-callback-ref': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ '@radix-ui/react-use-layout-effect': 1.1.1(@types/react@19.2.6)(react@19.2.0)
+ react: 19.2.0
+ react-dom: 19.2.0(react@19.2.0)
+ optionalDependencies:
+ '@types/react': 19.2.6
+ '@types/react-dom': 19.2.3(@types/react@19.2.6)
+
'@radix-ui/react-select@2.2.6(@types/react-dom@19.2.3(@types/react@19.2.6))(@types/react@19.2.6)(react-dom@19.2.0(react@19.2.0))(react@19.2.0)':
dependencies:
'@radix-ui/number': 1.1.1
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 2ce6cc6..2c8cf0c 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -24,6 +24,7 @@ import { TrackInfo } from "@/components/TrackInfo";
import { AlbumInfo } from "@/components/AlbumInfo";
import { PlaylistInfo } from "@/components/PlaylistInfo";
import { ArtistInfo } from "@/components/ArtistInfo";
+import { DownloadQueue } from "@/components/DownloadQueue";
import { DownloadProgressToast } from "@/components/DownloadProgressToast";
import type { HistoryItem } from "@/components/FetchHistory";
@@ -32,6 +33,7 @@ import { useDownload } from "@/hooks/useDownload";
import { useMetadata } from "@/hooks/useMetadata";
import { useLyrics } from "@/hooks/useLyrics";
import { useAvailability } from "@/hooks/useAvailability";
+import { useDownloadQueueDialog } from "@/hooks/useDownloadQueueDialog";
const HISTORY_KEY = "spotiflac_fetch_history";
const MAX_HISTORY = 5;
@@ -52,6 +54,7 @@ function App() {
const metadata = useMetadata();
const lyrics = useLyrics();
const availability = useAvailability();
+ const downloadQueue = useDownloadQueueDialog();
useEffect(() => {
const settings = getSettings();
@@ -456,10 +459,19 @@ function App() {