diff --git a/CHANGELOG.md b/CHANGELOG.md index fcf21a0d..99bb3dde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [2.0.1] - 2026-01-03 + +### Added +- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker + - Tap to expand long track titles + - Expand icon only shows when title is truncated + - Ripple effect follows rounded corners including drag handle + +### Changed +- **Update Dialog Redesign**: Material Expressive 3 style + - Icon header with container + - Version chips with "Current" and "New" labels + - Changelog in rounded card + - Download progress with percentage indicator + - Cleaner button layout +- **Unified Progress Tracking System**: Deprecated legacy single-download progress + - All downloads now use item-based progress tracking + - Fixes duplicate notification bug when finalizing + - Cleaner codebase with single progress system + +### Fixed +- **Duplicate Notification Bug**: Fixed issue where "Finalizing" and "Downloading" notifications appeared simultaneously +- **Update Notification Stuck**: Fixed notification staying at 100% after download completes + ## [2.0.0] - 2026-01-03 ### Added @@ -48,16 +72,6 @@ - Theme/view mode chips have visible borders in light mode - **Navigation Bar Styling**: Distinct background color from content area - **Ask Before Download Default**: Now enabled by default for better UX -- **Quality Picker Track Info**: Shows track name, artist, and cover in quality picker - - Tap to expand long track titles - - Expand icon only shows when title is truncated - - Ripple effect follows rounded corners including drag handle -- **Update Dialog Redesign**: Material Expressive 3 style - - Icon header with container - - Version chips with "Current" and "New" labels - - Changelog in rounded card - - Download progress with percentage indicator - - Cleaner button layout ### Fixed - **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch) diff --git a/flutter_01.png b/flutter_01.png deleted file mode 100644 index e80af511..00000000 Binary files a/flutter_01.png and /dev/null differ diff --git a/go_backend/amazon.go b/go_backend/amazon.go index 10d48fbd..55c8870a 100644 --- a/go_backend/amazon.go +++ b/go_backend/amazon.go @@ -203,12 +203,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir // DownloadFile downloads a file from URL with User-Agent and progress tracking func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { - // Set current file being downloaded (legacy) - SetCurrentFile(filepath.Base(outputPath)) - SetDownloading(true) - defer SetDownloading(false) - - // Initialize item progress if itemID provided + // Initialize item progress (required for all downloads) if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) @@ -232,11 +227,8 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) } // Set total bytes if available - if resp.ContentLength > 0 { - SetBytesTotal(resp.ContentLength) - if itemID != "" { - SetItemBytesTotal(itemID, resp.ContentLength) - } + if resp.ContentLength > 0 && itemID != "" { + SetItemBytesTotal(itemID, resp.ContentLength) } out, err := os.Create(outputPath) @@ -245,14 +237,14 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) } defer out.Close() - // Use appropriate progress writer + // Use item progress writer var bytesWritten int64 if itemID != "" { pw := NewItemProgressWriter(out, itemID) bytesWritten, err = io.Copy(pw, resp.Body) } else { - pw := NewProgressWriter(out) - bytesWritten, err = io.Copy(pw, resp.Body) + // Fallback: direct copy without progress tracking + bytesWritten, err = io.Copy(out, resp.Body) } if err != nil { return fmt.Errorf("failed to write file: %w", err) diff --git a/go_backend/progress.go b/go_backend/progress.go index 94a0d3e6..d86778f9 100644 --- a/go_backend/progress.go +++ b/go_backend/progress.go @@ -5,7 +5,8 @@ import ( "sync" ) -// DownloadProgress represents current download progress (legacy single download) +// DownloadProgress represents current download progress +// Now unified - returns data from multi-progress system type DownloadProgress struct { CurrentFile string `json:"current_file"` Progress float64 `json:"progress"` @@ -32,28 +33,40 @@ type MultiProgress struct { } var ( - currentProgress DownloadProgress - progressMu sync.RWMutex - downloadDir string - downloadDirMu sync.RWMutex - - // Multi-download progress tracking + downloadDir string + downloadDirMu sync.RWMutex + + // Multi-download progress tracking (unified system) multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)} multiMu sync.RWMutex ) -// getProgress returns current download progress (legacy) +// getProgress returns current download progress from multi-progress system +// Returns first active item's progress for backward compatibility func getProgress() DownloadProgress { - progressMu.RLock() - defer progressMu.RUnlock() - return currentProgress + multiMu.RLock() + defer multiMu.RUnlock() + + // Find first active item + for _, item := range multiProgress.Items { + return DownloadProgress{ + CurrentFile: item.ItemID, + Progress: item.Progress * 100, // Convert to percentage + BytesTotal: item.BytesTotal, + BytesReceived: item.BytesReceived, + IsDownloading: item.IsDownloading, + Status: item.Status, + } + } + + return DownloadProgress{} } // GetMultiProgress returns progress for all active downloads as JSON func GetMultiProgress() string { multiMu.RLock() defer multiMu.RUnlock() - + jsonBytes, err := json.Marshal(multiProgress) if err != nil { return "{\"items\":{}}" @@ -65,7 +78,7 @@ func GetMultiProgress() string { func GetItemProgress(itemID string) string { multiMu.RLock() defer multiMu.RUnlock() - + if item, ok := multiProgress.Items[itemID]; ok { jsonBytes, _ := json.Marshal(item) return string(jsonBytes) @@ -77,7 +90,7 @@ func GetItemProgress(itemID string) string { func StartItemProgress(itemID string) { multiMu.Lock() defer multiMu.Unlock() - + multiProgress.Items[itemID] = &ItemProgress{ ItemID: itemID, BytesTotal: 0, @@ -92,7 +105,7 @@ func StartItemProgress(itemID string) { func SetItemBytesTotal(itemID string, total int64) { multiMu.Lock() defer multiMu.Unlock() - + if item, ok := multiProgress.Items[itemID]; ok { item.BytesTotal = total } @@ -102,7 +115,7 @@ func SetItemBytesTotal(itemID string, total int64) { func SetItemBytesReceived(itemID string, received int64) { multiMu.Lock() defer multiMu.Unlock() - + if item, ok := multiProgress.Items[itemID]; ok { item.BytesReceived = received if item.BytesTotal > 0 { @@ -115,16 +128,19 @@ func SetItemBytesReceived(itemID string, received int64) { func CompleteItemProgress(itemID string) { multiMu.Lock() defer multiMu.Unlock() - + if item, ok := multiProgress.Items[itemID]; ok { item.Progress = 1.0 item.IsDownloading = false + item.Status = "completed" } } -// SetItemProgress sets progress for an item directly (used to force 100% before embedding) +// SetItemProgress sets progress for an item directly func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) { multiMu.Lock() + defer multiMu.Unlock() + if item, ok := multiProgress.Items[itemID]; ok { item.Progress = progress if bytesReceived > 0 { @@ -134,39 +150,24 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal item.BytesTotal = bytesTotal } } - multiMu.Unlock() - - // Also update legacy progress for backward compatibility - progressMu.Lock() - if progress >= 1.0 { - currentProgress.Progress = 100.0 - } else { - currentProgress.Progress = progress * 100.0 - } - progressMu.Unlock() } // SetItemFinalizing marks an item as finalizing (embedding metadata) func SetItemFinalizing(itemID string) { multiMu.Lock() + defer multiMu.Unlock() + if item, ok := multiProgress.Items[itemID]; ok { item.Progress = 1.0 item.Status = "finalizing" } - multiMu.Unlock() - - // Also update legacy progress - progressMu.Lock() - currentProgress.Progress = 100.0 - currentProgress.Status = "finalizing" - progressMu.Unlock() } // RemoveItemProgress removes progress tracking for an item func RemoveItemProgress(itemID string) { multiMu.Lock() defer multiMu.Unlock() - + delete(multiProgress.Items, itemID) } @@ -174,46 +175,10 @@ func RemoveItemProgress(itemID string) { func ClearAllItemProgress() { multiMu.Lock() defer multiMu.Unlock() - + multiProgress.Items = make(map[string]*ItemProgress) } -// Legacy functions for backward compatibility - -// SetDownloadProgress sets the current download progress (MB downloaded) -func SetDownloadProgress(mbDownloaded float64) { - progressMu.Lock() - defer progressMu.Unlock() - currentProgress.Progress = mbDownloaded - currentProgress.IsDownloading = true -} - -// SetDownloadSpeed sets the current download speed -func SetDownloadSpeed(speedMBps float64) { - progressMu.Lock() - defer progressMu.Unlock() - currentProgress.Speed = speedMBps -} - -// SetCurrentFile sets the current file being downloaded and resets progress -func SetCurrentFile(filename string) { - progressMu.Lock() - defer progressMu.Unlock() - currentProgress.BytesReceived = 0 - currentProgress.BytesTotal = 0 - currentProgress.Progress = 0 - currentProgress.CurrentFile = filename - currentProgress.IsDownloading = true - currentProgress.Status = "downloading" -} - -// ResetProgress resets the download progress -func ResetProgress() { - progressMu.Lock() - defer progressMu.Unlock() - currentProgress = DownloadProgress{} -} - // setDownloadDir sets the default download directory func setDownloadDir(path string) error { downloadDirMu.Lock() @@ -229,64 +194,6 @@ func getDownloadDir() string { return downloadDir } -// SetDownloading sets the download status -func SetDownloading(status bool) { - progressMu.Lock() - defer progressMu.Unlock() - currentProgress.IsDownloading = status -} - -// SetBytesTotal sets total bytes to download -func SetBytesTotal(total int64) { - progressMu.Lock() - defer progressMu.Unlock() - currentProgress.BytesTotal = total -} - -// SetBytesReceived sets bytes received so far -func SetBytesReceived(received int64) { - progressMu.Lock() - defer progressMu.Unlock() - currentProgress.BytesReceived = received - if currentProgress.BytesTotal > 0 { - currentProgress.Progress = float64(received) / float64(currentProgress.BytesTotal) * 100 - } -} - -// ProgressWriter wraps io.Writer to track download progress (legacy single) -type ProgressWriter struct { - writer interface{ Write([]byte) (int, error) } - total int64 - current int64 -} - -// NewProgressWriter creates a new progress writer wrapping an io.Writer -func NewProgressWriter(w interface{ Write([]byte) (int, error) }) *ProgressWriter { - SetBytesReceived(0) - return &ProgressWriter{ - writer: w, - current: 0, - total: 0, - } -} - -// Write implements io.Writer -func (pw *ProgressWriter) Write(p []byte) (int, error) { - n, err := pw.writer.Write(p) - if err != nil { - return n, err - } - pw.current += int64(n) - pw.total += int64(n) - SetBytesReceived(pw.current) - return n, nil -} - -// GetTotal returns total bytes written -func (pw *ProgressWriter) GetTotal() int64 { - return pw.total -} - // ItemProgressWriter wraps io.Writer to track download progress for a specific item type ItemProgressWriter struct { writer interface{ Write([]byte) (int, error) } @@ -311,7 +218,5 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) { } pw.current += int64(n) SetItemBytesReceived(pw.itemID, pw.current) - // Also update legacy progress for backward compatibility - SetBytesReceived(pw.current) return n, nil } diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 338c1951..97c15511 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -262,12 +262,7 @@ func (q *QobuzDownloader) GetDownloadURL(trackID int64, quality string) (string, // DownloadFile downloads a file from URL with User-Agent and progress tracking func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error { - // Set current file being downloaded (legacy) - SetCurrentFile(filepath.Base(outputPath)) - SetDownloading(true) - defer SetDownloading(false) - - // Initialize item progress if itemID provided + // Initialize item progress (required for all downloads) if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) @@ -289,11 +284,8 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e } // Set total bytes if available - if resp.ContentLength > 0 { - SetBytesTotal(resp.ContentLength) - if itemID != "" { - SetItemBytesTotal(itemID, resp.ContentLength) - } + if resp.ContentLength > 0 && itemID != "" { + SetItemBytesTotal(itemID, resp.ContentLength) } out, err := os.Create(outputPath) @@ -302,13 +294,13 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e } defer out.Close() - // Use appropriate progress writer + // Use item progress writer if itemID != "" { progressWriter := NewItemProgressWriter(out, itemID) _, err = io.Copy(progressWriter, resp.Body) } else { - progressWriter := NewProgressWriter(out) - _, err = io.Copy(progressWriter, resp.Body) + // Fallback: direct copy without progress tracking + _, err = io.Copy(out, resp.Body) } return err } diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 1ad3728f..664c0105 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -646,12 +646,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID) } - // Set current file being downloaded (legacy) - SetCurrentFile(filepath.Base(outputPath)) - SetDownloading(true) - defer SetDownloading(false) - - // Initialize item progress if itemID provided + // Initialize item progress (required for all downloads) if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) @@ -673,11 +668,8 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e } // Set total bytes if available - if resp.ContentLength > 0 { - SetBytesTotal(resp.ContentLength) - if itemID != "" { - SetItemBytesTotal(itemID, resp.ContentLength) - } + if resp.ContentLength > 0 && itemID != "" { + SetItemBytesTotal(itemID, resp.ContentLength) } out, err := os.Create(outputPath) @@ -686,13 +678,13 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e } defer out.Close() - // Use appropriate progress writer + // Use item progress writer if itemID != "" { progressWriter := NewItemProgressWriter(out, itemID) _, err = io.Copy(progressWriter, resp.Body) } else { - progressWriter := NewProgressWriter(out) - _, err = io.Copy(progressWriter, resp.Body) + // Fallback: direct copy without progress tracking + _, err = io.Copy(out, resp.Body) } return err } @@ -709,12 +701,7 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s // If we have a direct URL (BTS format), download directly with progress tracking if directURL != "" { - // Set current file being downloaded (legacy) - SetCurrentFile(filepath.Base(outputPath)) - SetDownloading(true) - defer SetDownloading(false) - - // Initialize item progress if itemID provided + // Initialize item progress (required for all downloads) if itemID != "" { StartItemProgress(itemID) defer CompleteItemProgress(itemID) @@ -736,11 +723,8 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s } // Set total bytes for progress tracking - if resp.ContentLength > 0 { - SetBytesTotal(resp.ContentLength) - if itemID != "" { - SetItemBytesTotal(itemID, resp.ContentLength) - } + if resp.ContentLength > 0 && itemID != "" { + SetItemBytesTotal(itemID, resp.ContentLength) } out, err := os.Create(outputPath) @@ -749,13 +733,13 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s } defer out.Close() - // Use appropriate progress writer + // Use item progress writer if itemID != "" { progressWriter := NewItemProgressWriter(out, itemID) _, err = io.Copy(progressWriter, resp.Body) } else { - progressWriter := NewProgressWriter(out) - _, err = io.Copy(progressWriter, resp.Body) + // Fallback: direct copy without progress tracking + _, err = io.Copy(out, resp.Body) } return err } diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index f1f82455..95aabbe6 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '2.0.0'; - static const String buildNumber = '30'; + static const String version = '2.0.1'; + static const String buildNumber = '31'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 0509ae1c..a92d7eb6 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -267,6 +267,8 @@ class DownloadQueueNotifier extends Notifier { static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence final NotificationService _notificationService = NotificationService(); int _totalQueuedAtStart = 0; // Track total items when queue started + int _completedInSession = 0; // Track completed downloads in current session + int _failedInSession = 0; // Track failed downloads in current session bool _isLoaded = false; @override @@ -354,69 +356,7 @@ class DownloadQueueNotifier extends Notifier { } } - void _startProgressPolling(String itemId) { - _progressTimer?.cancel(); - _progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async { - try { - final progress = await PlatformBridge.getDownloadProgress(); - final bytesReceived = progress['bytes_received'] as int? ?? 0; - final bytesTotal = progress['bytes_total'] as int? ?? 0; - final isDownloading = progress['is_downloading'] as bool? ?? false; - final status = progress['status'] as String? ?? 'downloading'; - - // Check if status is "finalizing" (embedding metadata) - if (status == 'finalizing') { - updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0); - - // Update notification to show finalizing - final currentItem = state.items.where((i) => i.id == itemId).firstOrNull; - if (currentItem != null) { - _notificationService.showDownloadFinalizing( - trackName: currentItem.track.name, - artistName: currentItem.track.artistName, - ); - } - return; - } - - if (isDownloading && bytesTotal > 0) { - final percentage = bytesReceived / bytesTotal; - updateProgress(itemId, percentage); - - // Update notification with progress - final currentItem = state.currentDownload; - if (currentItem != null) { - _notificationService.showDownloadProgress( - trackName: currentItem.track.name, - artistName: currentItem.track.artistName, - progress: bytesReceived, - total: bytesTotal, - ); - - // Update foreground service notification (Android) - if (Platform.isAndroid) { - PlatformBridge.updateDownloadServiceProgress( - trackName: currentItem.track.name, - artistName: currentItem.track.artistName, - progress: bytesReceived, - total: bytesTotal, - queueCount: state.queuedCount, - ).catchError((_) {}); // Ignore errors - } - } - - // Log progress - final mbReceived = bytesReceived / (1024 * 1024); - final mbTotal = bytesTotal / (1024 * 1024); - _log.d('Progress: ${(percentage * 100).toStringAsFixed(1)}% (${mbReceived.toStringAsFixed(2)}/${mbTotal.toStringAsFixed(2)} MB)'); - } - } catch (e) { - // Ignore polling errors - } - }); - } - - /// Start multi-progress polling for concurrent downloads + /// Start multi-progress polling for all downloads (sequential and parallel) void _startMultiProgressPolling() { _progressTimer?.cancel(); _progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async { @@ -424,6 +364,10 @@ class DownloadQueueNotifier extends Notifier { final allProgress = await PlatformBridge.getAllDownloadProgress(); final items = allProgress['items'] as Map? ?? {}; + bool hasFinalizingItem = false; + String? finalizingTrackName; + String? finalizingArtistName; + for (final entry in items.entries) { final itemId = entry.key; final itemProgress = entry.value as Map; @@ -433,16 +377,16 @@ class DownloadQueueNotifier extends Notifier { final status = itemProgress['status'] as String? ?? 'downloading'; // Check if status is "finalizing" (embedding metadata) - if (status == 'finalizing') { + // Only trust finalizing status if bytesTotal > 0 (download actually happened) + if (status == 'finalizing' && bytesTotal > 0) { updateItemStatus(itemId, DownloadStatus.finalizing, progress: 1.0); - // Update notification to show finalizing + // Track finalizing item for notification final currentItem = state.items.where((i) => i.id == itemId).firstOrNull; if (currentItem != null) { - _notificationService.showDownloadFinalizing( - trackName: currentItem.track.name, - artistName: currentItem.track.artistName, - ); + hasFinalizingItem = true; + finalizingTrackName = currentItem.track.name; + finalizingArtistName = currentItem.track.artistName; } continue; } @@ -458,19 +402,36 @@ class DownloadQueueNotifier extends Notifier { } } - // Update notification with first active download + // Show finalizing notification if any item is finalizing (takes priority) + if (hasFinalizingItem && finalizingTrackName != null) { + _notificationService.showDownloadFinalizing( + trackName: finalizingTrackName, + artistName: finalizingArtistName ?? '', + ); + return; // Don't show download progress notification + } + + // Update notification with active downloads if (items.isNotEmpty) { final firstEntry = items.entries.first; final firstProgress = firstEntry.value as Map; final bytesReceived = firstProgress['bytes_received'] as int? ?? 0; final bytesTotal = firstProgress['bytes_total'] as int? ?? 0; - // Find the item to get track info - final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading || i.status == DownloadStatus.finalizing).toList(); + // Find downloading items (not finalizing) + final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList(); if (downloadingItems.isNotEmpty) { + // Show single track name if only 1 download, otherwise show count + final trackName = downloadingItems.length == 1 + ? downloadingItems.first.track.name + : '${downloadingItems.length} downloads'; + final artistName = downloadingItems.length == 1 + ? downloadingItems.first.track.artistName + : 'Downloading...'; + _notificationService.showDownloadProgress( - trackName: '${downloadingItems.length} downloads', - artistName: 'Downloading...', + trackName: trackName, + artistName: artistName, progress: bytesReceived, total: bytesTotal > 0 ? bytesTotal : 1, ); @@ -823,6 +784,8 @@ class DownloadQueueNotifier extends Notifier { // Track total items at start for notification _totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length; + _completedInSession = 0; + _failedInSession = 0; // Start foreground service to keep downloads running in background (Android only) if (Platform.isAndroid && _totalQueuedAtStart > 0) { @@ -893,12 +856,11 @@ class DownloadQueueNotifier extends Notifier { } // Show queue completion notification - final completedCount = state.completedCount; - final failedCount = state.failedCount; + _log.i('Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart'); if (_totalQueuedAtStart > 0) { await _notificationService.showQueueComplete( - completedCount: completedCount, - failedCount: failedCount, + completedCount: _completedInSession, + failedCount: _failedInSession, ); } @@ -906,8 +868,11 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(isProcessing: false, currentDownload: null); } - /// Sequential download processing (original behavior) + /// Sequential download processing (uses multi-progress system with single item) Future _processQueueSequential() async { + // Start multi-progress polling (works for both sequential and parallel) + _startMultiProgressPolling(); + while (true) { // Check if paused if (state.isPaused) { @@ -932,7 +897,13 @@ class DownloadQueueNotifier extends Notifier { } await _downloadSingleItem(nextItem); + + // Clear item progress after download completes + PlatformBridge.clearItemProgress(nextItem.id).catchError((_) {}); } + + // Stop polling when queue is done + _stopProgressPolling(); } /// Parallel download processing with worker pool @@ -940,7 +911,7 @@ class DownloadQueueNotifier extends Notifier { final maxConcurrent = state.concurrentDownloads; final activeDownloads = >{}; // Map item ID to future - // Start multi-progress polling for concurrent downloads + // Start multi-progress polling (shared with sequential mode) _startMultiProgressPolling(); while (true) { @@ -991,6 +962,9 @@ class DownloadQueueNotifier extends Notifier { if (activeDownloads.isNotEmpty) { await Future.wait(activeDownloads.values); } + + // Stop polling when queue is done + _stopProgressPolling(); } /// Download a single item (used by both sequential and parallel processing) @@ -998,11 +972,8 @@ class DownloadQueueNotifier extends Notifier { _log.d('Processing: ${item.track.name} by ${item.track.artistName}'); _log.d('Cover URL: ${item.track.coverUrl}'); - // Only set currentDownload for sequential mode (for progress polling) - if (state.concurrentDownloads == 1) { - state = state.copyWith(currentDownload: item); - _startProgressPolling(item.id); - } + // Set currentDownload for UI reference + state = state.copyWith(currentDownload: item); updateItemStatus(item.id, DownloadStatus.downloading); @@ -1058,11 +1029,6 @@ class DownloadQueueNotifier extends Notifier { convertLyricsToRomaji: settings.convertLyricsToRomaji, ); } - - // Stop progress polling for this item (sequential mode only) - if (state.concurrentDownloads == 1) { - _stopProgressPolling(); - } _log.d('Result: $result'); @@ -1099,12 +1065,15 @@ class DownloadQueueNotifier extends Notifier { progress: 1.0, filePath: filePath, ); + + // Increment completed counter + _completedInSession++; // Show completion notification for this track await _notificationService.showDownloadComplete( trackName: item.track.name, artistName: item.track.artistName, - completedCount: state.completedCount, + completedCount: _completedInSession, totalCount: _totalQueuedAtStart, ); @@ -1142,6 +1111,7 @@ class DownloadQueueNotifier extends Notifier { DownloadStatus.failed, error: errorMsg, ); + _failedInSession++; } // Increment download counter and cleanup connections periodically @@ -1155,15 +1125,13 @@ class DownloadQueueNotifier extends Notifier { } } } catch (e, stackTrace) { - if (state.concurrentDownloads == 1) { - _stopProgressPolling(); - } _log.e('Exception: $e', e, stackTrace); updateItemStatus( item.id, DownloadStatus.failed, error: e.toString(), ); + _failedInSession++; } } } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index d6e0ebb3..1eb2024a 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -351,7 +351,7 @@ class _AlbumScreenState extends ConsumerState { ), _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }), _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }), - _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.hd, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }), + _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }), const SizedBox(height: 16), ], ), diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index f45caefd..ac30d485 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -225,16 +225,19 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient _QualityPickerOption( title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', + icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }, ), _QualityPickerOption( title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', + icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }, ), _QualityPickerOption( title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', + icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }, ), const SizedBox(height: 16), @@ -669,16 +672,17 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient class _QualityPickerOption extends StatelessWidget { final String title; final String subtitle; + final IconData icon; final VoidCallback onTap; - const _QualityPickerOption({required this.title, required this.subtitle, required this.onTap}); + const _QualityPickerOption({required this.title, required this.subtitle, required this.icon, required this.onTap}); @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Icon(Icons.music_note, color: colorScheme.primary), - title: Text(title), + leading: Container(padding: const EdgeInsets.all(10), decoration: BoxDecoration(color: colorScheme.primaryContainer, borderRadius: BorderRadius.circular(12)), child: Icon(icon, color: colorScheme.onPrimaryContainer, size: 20)), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)), onTap: onTap, ); diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index cbbfb3ba..2d42642d 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -213,7 +213,7 @@ class PlaylistScreen extends ConsumerWidget { Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))), _QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }), _QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES'); }), - _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.hd, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }), + _QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }), const SizedBox(height: 16), ], ), diff --git a/pubspec.yaml b/pubspec.yaml index c4611ba1..182d7fa0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 2.0.0+30 +version: 2.0.1+31 environment: sdk: ^3.10.0 diff --git a/temp_changelog.txt b/temp_changelog.txt deleted file mode 100644 index 1d377d42..00000000 --- a/temp_changelog.txt +++ /dev/null @@ -1,38 +0,0 @@ -# Changelog - -## [1.1.0] - 2026-01-01 - -### Added -- **Parallel Downloads**: Download up to 3 tracks simultaneously (configurable in Settings) - - Default: Sequential (1 at a time) for stability - - Options: 1, 2, or 3 concurrent downloads - - Warning about potential rate limiting from streaming services -- **Download Progress Tracking**: Real-time progress for BTS manifest downloads from Tidal -- **History Persistence**: Download history now persists across app restarts using SharedPreferences -- **Connection Pooling**: Shared HTTP transport to prevent TCP connection exhaustion during large batch downloads -- **Connection Cleanup**: Automatic cleanup of idle connections every 50 downloads and at queue end -- **GitHub & Credits Section**: Added links to SpotiFLAC Mobile and original SpotiFLAC desktop in Settings - -### Fixed -- **Download Progress Bug**: Fixed 0% → 100% jump by adding proper progress tracking for BTS format downloads -- **TCP Connection Exhaustion**: Fixed slow downloads after ~300 tracks by implementing connection pooling and periodic cleanup -- **Trailing Space in Names**: Fixed download failures when playlist/album/track names have trailing spaces -- **History Loss on Debug**: History no longer disappears when sideloading via `flutter run --debug` - -### Changed -- Updated version to 1.1.0 - -### Technical Details -- Added `concurrentDownloads` field to `AppSettings` model (default: 1, max: 3) -- Implemented worker pool pattern in `DownloadQueueNotifier` for parallel processing -- Added `SetCurrentFile()`, `SetBytesTotal()`, and `ProgressWriter` for BTS downloads in Go backend -- Added `strings.TrimSpace()` to all string fields in `DownloadTrack()` and `DownloadWithFallback()` -- Added shared `http.Transport` with connection pooling in `httputil.go` -- Added `CleanupConnections()` export for Flutter to call via method channel - -## [1.0.5] - Previous Release -- Material Expressive 3 UI -- Dynamic color support -- Swipe navigation with PageView -- Settings as bottom navigation tab -- APK size optimization