mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-07-03 11:18:04 +02:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5c6bf02f1c |
+24
-10
@@ -1,5 +1,29 @@
|
|||||||
# Changelog
|
# 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
|
## [2.0.0] - 2026-01-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -48,16 +72,6 @@
|
|||||||
- Theme/view mode chips have visible borders in light mode
|
- Theme/view mode chips have visible borders in light mode
|
||||||
- **Navigation Bar Styling**: Distinct background color from content area
|
- **Navigation Bar Styling**: Distinct background color from content area
|
||||||
- **Ask Before Download Default**: Now enabled by default for better UX
|
- **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
|
### Fixed
|
||||||
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
|
- **Artist Profile Images**: Fixed artist images not showing in search results (field name mismatch)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 130 KiB |
+6
-14
@@ -203,12 +203,7 @@ func (a *AmazonDownloader) downloadFromDoubleDoubleService(amazonURL, outputDir
|
|||||||
|
|
||||||
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Set current file being downloaded (legacy)
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
|
||||||
SetDownloading(true)
|
|
||||||
defer SetDownloading(false)
|
|
||||||
|
|
||||||
// Initialize item progress if itemID provided
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -232,11 +227,8 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
if itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -245,14 +237,14 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use appropriate progress writer
|
// Use item progress writer
|
||||||
var bytesWritten int64
|
var bytesWritten int64
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
pw := NewItemProgressWriter(out, itemID)
|
pw := NewItemProgressWriter(out, itemID)
|
||||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
bytesWritten, err = io.Copy(pw, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
pw := NewProgressWriter(out)
|
// Fallback: direct copy without progress tracking
|
||||||
bytesWritten, err = io.Copy(pw, resp.Body)
|
bytesWritten, err = io.Copy(out, resp.Body)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to write file: %w", err)
|
return fmt.Errorf("failed to write file: %w", err)
|
||||||
|
|||||||
+38
-133
@@ -5,7 +5,8 @@ import (
|
|||||||
"sync"
|
"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 {
|
type DownloadProgress struct {
|
||||||
CurrentFile string `json:"current_file"`
|
CurrentFile string `json:"current_file"`
|
||||||
Progress float64 `json:"progress"`
|
Progress float64 `json:"progress"`
|
||||||
@@ -32,28 +33,40 @@ type MultiProgress struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
currentProgress DownloadProgress
|
downloadDir string
|
||||||
progressMu sync.RWMutex
|
downloadDirMu sync.RWMutex
|
||||||
downloadDir string
|
|
||||||
downloadDirMu sync.RWMutex
|
// Multi-download progress tracking (unified system)
|
||||||
|
|
||||||
// Multi-download progress tracking
|
|
||||||
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
multiProgress = MultiProgress{Items: make(map[string]*ItemProgress)}
|
||||||
multiMu sync.RWMutex
|
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 {
|
func getProgress() DownloadProgress {
|
||||||
progressMu.RLock()
|
multiMu.RLock()
|
||||||
defer progressMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
return currentProgress
|
|
||||||
|
// 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
|
// GetMultiProgress returns progress for all active downloads as JSON
|
||||||
func GetMultiProgress() string {
|
func GetMultiProgress() string {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
|
|
||||||
jsonBytes, err := json.Marshal(multiProgress)
|
jsonBytes, err := json.Marshal(multiProgress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "{\"items\":{}}"
|
return "{\"items\":{}}"
|
||||||
@@ -65,7 +78,7 @@ func GetMultiProgress() string {
|
|||||||
func GetItemProgress(itemID string) string {
|
func GetItemProgress(itemID string) string {
|
||||||
multiMu.RLock()
|
multiMu.RLock()
|
||||||
defer multiMu.RUnlock()
|
defer multiMu.RUnlock()
|
||||||
|
|
||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
jsonBytes, _ := json.Marshal(item)
|
jsonBytes, _ := json.Marshal(item)
|
||||||
return string(jsonBytes)
|
return string(jsonBytes)
|
||||||
@@ -77,7 +90,7 @@ func GetItemProgress(itemID string) string {
|
|||||||
func StartItemProgress(itemID string) {
|
func StartItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
multiProgress.Items[itemID] = &ItemProgress{
|
multiProgress.Items[itemID] = &ItemProgress{
|
||||||
ItemID: itemID,
|
ItemID: itemID,
|
||||||
BytesTotal: 0,
|
BytesTotal: 0,
|
||||||
@@ -92,7 +105,7 @@ func StartItemProgress(itemID string) {
|
|||||||
func SetItemBytesTotal(itemID string, total int64) {
|
func SetItemBytesTotal(itemID string, total int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.BytesTotal = total
|
item.BytesTotal = total
|
||||||
}
|
}
|
||||||
@@ -102,7 +115,7 @@ func SetItemBytesTotal(itemID string, total int64) {
|
|||||||
func SetItemBytesReceived(itemID string, received int64) {
|
func SetItemBytesReceived(itemID string, received int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.BytesReceived = received
|
item.BytesReceived = received
|
||||||
if item.BytesTotal > 0 {
|
if item.BytesTotal > 0 {
|
||||||
@@ -115,16 +128,19 @@ func SetItemBytesReceived(itemID string, received int64) {
|
|||||||
func CompleteItemProgress(itemID string) {
|
func CompleteItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.IsDownloading = false
|
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) {
|
func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal int64) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.Progress = progress
|
item.Progress = progress
|
||||||
if bytesReceived > 0 {
|
if bytesReceived > 0 {
|
||||||
@@ -134,39 +150,24 @@ func SetItemProgress(itemID string, progress float64, bytesReceived, bytesTotal
|
|||||||
item.BytesTotal = 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)
|
// SetItemFinalizing marks an item as finalizing (embedding metadata)
|
||||||
func SetItemFinalizing(itemID string) {
|
func SetItemFinalizing(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
if item, ok := multiProgress.Items[itemID]; ok {
|
if item, ok := multiProgress.Items[itemID]; ok {
|
||||||
item.Progress = 1.0
|
item.Progress = 1.0
|
||||||
item.Status = "finalizing"
|
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
|
// RemoveItemProgress removes progress tracking for an item
|
||||||
func RemoveItemProgress(itemID string) {
|
func RemoveItemProgress(itemID string) {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
delete(multiProgress.Items, itemID)
|
delete(multiProgress.Items, itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,46 +175,10 @@ func RemoveItemProgress(itemID string) {
|
|||||||
func ClearAllItemProgress() {
|
func ClearAllItemProgress() {
|
||||||
multiMu.Lock()
|
multiMu.Lock()
|
||||||
defer multiMu.Unlock()
|
defer multiMu.Unlock()
|
||||||
|
|
||||||
multiProgress.Items = make(map[string]*ItemProgress)
|
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
|
// setDownloadDir sets the default download directory
|
||||||
func setDownloadDir(path string) error {
|
func setDownloadDir(path string) error {
|
||||||
downloadDirMu.Lock()
|
downloadDirMu.Lock()
|
||||||
@@ -229,64 +194,6 @@ func getDownloadDir() string {
|
|||||||
return downloadDir
|
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
|
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
|
||||||
type ItemProgressWriter struct {
|
type ItemProgressWriter struct {
|
||||||
writer interface{ Write([]byte) (int, error) }
|
writer interface{ Write([]byte) (int, error) }
|
||||||
@@ -311,7 +218,5 @@ func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
pw.current += int64(n)
|
pw.current += int64(n)
|
||||||
SetItemBytesReceived(pw.itemID, pw.current)
|
SetItemBytesReceived(pw.itemID, pw.current)
|
||||||
// Also update legacy progress for backward compatibility
|
|
||||||
SetBytesReceived(pw.current)
|
|
||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-14
@@ -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
|
// DownloadFile downloads a file from URL with User-Agent and progress tracking
|
||||||
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) error {
|
||||||
// Set current file being downloaded (legacy)
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
|
||||||
SetDownloading(true)
|
|
||||||
defer SetDownloading(false)
|
|
||||||
|
|
||||||
// Initialize item progress if itemID provided
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -289,11 +284,8 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
if itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -302,13 +294,13 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use appropriate progress writer
|
// Use item progress writer
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
progressWriter := NewProgressWriter(out)
|
// Fallback: direct copy without progress tracking
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-28
@@ -646,12 +646,7 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
return t.downloadFromManifest(strings.TrimPrefix(downloadURL, "MANIFEST:"), outputPath, itemID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set current file being downloaded (legacy)
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
|
||||||
SetDownloading(true)
|
|
||||||
defer SetDownloading(false)
|
|
||||||
|
|
||||||
// Initialize item progress if itemID provided
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -673,11 +668,8 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes if available
|
// Set total bytes if available
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
if itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -686,13 +678,13 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use appropriate progress writer
|
// Use item progress writer
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
progressWriter := NewProgressWriter(out)
|
// Fallback: direct copy without progress tracking
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
}
|
}
|
||||||
return err
|
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 we have a direct URL (BTS format), download directly with progress tracking
|
||||||
if directURL != "" {
|
if directURL != "" {
|
||||||
// Set current file being downloaded (legacy)
|
// Initialize item progress (required for all downloads)
|
||||||
SetCurrentFile(filepath.Base(outputPath))
|
|
||||||
SetDownloading(true)
|
|
||||||
defer SetDownloading(false)
|
|
||||||
|
|
||||||
// Initialize item progress if itemID provided
|
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
StartItemProgress(itemID)
|
StartItemProgress(itemID)
|
||||||
defer CompleteItemProgress(itemID)
|
defer CompleteItemProgress(itemID)
|
||||||
@@ -736,11 +723,8 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set total bytes for progress tracking
|
// Set total bytes for progress tracking
|
||||||
if resp.ContentLength > 0 {
|
if resp.ContentLength > 0 && itemID != "" {
|
||||||
SetBytesTotal(resp.ContentLength)
|
SetItemBytesTotal(itemID, resp.ContentLength)
|
||||||
if itemID != "" {
|
|
||||||
SetItemBytesTotal(itemID, resp.ContentLength)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
out, err := os.Create(outputPath)
|
out, err := os.Create(outputPath)
|
||||||
@@ -749,13 +733,13 @@ func (t *TidalDownloader) downloadFromManifest(manifestB64, outputPath, itemID s
|
|||||||
}
|
}
|
||||||
defer out.Close()
|
defer out.Close()
|
||||||
|
|
||||||
// Use appropriate progress writer
|
// Use item progress writer
|
||||||
if itemID != "" {
|
if itemID != "" {
|
||||||
progressWriter := NewItemProgressWriter(out, itemID)
|
progressWriter := NewItemProgressWriter(out, itemID)
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(progressWriter, resp.Body)
|
||||||
} else {
|
} else {
|
||||||
progressWriter := NewProgressWriter(out)
|
// Fallback: direct copy without progress tracking
|
||||||
_, err = io.Copy(progressWriter, resp.Body)
|
_, err = io.Copy(out, resp.Body)
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/// App version and info constants
|
/// App version and info constants
|
||||||
/// Update version here only - all other files will reference this
|
/// Update version here only - all other files will reference this
|
||||||
class AppInfo {
|
class AppInfo {
|
||||||
static const String version = '2.0.0';
|
static const String version = '2.0.1';
|
||||||
static const String buildNumber = '30';
|
static const String buildNumber = '31';
|
||||||
static const String fullVersion = '$version+$buildNumber';
|
static const String fullVersion = '$version+$buildNumber';
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -267,6 +267,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence
|
static const _queueStorageKey = 'download_queue'; // Storage key for queue persistence
|
||||||
final NotificationService _notificationService = NotificationService();
|
final NotificationService _notificationService = NotificationService();
|
||||||
int _totalQueuedAtStart = 0; // Track total items when queue started
|
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;
|
bool _isLoaded = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -354,69 +356,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startProgressPolling(String itemId) {
|
/// Start multi-progress polling for all downloads (sequential and parallel)
|
||||||
_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
|
|
||||||
void _startMultiProgressPolling() {
|
void _startMultiProgressPolling() {
|
||||||
_progressTimer?.cancel();
|
_progressTimer?.cancel();
|
||||||
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
_progressTimer = Timer.periodic(const Duration(milliseconds: 500), (timer) async {
|
||||||
@@ -424,6 +364,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
final allProgress = await PlatformBridge.getAllDownloadProgress();
|
||||||
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
final items = allProgress['items'] as Map<String, dynamic>? ?? {};
|
||||||
|
|
||||||
|
bool hasFinalizingItem = false;
|
||||||
|
String? finalizingTrackName;
|
||||||
|
String? finalizingArtistName;
|
||||||
|
|
||||||
for (final entry in items.entries) {
|
for (final entry in items.entries) {
|
||||||
final itemId = entry.key;
|
final itemId = entry.key;
|
||||||
final itemProgress = entry.value as Map<String, dynamic>;
|
final itemProgress = entry.value as Map<String, dynamic>;
|
||||||
@@ -433,16 +377,16 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final status = itemProgress['status'] as String? ?? 'downloading';
|
final status = itemProgress['status'] as String? ?? 'downloading';
|
||||||
|
|
||||||
// Check if status is "finalizing" (embedding metadata)
|
// 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);
|
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;
|
final currentItem = state.items.where((i) => i.id == itemId).firstOrNull;
|
||||||
if (currentItem != null) {
|
if (currentItem != null) {
|
||||||
_notificationService.showDownloadFinalizing(
|
hasFinalizingItem = true;
|
||||||
trackName: currentItem.track.name,
|
finalizingTrackName = currentItem.track.name;
|
||||||
artistName: currentItem.track.artistName,
|
finalizingArtistName = currentItem.track.artistName;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -458,19 +402,36 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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) {
|
if (items.isNotEmpty) {
|
||||||
final firstEntry = items.entries.first;
|
final firstEntry = items.entries.first;
|
||||||
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
final firstProgress = firstEntry.value as Map<String, dynamic>;
|
||||||
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
final bytesReceived = firstProgress['bytes_received'] as int? ?? 0;
|
||||||
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
final bytesTotal = firstProgress['bytes_total'] as int? ?? 0;
|
||||||
|
|
||||||
// Find the item to get track info
|
// Find downloading items (not finalizing)
|
||||||
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading || i.status == DownloadStatus.finalizing).toList();
|
final downloadingItems = state.items.where((i) => i.status == DownloadStatus.downloading).toList();
|
||||||
if (downloadingItems.isNotEmpty) {
|
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(
|
_notificationService.showDownloadProgress(
|
||||||
trackName: '${downloadingItems.length} downloads',
|
trackName: trackName,
|
||||||
artistName: 'Downloading...',
|
artistName: artistName,
|
||||||
progress: bytesReceived,
|
progress: bytesReceived,
|
||||||
total: bytesTotal > 0 ? bytesTotal : 1,
|
total: bytesTotal > 0 ? bytesTotal : 1,
|
||||||
);
|
);
|
||||||
@@ -823,6 +784,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
|
|
||||||
// Track total items at start for notification
|
// Track total items at start for notification
|
||||||
_totalQueuedAtStart = state.items.where((i) => i.status == DownloadStatus.queued).length;
|
_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)
|
// Start foreground service to keep downloads running in background (Android only)
|
||||||
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
|
if (Platform.isAndroid && _totalQueuedAtStart > 0) {
|
||||||
@@ -893,12 +856,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show queue completion notification
|
// Show queue completion notification
|
||||||
final completedCount = state.completedCount;
|
_log.i('Queue stats - completed: $_completedInSession, failed: $_failedInSession, totalAtStart: $_totalQueuedAtStart');
|
||||||
final failedCount = state.failedCount;
|
|
||||||
if (_totalQueuedAtStart > 0) {
|
if (_totalQueuedAtStart > 0) {
|
||||||
await _notificationService.showQueueComplete(
|
await _notificationService.showQueueComplete(
|
||||||
completedCount: completedCount,
|
completedCount: _completedInSession,
|
||||||
failedCount: failedCount,
|
failedCount: _failedInSession,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -906,8 +868,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
state = state.copyWith(isProcessing: false, currentDownload: null);
|
state = state.copyWith(isProcessing: false, currentDownload: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sequential download processing (original behavior)
|
/// Sequential download processing (uses multi-progress system with single item)
|
||||||
Future<void> _processQueueSequential() async {
|
Future<void> _processQueueSequential() async {
|
||||||
|
// Start multi-progress polling (works for both sequential and parallel)
|
||||||
|
_startMultiProgressPolling();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// Check if paused
|
// Check if paused
|
||||||
if (state.isPaused) {
|
if (state.isPaused) {
|
||||||
@@ -932,7 +897,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await _downloadSingleItem(nextItem);
|
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
|
/// Parallel download processing with worker pool
|
||||||
@@ -940,7 +911,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
final maxConcurrent = state.concurrentDownloads;
|
final maxConcurrent = state.concurrentDownloads;
|
||||||
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
final activeDownloads = <String, Future<void>>{}; // Map item ID to future
|
||||||
|
|
||||||
// Start multi-progress polling for concurrent downloads
|
// Start multi-progress polling (shared with sequential mode)
|
||||||
_startMultiProgressPolling();
|
_startMultiProgressPolling();
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -991,6 +962,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
if (activeDownloads.isNotEmpty) {
|
if (activeDownloads.isNotEmpty) {
|
||||||
await Future.wait(activeDownloads.values);
|
await Future.wait(activeDownloads.values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop polling when queue is done
|
||||||
|
_stopProgressPolling();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a single item (used by both sequential and parallel processing)
|
/// Download a single item (used by both sequential and parallel processing)
|
||||||
@@ -998,11 +972,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
|
_log.d('Processing: ${item.track.name} by ${item.track.artistName}');
|
||||||
_log.d('Cover URL: ${item.track.coverUrl}');
|
_log.d('Cover URL: ${item.track.coverUrl}');
|
||||||
|
|
||||||
// Only set currentDownload for sequential mode (for progress polling)
|
// Set currentDownload for UI reference
|
||||||
if (state.concurrentDownloads == 1) {
|
state = state.copyWith(currentDownload: item);
|
||||||
state = state.copyWith(currentDownload: item);
|
|
||||||
_startProgressPolling(item.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateItemStatus(item.id, DownloadStatus.downloading);
|
updateItemStatus(item.id, DownloadStatus.downloading);
|
||||||
|
|
||||||
@@ -1058,11 +1029,6 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
convertLyricsToRomaji: settings.convertLyricsToRomaji,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop progress polling for this item (sequential mode only)
|
|
||||||
if (state.concurrentDownloads == 1) {
|
|
||||||
_stopProgressPolling();
|
|
||||||
}
|
|
||||||
|
|
||||||
_log.d('Result: $result');
|
_log.d('Result: $result');
|
||||||
|
|
||||||
@@ -1099,12 +1065,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
progress: 1.0,
|
progress: 1.0,
|
||||||
filePath: filePath,
|
filePath: filePath,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Increment completed counter
|
||||||
|
_completedInSession++;
|
||||||
|
|
||||||
// Show completion notification for this track
|
// Show completion notification for this track
|
||||||
await _notificationService.showDownloadComplete(
|
await _notificationService.showDownloadComplete(
|
||||||
trackName: item.track.name,
|
trackName: item.track.name,
|
||||||
artistName: item.track.artistName,
|
artistName: item.track.artistName,
|
||||||
completedCount: state.completedCount,
|
completedCount: _completedInSession,
|
||||||
totalCount: _totalQueuedAtStart,
|
totalCount: _totalQueuedAtStart,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1142,6 +1111,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: errorMsg,
|
error: errorMsg,
|
||||||
);
|
);
|
||||||
|
_failedInSession++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment download counter and cleanup connections periodically
|
// Increment download counter and cleanup connections periodically
|
||||||
@@ -1155,15 +1125,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
if (state.concurrentDownloads == 1) {
|
|
||||||
_stopProgressPolling();
|
|
||||||
}
|
|
||||||
_log.e('Exception: $e', e, stackTrace);
|
_log.e('Exception: $e', e, stackTrace);
|
||||||
updateItemStatus(
|
updateItemStatus(
|
||||||
item.id,
|
item.id,
|
||||||
DownloadStatus.failed,
|
DownloadStatus.failed,
|
||||||
error: e.toString(),
|
error: e.toString(),
|
||||||
);
|
);
|
||||||
|
_failedInSession++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -351,7 +351,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
|
|||||||
),
|
),
|
||||||
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); }),
|
_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', 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),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -225,16 +225,19 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
_QualityPickerOption(
|
_QualityPickerOption(
|
||||||
title: 'FLAC Lossless',
|
title: 'FLAC Lossless',
|
||||||
subtitle: '16-bit / 44.1kHz',
|
subtitle: '16-bit / 44.1kHz',
|
||||||
|
icon: Icons.music_note,
|
||||||
onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); },
|
onTap: () { Navigator.pop(context); onSelect('LOSSLESS'); },
|
||||||
),
|
),
|
||||||
_QualityPickerOption(
|
_QualityPickerOption(
|
||||||
title: 'Hi-Res FLAC',
|
title: 'Hi-Res FLAC',
|
||||||
subtitle: '24-bit / up to 96kHz',
|
subtitle: '24-bit / up to 96kHz',
|
||||||
|
icon: Icons.high_quality,
|
||||||
onTap: () { Navigator.pop(context); onSelect('HI_RES'); },
|
onTap: () { Navigator.pop(context); onSelect('HI_RES'); },
|
||||||
),
|
),
|
||||||
_QualityPickerOption(
|
_QualityPickerOption(
|
||||||
title: 'Hi-Res FLAC Max',
|
title: 'Hi-Res FLAC Max',
|
||||||
subtitle: '24-bit / up to 192kHz',
|
subtitle: '24-bit / up to 192kHz',
|
||||||
|
icon: Icons.four_k,
|
||||||
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); },
|
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); },
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@@ -669,16 +672,17 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
|||||||
class _QualityPickerOption extends StatelessWidget {
|
class _QualityPickerOption extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
|
final IconData icon;
|
||||||
final VoidCallback onTap;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||||
leading: Icon(Icons.music_note, color: colorScheme.primary),
|
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),
|
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||||
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
subtitle: Text(subtitle, style: TextStyle(color: colorScheme.onSurfaceVariant)),
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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))),
|
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: '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', 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),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
name: spotiflac_android
|
name: spotiflac_android
|
||||||
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 2.0.0+30
|
version: 2.0.1+31
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.0
|
sdk: ^3.10.0
|
||||||
|
|||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user