Compare commits

...

4 Commits

Author SHA1 Message Date
zarzet c725e53e4c Release 2.1.0 2026-01-07 00:00:28 +07:00
zarzet 1d7c43a302 v2.1.0-preview: Download speed optimizations 2026-01-06 03:56:26 +07:00
Zarz Eleutherius df7c1c5bb7 Add VirusTotal badge to README
Added VirusTotal badge to README for safety verification.
2026-01-06 02:05:50 +07:00
zarzet bb05353b7e fix(ios): directory picker - add App Documents option for iOS
- iOS limitation: empty folders cannot be selected via document picker
- Added bottom sheet with App Documents Folder as recommended option
- Shows info message explaining iOS limitation
- Files accessible via iOS Files app

Version: 2.0.7-preview2+38
2026-01-06 00:23:19 +07:00
36 changed files with 1565 additions and 340 deletions
+1
View File
@@ -49,3 +49,4 @@ ios/Pods/
ios/.symlinks/
ios/Flutter/Flutter.framework/
ios/Flutter/Flutter.podspec
android/app/libs/gobackend-sources.jar
+119
View File
@@ -1,5 +1,124 @@
# Changelog
## [2.1.0] - 2026-01-06
### Added
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
- Service selector chips appear above quality options
- Defaults to your preferred service from settings
- Change service on-the-fly without going to settings
- Available in Home, Album, and Playlist screens
- **AMOLED Dark Theme**: Pure black background for OLED screens
- Toggle in Settings > Appearance > Theme
- Saves battery on OLED/AMOLED displays
- All surface colors adjusted for true black background
- **Update Channel Setting**: Choose between Stable and Preview release channels
- Stable: Only receive stable release notifications
- Preview: Get notified about preview/beta releases too
- Configure in Settings > Options > App
### Changed
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
- arm64 APK: 46.6 MB (previously 51 MB)
- arm32 APK: 59 MB (previously 64 MB)
- Only includes FLAC, MP3 (LAME), and AAC codecs
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
- Native MethodChannel bridge for FFmpeg operations
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
### Fixed
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
- Now properly handles retry when queue processing has finished
- Also allows retrying skipped (cancelled) downloads
- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching
- Shows "Lyrics not available" instead of loading forever
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
- iOS limitation: Empty folders cannot be selected via document picker
- Added "App Documents Folder" option as recommended default
- Files saved to app Documents folder are accessible via iOS Files app
### Performance
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
- Token caching for Tidal (eliminates redundant auth requests)
- Singleton pattern for all downloaders (HTTP connection reuse)
- ISRC search first strategy (faster than SongLink API)
- Track ID cache with 30 minute TTL for album/playlist downloads
- Pre-warm cache when viewing album/playlist
- Parallel cover art and lyrics fetching during audio download
- 64KB HTTP read/write buffers
- 256KB buffered file writer for all downloaders
- Progress updates every 64KB (reduced lock contention)
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
## [2.1.0-preview2] - 2026-01-06
### Added
- **Service Switcher in Quality Picker**: Choose download service (Tidal/Qobuz/Amazon) directly when selecting quality
- Service selector chips appear above quality options
- Defaults to your preferred service from settings
- Change service on-the-fly without going to settings
- Available in Home, Album, and Playlist screens
- **AMOLED Dark Theme**: Pure black background for OLED screens
- Toggle in Settings > Appearance > Theme
- Saves battery on OLED/AMOLED displays
- All surface colors adjusted for true black background
- **Update Channel Setting**: Choose between Stable and Preview release channels
- Stable: Only receive stable release notifications
- Preview: Get notified about preview/beta releases too
- Configure in Settings > Options > App
### Fixed
- **Retry Failed Downloads**: Fixed issue where retrying failed downloads sometimes did nothing
- Now properly handles retry when queue processing has finished
- Also allows retrying skipped (cancelled) downloads
- Added logging for better debugging
- **Lyrics Loading Timeout**: Added 20 second timeout for lyrics fetching
- Shows "Lyrics not available" instead of loading forever
- Better error messages for timeout and not found cases
## [2.1.0-preview] - 2026-01-06
### Performance
- **Download Speed Optimizations**: Significant improvements to download initialization and throughput
- Token caching for Tidal (eliminates redundant auth requests)
- Singleton pattern for all downloaders (HTTP connection reuse)
- ISRC search first strategy (faster than SongLink API)
- Track ID cache with 30 minute TTL for album/playlist downloads
- Pre-warm cache when viewing album/playlist
- Parallel cover art and lyrics fetching during audio download
- 64KB HTTP read/write buffers
- 256KB buffered file writer for all downloaders
- Progress updates every 64KB (reduced lock contention)
- **Amazon Music Optimizations**: Same optimizations now applied to Amazon downloader
### Technical
- New `go_backend/parallel.go` with `TrackIDCache`, `FetchCoverAndLyricsParallel()`, `PreWarmTrackCache()`
- Flutter: `_preWarmCacheForTracks()` in `track_provider.dart`
- New method channels: `preWarmTrackCache`, `getTrackCacheSize`, `clearTrackCache`
## [2.0.7-preview2] - 2026-01-06
### Fixed
- **iOS Directory Picker**: Fixed unable to select download folder on iOS
- iOS limitation: Empty folders cannot be selected via document picker
- Added "App Documents Folder" option as recommended default
- Shows info message explaining iOS limitation
- Files saved to app Documents folder are accessible via iOS Files app
## [2.0.7-preview] - 2026-01-05
### Changed
- **Reduced APK Size**: Replaced FFmpeg plugin with custom AAR containing only required codecs
- arm64 APK: 46.6 MB (previously 51 MB)
- arm32 APK: 59 MB (previously 64 MB)
- Only includes FLAC, MP3 (LAME), and AAC codecs
- Removed x86/x86_64 architectures (emulator only)
### Technical
- Custom FFmpeg AAR with arm64-v8a and armeabi-v7a only
- Native MethodChannel bridge for FFmpeg operations
- Separate iOS build configuration with ffmpeg_kit_flutter plugin
## [2.0.6] - 2026-01-05
### Fixed
+1
View File
@@ -1,4 +1,5 @@
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/b715b67c1a027823d622a6e578e668c855dee1c364726895a7c379f080a0b987)
<div align="center">
@@ -211,6 +211,25 @@ class MainActivity: FlutterActivity() {
}
result.success(null)
}
"preWarmTrackCache" -> {
val tracksJson = call.argument<String>("tracks") ?: "[]"
withContext(Dispatchers.IO) {
Gobackend.preWarmTrackCacheJSON(tracksJson)
}
result.success(null)
}
"getTrackCacheSize" -> {
val size = withContext(Dispatchers.IO) {
Gobackend.getTrackCacheSize()
}
result.success(size.toInt())
}
"clearTrackCache" -> {
withContext(Dispatchers.IO) {
Gobackend.clearTrackIDCache()
}
result.success(null)
}
else -> result.notImplemented()
}
} catch (e: Exception) {
+54 -35
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
@@ -10,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
)
@@ -19,6 +21,12 @@ type AmazonDownloader struct {
regions []string // us, eu regions for DoubleDouble service
}
var (
// Global Amazon downloader instance for connection reuse
globalAmazonDownloader *AmazonDownloader
amazonDownloaderOnce sync.Once
)
// DoubleDoubleSubmitResponse is the response from DoubleDouble submit endpoint
type DoubleDoubleSubmitResponse struct {
Success bool `json:"success"`
@@ -93,12 +101,15 @@ func amazonIsASCIIString(s string) bool {
return true
}
// NewAmazonDownloader creates a new Amazon downloader using DoubleDouble service
// NewAmazonDownloader creates a new Amazon downloader (returns singleton for connection reuse)
func NewAmazonDownloader() *AmazonDownloader {
return &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
regions: []string{"us", "eu"}, // Same regions as PC
}
amazonDownloaderOnce.Do(func() {
globalAmazonDownloader = &AmazonDownloader{
client: NewHTTPClientWithTimeout(120 * time.Second), // 120s timeout like PC
regions: []string{"us", "eu"}, // Same regions as PC
}
})
return globalAmazonDownloader
}
// GetAvailableAPIs returns list of available DoubleDouble regions
@@ -294,14 +305,18 @@ func (a *AmazonDownloader) DownloadFile(downloadURL, outputPath, itemID string)
}
defer out.Close()
// Use item progress writer
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
defer bufWriter.Flush()
// Use item progress writer with buffered output
var bytesWritten int64
if itemID != "" {
pw := NewItemProgressWriter(out, itemID)
pw := NewItemProgressWriter(bufWriter, itemID)
bytesWritten, err = io.Copy(pw, resp.Body)
} else {
// Fallback: direct copy without progress tracking
bytesWritten, err = io.Copy(out, resp.Body)
bytesWritten, err = io.Copy(bufWriter, resp.Body)
}
if err != nil {
return fmt.Errorf("failed to write file: %w", err)
@@ -378,11 +393,29 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
return AmazonDownloadResult{FilePath: "EXISTS:" + outputPath}, nil
}
// Download file with item ID for progress tracking
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return AmazonDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
@@ -408,41 +441,27 @@ func downloadFromAmazon(req DownloadRequest) (AmazonDownloadResult, error) {
ISRC: req.ISRC,
}
// Download cover to memory (avoids file permission issues on Android)
// Use cover data from parallel fetch
var coverData []byte
if req.CoverURL != "" {
fmt.Println("[Amazon] Downloading cover to memory...")
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
if err == nil {
coverData = data
fmt.Printf("[Amazon] Cover downloaded successfully (%d bytes)\n", len(coverData))
} else {
fmt.Printf("[Amazon] Warning: failed to download cover: %v\n", err)
}
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
fmt.Printf("[Amazon] Using parallel-fetched cover (%d bytes)\n", len(coverData))
}
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics if enabled
if req.EmbedLyrics {
fmt.Println("[Amazon] Fetching lyrics...")
lyricsClient := NewLyricsClient()
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
if lyricsErr != nil {
fmt.Printf("[Amazon] Warning: lyrics fetch error: %v\n", lyricsErr)
} else if lyrics == nil || len(lyrics.Lines) == 0 {
fmt.Println("[Amazon] No lyrics found for this track")
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
fmt.Printf("[Amazon] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Printf("[Amazon] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
lrcContent := convertToLRC(lyrics)
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
fmt.Printf("[Amazon] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Amazon] Lyrics embedded successfully")
}
fmt.Println("[Amazon] Lyrics embedded successfully")
}
} else if req.EmbedLyrics {
fmt.Println("[Amazon] No lyrics available from parallel fetch")
}
fmt.Println("[Amazon] ✓ Downloaded successfully from Amazon Music")
+50
View File
@@ -516,6 +516,56 @@ func EmbedLyricsToFile(filePath, lyrics string) (string, error) {
return string(jsonBytes), nil
}
// PreWarmTrackCacheJSON pre-warms the track ID cache for album/playlist tracks
// tracksJSON is a JSON array of objects with: isrc, track_name, artist_name, spotify_id, service
// This runs in background and returns immediately
func PreWarmTrackCacheJSON(tracksJSON string) (string, error) {
var tracks []struct {
ISRC string `json:"isrc"`
TrackName string `json:"track_name"`
ArtistName string `json:"artist_name"`
SpotifyID string `json:"spotify_id"`
Service string `json:"service"`
}
if err := json.Unmarshal([]byte(tracksJSON), &tracks); err != nil {
return errorResponse("Invalid JSON: " + err.Error())
}
// Convert to PreWarmCacheRequest
requests := make([]PreWarmCacheRequest, len(tracks))
for i, t := range tracks {
requests[i] = PreWarmCacheRequest{
ISRC: t.ISRC,
TrackName: t.TrackName,
ArtistName: t.ArtistName,
SpotifyID: t.SpotifyID,
Service: t.Service,
}
}
// Run in background
go PreWarmTrackCache(requests)
resp := map[string]interface{}{
"success": true,
"message": fmt.Sprintf("Pre-warming cache for %d tracks in background", len(tracks)),
}
jsonBytes, _ := json.Marshal(resp)
return string(jsonBytes), nil
}
// GetTrackCacheSize returns the current track ID cache size
func GetTrackCacheSize() int {
return GetCacheSize()
}
// ClearTrackIDCache clears the track ID cache
func ClearTrackIDCache() {
ClearTrackCache()
}
func errorResponse(msg string) (string, error) {
resp := DownloadResponse{
Success: false,
+4
View File
@@ -43,6 +43,7 @@ const (
)
// Shared transport with connection pooling to prevent TCP exhaustion
// Optimized for large file downloads (FLAC ~30-50MB)
var sharedTransport = &http.Transport{
DialContext: (&net.Dialer{
Timeout: 30 * time.Second,
@@ -56,6 +57,9 @@ var sharedTransport = &http.Transport{
ExpectContinueTimeout: 1 * time.Second,
DisableKeepAlives: false, // Enable keep-alives for connection reuse
ForceAttemptHTTP2: true,
WriteBufferSize: 64 * 1024, // 64KB write buffer
ReadBufferSize: 64 * 1024, // 64KB read buffer
DisableCompression: true, // FLAC is already compressed
}
// Shared HTTP client for general requests (reuses connections)
+288
View File
@@ -0,0 +1,288 @@
package gobackend
import (
"fmt"
"sync"
"time"
)
// ========================================
// ISRC to Track ID Cache
// ========================================
// TrackIDCacheEntry holds cached track ID with metadata
type TrackIDCacheEntry struct {
TidalTrackID int64
QobuzTrackID int64
AmazonTrackID string
ExpiresAt time.Time
}
// TrackIDCache caches ISRC to track ID mappings
type TrackIDCache struct {
cache map[string]*TrackIDCacheEntry
mu sync.RWMutex
ttl time.Duration
}
var (
globalTrackIDCache *TrackIDCache
trackIDCacheOnce sync.Once
)
// GetTrackIDCache returns the global track ID cache
func GetTrackIDCache() *TrackIDCache {
trackIDCacheOnce.Do(func() {
globalTrackIDCache = &TrackIDCache{
cache: make(map[string]*TrackIDCacheEntry),
ttl: 30 * time.Minute, // Cache for 30 minutes
}
})
return globalTrackIDCache
}
// Get retrieves a cached entry by ISRC
func (c *TrackIDCache) Get(isrc string) *TrackIDCacheEntry {
c.mu.RLock()
defer c.mu.RUnlock()
entry, exists := c.cache[isrc]
if !exists || time.Now().After(entry.ExpiresAt) {
return nil
}
return entry
}
// SetTidal caches Tidal track ID for an ISRC
func (c *TrackIDCache) SetTidal(isrc string, trackID int64) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.cache[isrc]
if !exists {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.TidalTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl)
}
// SetQobuz caches Qobuz track ID for an ISRC
func (c *TrackIDCache) SetQobuz(isrc string, trackID int64) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.cache[isrc]
if !exists {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.QobuzTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl)
}
// SetAmazon caches Amazon track ID for an ISRC
func (c *TrackIDCache) SetAmazon(isrc string, trackID string) {
c.mu.Lock()
defer c.mu.Unlock()
entry, exists := c.cache[isrc]
if !exists {
entry = &TrackIDCacheEntry{}
c.cache[isrc] = entry
}
entry.AmazonTrackID = trackID
entry.ExpiresAt = time.Now().Add(c.ttl)
}
// Clear removes all cached entries
func (c *TrackIDCache) Clear() {
c.mu.Lock()
defer c.mu.Unlock()
c.cache = make(map[string]*TrackIDCacheEntry)
}
// Size returns the number of cached entries
func (c *TrackIDCache) Size() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.cache)
}
// ========================================
// Parallel Download Helper
// ========================================
// ParallelDownloadResult holds results from parallel operations
type ParallelDownloadResult struct {
CoverData []byte
LyricsData *LyricsResponse
LyricsLRC string
CoverErr error
LyricsErr error
}
// FetchCoverAndLyricsParallel downloads cover and fetches lyrics in parallel
// This runs while the main audio download is happening
func FetchCoverAndLyricsParallel(
coverURL string,
maxQualityCover bool,
spotifyID string,
trackName string,
artistName string,
embedLyrics bool,
) *ParallelDownloadResult {
result := &ParallelDownloadResult{}
var wg sync.WaitGroup
// Download cover in parallel
if coverURL != "" {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("[Parallel] Starting cover download...")
data, err := downloadCoverToMemory(coverURL, maxQualityCover)
if err != nil {
result.CoverErr = err
fmt.Printf("[Parallel] Cover download failed: %v\n", err)
} else {
result.CoverData = data
fmt.Printf("[Parallel] Cover downloaded: %d bytes\n", len(data))
}
}()
}
// Fetch lyrics in parallel
if embedLyrics {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("[Parallel] Starting lyrics fetch...")
client := NewLyricsClient()
lyrics, err := client.FetchLyricsAllSources(spotifyID, trackName, artistName)
if err != nil {
result.LyricsErr = err
fmt.Printf("[Parallel] Lyrics fetch failed: %v\n", err)
} else if lyrics != nil && len(lyrics.Lines) > 0 {
result.LyricsData = lyrics
result.LyricsLRC = convertToLRC(lyrics)
fmt.Printf("[Parallel] Lyrics fetched: %d lines\n", len(lyrics.Lines))
} else {
result.LyricsErr = fmt.Errorf("no lyrics found")
fmt.Println("[Parallel] No lyrics found")
}
}()
}
wg.Wait()
return result
}
// ========================================
// Pre-warm Cache for Album/Playlist
// ========================================
// PreWarmCacheRequest represents a track to pre-warm cache for
type PreWarmCacheRequest struct {
ISRC string
TrackName string
ArtistName string
SpotifyID string // Needed for Amazon (SongLink lookup)
Service string // "tidal", "qobuz", "amazon"
}
// PreWarmTrackCache pre-fetches track IDs for multiple tracks (for album/playlist)
// This runs in background while user is viewing the track list
func PreWarmTrackCache(requests []PreWarmCacheRequest) {
if len(requests) == 0 {
return
}
fmt.Printf("[Cache] Pre-warming cache for %d tracks...\n", len(requests))
cache := GetTrackIDCache()
// Limit concurrent pre-warm requests
semaphore := make(chan struct{}, 3) // Max 3 concurrent
var wg sync.WaitGroup
for _, req := range requests {
// Skip if already cached
if cached := cache.Get(req.ISRC); cached != nil {
continue
}
wg.Add(1)
go func(r PreWarmCacheRequest) {
defer wg.Done()
semaphore <- struct{}{} // Acquire
defer func() { <-semaphore }() // Release
switch r.Service {
case "tidal":
preWarmTidalCache(r.ISRC, r.TrackName, r.ArtistName)
case "qobuz":
preWarmQobuzCache(r.ISRC)
case "amazon":
preWarmAmazonCache(r.ISRC, r.SpotifyID)
}
}(req)
}
wg.Wait()
fmt.Printf("[Cache] Pre-warm complete. Cache size: %d\n", cache.Size())
}
func preWarmTidalCache(isrc, trackName, artistName string) {
downloader := NewTidalDownloader()
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
GetTrackIDCache().SetTidal(isrc, track.ID)
fmt.Printf("[Cache] Cached Tidal ID for ISRC %s: %d\n", isrc, track.ID)
}
}
func preWarmQobuzCache(isrc string) {
downloader := NewQobuzDownloader()
track, err := downloader.SearchTrackByISRC(isrc)
if err == nil && track != nil {
GetTrackIDCache().SetQobuz(isrc, track.ID)
fmt.Printf("[Cache] Cached Qobuz ID for ISRC %s: %d\n", isrc, track.ID)
}
}
func preWarmAmazonCache(isrc, spotifyID string) {
// Amazon uses SongLink to get URL, so we pre-warm by checking availability
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
if err == nil && availability != nil && availability.Amazon {
// Store Amazon URL in cache (using ISRC as key)
GetTrackIDCache().SetAmazon(isrc, availability.AmazonURL)
fmt.Printf("[Cache] Cached Amazon URL for ISRC %s\n", isrc)
}
}
// ========================================
// Exported Functions for Flutter
// ========================================
// PreWarmCache is called from Flutter to pre-warm cache for album/playlist tracks
// tracksJSON is a JSON array of {isrc, track_name, artist_name, service}
func PreWarmCache(tracksJSON string) error {
var requests []PreWarmCacheRequest
// Parse JSON (simplified - in production use proper JSON parsing)
// For now, this is called from exports.go with proper parsing
go PreWarmTrackCache(requests) // Run in background
return nil
}
// ClearTrackCache clears the track ID cache
func ClearTrackCache() {
GetTrackIDCache().Clear()
fmt.Println("[Cache] Track ID cache cleared")
}
// GetCacheSize returns the current cache size
func GetCacheSize() int {
return GetTrackIDCache().Size()
}
+13 -2
View File
@@ -195,28 +195,39 @@ func getDownloadDir() string {
}
// ItemProgressWriter wraps io.Writer to track download progress for a specific item
// Uses buffered writing for better performance
type ItemProgressWriter struct {
writer interface{ Write([]byte) (int, error) }
itemID string
current int64
buffer []byte
bufPos int
}
const progressWriterBufferSize = 256 * 1024 // 256KB buffer for faster writes
// NewItemProgressWriter creates a new progress writer for a specific item
func NewItemProgressWriter(w interface{ Write([]byte) (int, error) }, itemID string) *ItemProgressWriter {
return &ItemProgressWriter{
writer: w,
itemID: itemID,
current: 0,
buffer: make([]byte, progressWriterBufferSize),
bufPos: 0,
}
}
// Write implements io.Writer
// Write implements io.Writer with buffering
func (pw *ItemProgressWriter) Write(p []byte) (int, error) {
n, err := pw.writer.Write(p)
if err != nil {
return n, err
}
pw.current += int64(n)
SetItemBytesReceived(pw.itemID, pw.current)
// Update progress less frequently (every 64KB) to reduce lock contention
if pw.current%(64*1024) == 0 || pw.current == 0 {
SetItemBytesReceived(pw.itemID, pw.current)
}
return n, nil
}
+73 -38
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"bufio"
"encoding/base64"
"encoding/json"
"fmt"
@@ -10,6 +11,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
)
// QobuzDownloader handles Qobuz downloads
@@ -19,6 +21,12 @@ type QobuzDownloader struct {
apiURL string
}
var (
// Global Qobuz downloader instance for connection reuse
globalQobuzDownloader *QobuzDownloader
qobuzDownloaderOnce sync.Once
)
// QobuzTrack represents a Qobuz track
type QobuzTrack struct {
ID int64 `json:"id"`
@@ -97,12 +105,15 @@ func qobuzIsASCIIString(s string) bool {
return true
}
// NewQobuzDownloader creates a new Qobuz downloader
// NewQobuzDownloader creates a new Qobuz downloader (returns singleton for connection reuse)
func NewQobuzDownloader() *QobuzDownloader {
return &QobuzDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
appID: "798273057",
}
qobuzDownloaderOnce.Do(func() {
globalQobuzDownloader = &QobuzDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
appID: "798273057",
}
})
return globalQobuzDownloader
}
// GetAvailableAPIs returns list of available Qobuz APIs
@@ -473,13 +484,17 @@ func (q *QobuzDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
defer out.Close()
// Use item progress writer
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
defer bufWriter.Flush()
// Use item progress writer with buffered output
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
progressWriter := NewItemProgressWriter(bufWriter, itemID)
_, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
_, err = io.Copy(out, resp.Body)
_, err = io.Copy(bufWriter, resp.Body)
}
return err
}
@@ -506,8 +521,21 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
var track *QobuzTrack
var err error
// Strategy 1: Search by ISRC with duration verification
// OPTIMIZATION: Check cache first for track ID
if req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.QobuzTrackID > 0 {
fmt.Printf("[Qobuz] Cache hit! Using cached track ID: %d\n", cached.QobuzTrackID)
// For Qobuz we need to search again to get full track info, but we can use the ID
track, err = downloader.SearchTrackByISRC(req.ISRC)
if err != nil {
fmt.Printf("[Qobuz] Cache hit but search failed: %v\n", err)
track = nil
}
}
}
// Strategy 1: Search by ISRC with duration verification
if track == nil && req.ISRC != "" {
track, err = downloader.SearchTrackByISRCWithDuration(req.ISRC, expectedDurationSec)
// Verify artist
if track != nil && !qobuzArtistsMatch(req.ArtistName, track.Performer.Name) {
@@ -536,8 +564,11 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{}, fmt.Errorf("qobuz search failed: %s", errMsg)
}
// Log match found
// Log match found and cache the track ID
fmt.Printf("[Qobuz] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, track.Performer.Name, track.Duration)
if req.ISRC != "" {
GetTrackIDCache().SetQobuz(req.ISRC, track.ID)
}
// Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
@@ -581,11 +612,29 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
return QobuzDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err)
}
// Download file with item ID for progress tracking
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadURL, outputPath, req.ItemID); err != nil {
return QobuzDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
@@ -593,7 +642,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
SetItemFinalizing(req.ItemID)
}
// Embed metadata
// Embed metadata using parallel-fetched cover data
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
@@ -606,41 +655,27 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
ISRC: req.ISRC,
}
// Download cover to memory (avoids file permission issues on Android)
// Use cover data from parallel fetch
var coverData []byte
if req.CoverURL != "" {
fmt.Println("[Qobuz] Downloading cover to memory...")
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
if err == nil {
coverData = data
fmt.Printf("[Qobuz] Cover downloaded successfully (%d bytes)\n", len(coverData))
} else {
fmt.Printf("[Qobuz] Warning: failed to download cover: %v\n", err)
}
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
fmt.Printf("[Qobuz] Using parallel-fetched cover (%d bytes)\n", len(coverData))
}
if err := EmbedMetadataWithCoverData(outputPath, metadata, coverData); err != nil {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics if enabled
if req.EmbedLyrics {
fmt.Println("[Qobuz] Fetching lyrics...")
lyricsClient := NewLyricsClient()
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
if lyricsErr != nil {
fmt.Printf("[Qobuz] Warning: lyrics fetch error: %v\n", lyricsErr)
} else if lyrics == nil || len(lyrics.Lines) == 0 {
fmt.Println("[Qobuz] No lyrics found for this track")
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
fmt.Printf("[Qobuz] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(outputPath, parallelResult.LyricsLRC); embedErr != nil {
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Printf("[Qobuz] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
lrcContent := convertToLRC(lyrics)
if embedErr := EmbedLyrics(outputPath, lrcContent); embedErr != nil {
fmt.Printf("[Qobuz] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
fmt.Println("[Qobuz] Lyrics embedded successfully")
}
} else if req.EmbedLyrics {
fmt.Println("[Qobuz] No lyrics available from parallel fetch")
}
return QobuzDownloadResult{
+14 -4
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/url"
"sync"
"time"
)
@@ -25,11 +26,20 @@ type TrackAvailability struct {
QobuzURL string `json:"qobuz_url,omitempty"`
}
// NewSongLinkClient creates a new SongLink client
var (
// Global SongLink client instance for connection reuse
globalSongLinkClient *SongLinkClient
songLinkClientOnce sync.Once
)
// NewSongLinkClient creates a new SongLink client (returns singleton for connection reuse)
func NewSongLinkClient() *SongLinkClient {
return &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
}
songLinkClientOnce.Do(func() {
globalSongLinkClient = &SongLinkClient{
client: NewHTTPClientWithTimeout(SongLinkTimeout), // 30s timeout
}
})
return globalSongLinkClient
}
// CheckTrackAvailability checks track availability on streaming platforms
+133 -75
View File
@@ -1,6 +1,7 @@
package gobackend
import (
"bufio"
"encoding/base64"
"encoding/json"
"encoding/xml"
@@ -12,17 +13,27 @@ import (
"path/filepath"
"regexp"
"strings"
"sync"
"time"
)
// TidalDownloader handles Tidal downloads
type TidalDownloader struct {
client *http.Client
clientID string
clientSecret string
apiURL string
client *http.Client
clientID string
clientSecret string
apiURL string
cachedToken string
tokenExpiresAt time.Time
tokenMu sync.Mutex
}
var (
// Global Tidal downloader instance for token reuse
globalTidalDownloader *TidalDownloader
tidalDownloaderOnce sync.Once
)
// TidalTrack represents a Tidal track
type TidalTrack struct {
ID int64 `json:"id"`
@@ -93,24 +104,25 @@ type MPD struct {
} `xml:"Period"`
}
// NewTidalDownloader creates a new Tidal downloader
// NewTidalDownloader creates a new Tidal downloader (returns singleton for token reuse)
func NewTidalDownloader() *TidalDownloader {
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
tidalDownloaderOnce.Do(func() {
clientID, _ := base64.StdEncoding.DecodeString("NkJEU1JkcEs5aHFFQlRnVQ==")
clientSecret, _ := base64.StdEncoding.DecodeString("eGV1UG1ZN25icFo5SUliTEFjUTkzc2hrYTFWTmhlVUFxTjZJY3N6alRHOD0=")
downloader := &TidalDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
clientID: string(clientID),
clientSecret: string(clientSecret),
}
globalTidalDownloader = &TidalDownloader{
client: NewHTTPClientWithTimeout(DefaultTimeout), // 60s timeout
clientID: string(clientID),
clientSecret: string(clientSecret),
}
// Get first available API
apis := downloader.GetAvailableAPIs()
if len(apis) > 0 {
downloader.apiURL = apis[0]
}
return downloader
// Get first available API
apis := globalTidalDownloader.GetAvailableAPIs()
if len(apis) > 0 {
globalTidalDownloader.apiURL = apis[0]
}
})
return globalTidalDownloader
}
// GetAvailableAPIs returns list of available Tidal APIs
@@ -138,8 +150,16 @@ func (t *TidalDownloader) GetAvailableAPIs() []string {
return apis
}
// GetAccessToken gets Tidal access token
// GetAccessToken gets Tidal access token (with caching)
func (t *TidalDownloader) GetAccessToken() (string, error) {
t.tokenMu.Lock()
defer t.tokenMu.Unlock()
// Return cached token if still valid (with 60s buffer)
if t.cachedToken != "" && time.Now().Add(60*time.Second).Before(t.tokenExpiresAt) {
return t.cachedToken, nil
}
data := fmt.Sprintf("client_id=%s&grant_type=client_credentials", t.clientID)
authURL, _ := base64.StdEncoding.DecodeString("aHR0cHM6Ly9hdXRoLnRpZGFsLmNvbS92MS9vYXV0aDIvdG9rZW4=")
@@ -163,12 +183,21 @@ func (t *TidalDownloader) GetAccessToken() (string, error) {
var result struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", err
}
// Cache the token
t.cachedToken = result.AccessToken
if result.ExpiresIn > 0 {
t.tokenExpiresAt = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
} else {
t.tokenExpiresAt = time.Now().Add(55 * time.Minute) // Default 55 min
}
return result.AccessToken, nil
}
@@ -728,13 +757,17 @@ func (t *TidalDownloader) DownloadFile(downloadURL, outputPath, itemID string) e
}
defer out.Close()
// Use item progress writer
// Use buffered writer for better performance (256KB buffer)
bufWriter := bufio.NewWriterSize(out, 256*1024)
defer bufWriter.Flush()
// Use item progress writer with buffered output
if itemID != "" {
progressWriter := NewItemProgressWriter(out, itemID)
progressWriter := NewItemProgressWriter(bufWriter, itemID)
_, err = io.Copy(progressWriter, resp.Body)
} else {
// Fallback: direct copy without progress tracking
_, err = io.Copy(out, resp.Body)
_, err = io.Copy(bufWriter, resp.Body)
}
return err
}
@@ -942,8 +975,44 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
var track *TidalTrack
var err error
// Strategy 1: Try to get Tidal URL from SongLink (using Spotify ID)
if req.SpotifyID != "" {
// OPTIMIZATION: Check cache first for track ID
if req.ISRC != "" {
if cached := GetTrackIDCache().Get(req.ISRC); cached != nil && cached.TidalTrackID > 0 {
fmt.Printf("[Tidal] Cache hit! Using cached track ID: %d\n", cached.TidalTrackID)
track, err = downloader.GetTrackInfoByID(cached.TidalTrackID)
if err != nil {
fmt.Printf("[Tidal] Cache hit but failed to get track info: %v\n", err)
track = nil // Fall through to normal search
}
}
}
// OPTIMIZED: Try ISRC search first (faster than SongLink API)
// Strategy 1: Search by ISRC with duration verification (FASTEST)
if track == nil && req.ISRC != "" {
fmt.Printf("[Tidal] Trying ISRC search first (faster): %s\n", req.ISRC)
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
// Verify artist for ISRC match
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
}
}
// Strategy 2: Try SongLink only if ISRC search failed (slower but more accurate)
if track == nil && req.SpotifyID != "" {
fmt.Printf("[Tidal] ISRC search failed, trying SongLink...\n")
tidalURL, slErr := downloader.GetTidalURLFromSpotify(req.SpotifyID)
if slErr == nil && tidalURL != "" {
// Extract track ID and get track info
@@ -986,29 +1055,9 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
}
// Strategy 2: Search by ISRC with duration verification
if track == nil && req.ISRC != "" {
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, req.ISRC, expectedDurationSec)
// Verify artist for ISRC match too
if track != nil {
tidalArtist := track.Artist.Name
if len(track.Artists) > 0 {
var artistNames []string
for _, a := range track.Artists {
artistNames = append(artistNames, a.Name)
}
tidalArtist = strings.Join(artistNames, ", ")
}
if !artistsMatch(req.ArtistName, tidalArtist) {
fmt.Printf("[Tidal] Artist mismatch from ISRC search: expected '%s', got '%s'. Rejecting.\n",
req.ArtistName, tidalArtist)
track = nil
}
}
}
// Strategy 3: Search by metadata only (no ISRC requirement)
// Strategy 3: Search by metadata only (no ISRC requirement) - last resort
if track == nil {
fmt.Printf("[Tidal] Trying metadata search as last resort...\n")
track, err = downloader.SearchTrackByMetadataWithISRC(req.TrackName, req.ArtistName, "", expectedDurationSec)
// Verify artist for metadata search too
if track != nil {
@@ -1047,6 +1096,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
}
fmt.Printf("[Tidal] Match found: '%s' by '%s' (duration: %ds)\n", track.Title, tidalArtist, track.Duration)
// Cache the track ID for future use
if req.ISRC != "" {
GetTrackIDCache().SetTidal(req.ISRC, track.ID)
}
// Build filename
filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]interface{}{
"title": req.TrackName,
@@ -1080,11 +1134,29 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
// Log actual quality received
fmt.Printf("[Tidal] Actual quality: %d-bit/%dHz\n", downloadInfo.BitDepth, downloadInfo.SampleRate)
// Download file with item ID for progress tracking
// START PARALLEL: Fetch cover and lyrics while downloading audio
var parallelResult *ParallelDownloadResult
parallelDone := make(chan struct{})
go func() {
defer close(parallelDone)
parallelResult = FetchCoverAndLyricsParallel(
req.CoverURL,
req.EmbedMaxQualityCover,
req.SpotifyID,
req.TrackName,
req.ArtistName,
req.EmbedLyrics,
)
}()
// Download audio file with item ID for progress tracking
if err := downloader.DownloadFile(downloadInfo.URL, outputPath, req.ItemID); err != nil {
return TidalDownloadResult{}, fmt.Errorf("download failed: %w", err)
}
// Wait for parallel operations to complete
<-parallelDone
// Set progress to 100% and status to finalizing (before embedding)
// This makes the UI show "Finalizing..." while embedding happens
if req.ItemID != "" {
@@ -1105,7 +1177,7 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
return TidalDownloadResult{}, fmt.Errorf("download completed but file not found at %s or %s", outputPath, m4aPath)
}
// Embed metadata
// Embed metadata using parallel-fetched cover data
metadata := Metadata{
Title: req.TrackName,
Artist: req.ArtistName,
@@ -1118,17 +1190,11 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
ISRC: req.ISRC,
}
// Download cover to memory (avoids file permission issues on Android)
// Use cover data from parallel fetch
var coverData []byte
if req.CoverURL != "" {
fmt.Println("[Tidal] Downloading cover to memory...")
data, err := downloadCoverToMemory(req.CoverURL, req.EmbedMaxQualityCover)
if err == nil {
coverData = data
fmt.Printf("[Tidal] Cover downloaded successfully (%d bytes)\n", len(coverData))
} else {
fmt.Printf("[Tidal] Warning: failed to download cover: %v\n", err)
}
if parallelResult != nil && parallelResult.CoverData != nil {
coverData = parallelResult.CoverData
fmt.Printf("[Tidal] Using parallel-fetched cover (%d bytes)\n", len(coverData))
}
// Only embed metadata to FLAC files (M4A will be converted by Flutter)
@@ -1137,24 +1203,16 @@ func downloadFromTidal(req DownloadRequest) (TidalDownloadResult, error) {
fmt.Printf("Warning: failed to embed metadata: %v\n", err)
}
// Embed lyrics if enabled
if req.EmbedLyrics {
fmt.Println("[Tidal] Fetching lyrics...")
lyricsClient := NewLyricsClient()
lyrics, lyricsErr := lyricsClient.FetchLyricsAllSources(req.SpotifyID, req.TrackName, req.ArtistName)
if lyricsErr != nil {
fmt.Printf("[Tidal] Warning: lyrics fetch error: %v\n", lyricsErr)
} else if lyrics == nil || len(lyrics.Lines) == 0 {
fmt.Println("[Tidal] No lyrics found for this track")
// Embed lyrics from parallel fetch
if req.EmbedLyrics && parallelResult != nil && parallelResult.LyricsLRC != "" {
fmt.Printf("[Tidal] Embedding parallel-fetched lyrics (%d lines)...\n", len(parallelResult.LyricsData.Lines))
if embedErr := EmbedLyrics(actualOutputPath, parallelResult.LyricsLRC); embedErr != nil {
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Printf("[Tidal] Lyrics found (%d lines), embedding...\n", len(lyrics.Lines))
lrcContent := convertToLRC(lyrics)
if embedErr := EmbedLyrics(actualOutputPath, lrcContent); embedErr != nil {
fmt.Printf("[Tidal] Warning: failed to embed lyrics: %v\n", embedErr)
} else {
fmt.Println("[Tidal] Lyrics embedded successfully")
}
fmt.Println("[Tidal] Lyrics embedded successfully")
}
} else if req.EmbedLyrics {
fmt.Println("[Tidal] No lyrics available from parallel fetch")
}
} else {
fmt.Printf("[Tidal] Skipping metadata embed for M4A file (will be handled after conversion): %s\n", actualOutputPath)
+4 -2
View File
@@ -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.7-preview';
static const String buildNumber = '37';
static const String version = '2.1.0';
static const String buildNumber = '41';
static const String fullVersion = '$version+$buildNumber';
@@ -15,4 +15,6 @@ class AppInfo {
static const String githubRepo = 'zarzet/SpotiFLAC-Mobile';
static const String githubUrl = 'https://github.com/$githubRepo';
static const String originalGithubUrl = 'https://github.com/afkarxyz/SpotiFLAC';
static const String kofiUrl = 'https://ko-fi.com/zarzet';
}
+4
View File
@@ -14,6 +14,7 @@ class AppSettings {
final bool isFirstLaunch;
final int concurrentDownloads; // 1 = sequential (default), max 3
final bool checkForUpdates; // Check for updates on app start
final String updateChannel; // stable, preview
final bool hasSearchedBefore; // Hide helper text after first search
final String folderOrganization; // none, artist, album, artist_album
final String historyViewMode; // list, grid
@@ -33,6 +34,7 @@ class AppSettings {
this.isFirstLaunch = true,
this.concurrentDownloads = 1, // Default: sequential (off)
this.checkForUpdates = true, // Default: enabled
this.updateChannel = 'stable', // Default: stable releases only
this.hasSearchedBefore = false, // Default: show helper text
this.folderOrganization = 'none', // Default: no folder organization
this.historyViewMode = 'grid', // Default: grid view
@@ -53,6 +55,7 @@ class AppSettings {
bool? isFirstLaunch,
int? concurrentDownloads,
bool? checkForUpdates,
String? updateChannel,
bool? hasSearchedBefore,
String? folderOrganization,
String? historyViewMode,
@@ -72,6 +75,7 @@ class AppSettings {
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
updateChannel: updateChannel ?? this.updateChannel,
hasSearchedBefore: hasSearchedBefore ?? this.hasSearchedBefore,
folderOrganization: folderOrganization ?? this.folderOrganization,
historyViewMode: historyViewMode ?? this.historyViewMode,
+2
View File
@@ -17,6 +17,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
updateChannel: json['updateChannel'] as String? ?? 'stable',
hasSearchedBefore: json['hasSearchedBefore'] as bool? ?? false,
folderOrganization: json['folderOrganization'] as String? ?? 'none',
historyViewMode: json['historyViewMode'] as String? ?? 'grid',
@@ -39,6 +40,7 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
'isFirstLaunch': instance.isFirstLaunch,
'concurrentDownloads': instance.concurrentDownloads,
'checkForUpdates': instance.checkForUpdates,
'updateChannel': instance.updateChannel,
'hasSearchedBefore': instance.hasSearchedBefore,
'folderOrganization': instance.folderOrganization,
'historyViewMode': instance.historyViewMode,
+10 -2
View File
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
const String kThemeModeKey = 'theme_mode';
const String kUseDynamicColorKey = 'use_dynamic_color';
const String kSeedColorKey = 'seed_color';
const String kUseAmoledKey = 'use_amoled';
/// Default Spotify green color for fallback
const int kDefaultSeedColor = 0xFF1DB954;
@@ -13,11 +14,13 @@ class ThemeSettings {
final ThemeMode themeMode;
final bool useDynamicColor;
final int seedColorValue;
final bool useAmoled; // Pure black background for OLED screens
const ThemeSettings({
this.themeMode = ThemeMode.system,
this.useDynamicColor = true,
this.seedColorValue = kDefaultSeedColor,
this.useAmoled = false,
});
/// Get seed color as Color object
@@ -28,11 +31,13 @@ class ThemeSettings {
ThemeMode? themeMode,
bool? useDynamicColor,
int? seedColorValue,
bool? useAmoled,
}) {
return ThemeSettings(
themeMode: themeMode ?? this.themeMode,
useDynamicColor: useDynamicColor ?? this.useDynamicColor,
seedColorValue: seedColorValue ?? this.seedColorValue,
useAmoled: useAmoled ?? this.useAmoled,
);
}
@@ -41,6 +46,7 @@ class ThemeSettings {
kThemeModeKey: themeMode.name,
kUseDynamicColorKey: useDynamicColor,
kSeedColorKey: seedColorValue,
kUseAmoledKey: useAmoled,
};
/// Create from JSON map
@@ -49,6 +55,7 @@ class ThemeSettings {
themeMode: _themeModeFromString(json[kThemeModeKey] as String?),
useDynamicColor: json[kUseDynamicColorKey] as bool? ?? true,
seedColorValue: json[kSeedColorKey] as int? ?? kDefaultSeedColor,
useAmoled: json[kUseAmoledKey] as bool? ?? false,
);
}
@@ -58,12 +65,13 @@ class ThemeSettings {
return other is ThemeSettings &&
other.themeMode == themeMode &&
other.useDynamicColor == useDynamicColor &&
other.seedColorValue == seedColorValue;
other.seedColorValue == seedColorValue &&
other.useAmoled == useAmoled;
}
@override
int get hashCode =>
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode;
themeMode.hashCode ^ useDynamicColor.hashCode ^ seedColorValue.hashCode ^ useAmoled.hashCode;
}
/// Helper to convert string to ThemeMode
+35 -8
View File
@@ -686,20 +686,37 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
}
/// Retry a failed download
/// Retry a failed or skipped download
void retryItem(String id) {
final items = state.items.map((item) {
if (item.id == id && item.status == DownloadStatus.failed) {
return item.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
final item = state.items.where((i) => i.id == id).firstOrNull;
if (item == null) {
_log.w('retryItem: Item not found: $id');
return;
}
// Only retry if status is failed or skipped
if (item.status != DownloadStatus.failed && item.status != DownloadStatus.skipped) {
_log.w('retryItem: Item status is ${item.status}, not retrying');
return;
}
_log.i('Retrying item: ${item.track.name} (id: $id)');
final items = state.items.map((i) {
if (i.id == id) {
return i.copyWith(status: DownloadStatus.queued, progress: 0, error: null);
}
return item;
return i;
}).toList();
state = state.copyWith(items: items);
_saveQueueToStorage(); // Persist queue
// Start processing if not already
// Start processing if not already running
if (!state.isProcessing) {
_log.d('Starting queue processing for retry');
Future.microtask(() => _processQueue());
} else {
_log.d('Queue already processing, item will be picked up');
}
}
@@ -851,6 +868,13 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
_log.i('Queue processing finished');
state = state.copyWith(isProcessing: false, currentDownload: null);
// Check if there are new queued items (e.g., from retry) and restart if needed
final hasQueuedItems = state.items.any((item) => item.status == DownloadStatus.queued);
if (hasQueuedItems) {
_log.i('Found queued items after processing finished, restarting queue...');
Future.microtask(() => _processQueue());
}
}
/// Sequential download processing (uses multi-progress system with single item)
@@ -866,7 +890,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
continue;
}
final nextItem = state.items.firstWhere(
// Re-read state to get latest items (important for retry)
final currentItems = state.items;
final nextItem = currentItems.firstWhere(
(item) => item.status == DownloadStatus.queued,
orElse: () => DownloadItem(
id: '',
@@ -877,10 +903,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
if (nextItem.id.isEmpty) {
_log.d('No more items to process');
_log.d('No more items to process (checked ${currentItems.length} items)');
break;
}
_log.d('Processing next item: ${nextItem.track.name} (id: ${nextItem.id})');
await _downloadSingleItem(nextItem);
// Clear item progress after download completes
+5
View File
@@ -96,6 +96,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUpdateChannel(String channel) {
state = state.copyWith(updateChannel: channel);
_saveSettings();
}
void setHasSearchedBefore() {
if (!state.hasSearchedBefore) {
state = state.copyWith(hasSearchedBefore: true);
+9
View File
@@ -24,11 +24,13 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
final modeString = prefs.getString(kThemeModeKey);
final useDynamic = prefs.getBool(kUseDynamicColorKey);
final seedColor = prefs.getInt(kSeedColorKey);
final useAmoled = prefs.getBool(kUseAmoledKey);
state = ThemeSettings(
themeMode: _themeModeFromString(modeString),
useDynamicColor: useDynamic ?? true,
seedColorValue: seedColor ?? kDefaultSeedColor,
useAmoled: useAmoled ?? false,
);
} catch (e) {
debugPrint('Error loading theme settings: $e');
@@ -43,6 +45,7 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
await prefs.setString(kThemeModeKey, state.themeMode.name);
await prefs.setBool(kUseDynamicColorKey, state.useDynamicColor);
await prefs.setInt(kSeedColorKey, state.seedColorValue);
await prefs.setBool(kUseAmoledKey, state.useAmoled);
} catch (e) {
debugPrint('Error saving theme settings: $e');
}
@@ -72,6 +75,12 @@ class ThemeNotifier extends Notifier<ThemeSettings> {
await _saveToStorage();
}
/// Enable or disable AMOLED mode (pure black background)
Future<void> setUseAmoled(bool value) async {
state = state.copyWith(useAmoled: value);
await _saveToStorage();
}
/// Helper to convert string to ThemeMode
ThemeMode _themeModeFromString(String? value) {
if (value == null) return ThemeMode.system;
+26
View File
@@ -149,6 +149,8 @@ class TrackNotifier extends Notifier<TrackState> {
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
// Pre-warm cache for album tracks in background
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
@@ -160,6 +162,8 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: owner?['name'] as String?,
coverUrl: owner?['images'] as String?,
);
// Pre-warm cache for playlist tracks in background
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
@@ -310,6 +314,28 @@ class TrackNotifier extends Notifier<TrackState> {
popularity: data['popularity'] as int? ?? 0,
);
}
/// Pre-warm track ID cache for faster downloads
/// Runs in background, doesn't block UI
void _preWarmCacheForTracks(List<Track> tracks) {
// Only pre-warm if we have tracks with ISRC
final tracksWithIsrc = tracks.where((t) => t.isrc != null && t.isrc!.isNotEmpty).toList();
if (tracksWithIsrc.isEmpty) return;
// Build request list for Go backend
final cacheRequests = tracksWithIsrc.map((t) => {
'isrc': t.isrc!,
'track_name': t.name,
'artist_name': t.artistName,
'spotify_id': t.id, // Include Spotify ID for Amazon lookup
'service': 'tidal', // Default to tidal for pre-warming
}).toList();
// Fire and forget - runs in background
PlatformBridge.preWarmTrackCache(cacheRequests).catchError((_) {
// Silently ignore errors - this is just an optimization
});
}
}
final trackProvider = NotifierProvider<TrackNotifier, TrackState>(
+94 -35
View File
@@ -302,8 +302,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
void _downloadTrack(BuildContext context, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
@@ -317,8 +317,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
if (tracks == null || tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}, trackName: '${tracks.length} tracks', artistName: widget.albumName);
} else {
@@ -327,44 +327,69 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
String selectedService = settings.defaultService;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
// Service selector
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
const SizedBox(width: 8),
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
const SizedBox(width: 8),
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
const SizedBox(height: 16),
],
),
_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.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
const SizedBox(height: 16),
],
),
),
),
);
@@ -455,6 +480,40 @@ class _QualityOption extends StatelessWidget {
}
}
class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
+107 -48
View File
@@ -170,8 +170,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
@@ -181,59 +181,84 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
void _showQualityPicker(BuildContext context, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
String selectedService = settings.defaultService;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
// Service selector
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
const SizedBox(width: 8),
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
const SizedBox(width: 8),
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_QualityPickerOption(
title: 'FLAC Lossless',
subtitle: '16-bit / 44.1kHz',
icon: Icons.music_note,
onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); },
),
_QualityPickerOption(
title: 'Hi-Res FLAC',
subtitle: '24-bit / up to 96kHz',
icon: Icons.high_quality,
onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); },
),
_QualityPickerOption(
title: 'Hi-Res FLAC Max',
subtitle: '24-bit / up to 192kHz',
icon: Icons.four_k,
onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); },
),
const SizedBox(height: 16),
],
),
_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),
],
),
),
),
);
@@ -764,6 +789,40 @@ class _QualityPickerOption extends StatelessWidget {
}
}
class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
+1 -1
View File
@@ -85,7 +85,7 @@ class _MainShellState extends ConsumerState<MainShell> {
final settings = ref.read(settingsProvider);
if (!settings.checkForUpdates) return;
final updateInfo = await UpdateChecker.checkForUpdate();
final updateInfo = await UpdateChecker.checkForUpdate(channel: settings.updateChannel);
if (updateInfo != null && mounted) {
showUpdateDialog(
context,
+91 -32
View File
@@ -168,8 +168,8 @@ class PlaylistScreen extends ConsumerWidget {
void _downloadTrack(BuildContext context, WidgetRef ref, Track track) {
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, ref, (quality, service) {
ref.read(downloadQueueProvider.notifier).addToQueue(track, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added "${track.name}" to queue')));
}, trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl);
} else {
@@ -182,8 +182,8 @@ class PlaylistScreen extends ConsumerWidget {
if (tracks.isEmpty) return;
final settings = ref.read(settingsProvider);
if (settings.askQualityBeforeDownload) {
_showQualityPicker(context, (quality) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, settings.defaultService, qualityOverride: quality);
_showQualityPicker(context, ref, (quality, service) {
ref.read(downloadQueueProvider.notifier).addMultipleToQueue(tracks, service, qualityOverride: quality);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Added ${tracks.length} tracks to queue')));
}, trackName: '${tracks.length} tracks', artistName: playlistName);
} else {
@@ -192,41 +192,66 @@ class PlaylistScreen extends ConsumerWidget {
}
}
void _showQualityPicker(BuildContext context, void Function(String quality) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
void _showQualityPicker(BuildContext context, WidgetRef ref, void Function(String quality, String service) onSelect, {String? trackName, String? artistName, String? coverUrl}) {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.read(settingsProvider);
String selectedService = settings.defaultService;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold))),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SafeArea(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (trackName != null) ...[
_TrackInfoHeader(trackName: trackName, artistName: artistName, coverUrl: coverUrl),
Divider(height: 1, color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
] else ...[
const SizedBox(height: 8),
Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))),
],
// Service selector
Padding(
padding: const EdgeInsets.fromLTRB(24, 16, 24, 8),
child: Text('Download From', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold)),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Row(
children: [
_ServiceChip(label: 'Tidal', isSelected: selectedService == 'tidal', onTap: () => setModalState(() => selectedService = 'tidal')),
const SizedBox(width: 8),
_ServiceChip(label: 'Qobuz', isSelected: selectedService == 'qobuz', onTap: () => setModalState(() => selectedService = 'qobuz')),
const SizedBox(width: 8),
_ServiceChip(label: 'Amazon', isSelected: selectedService == 'amazon', onTap: () => setModalState(() => selectedService = 'amazon')),
],
),
),
Padding(padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), child: Text('Select Quality', style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold))),
// Disclaimer
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 12),
child: Text(
'Actual quality depends on track availability. Hi-Res may not be available for all tracks.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontStyle: FontStyle.italic,
),
),
),
_QualityOption(title: 'FLAC Lossless', subtitle: '16-bit / 44.1kHz', icon: Icons.music_note, onTap: () { Navigator.pop(context); onSelect('LOSSLESS', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC', subtitle: '24-bit / up to 96kHz', icon: Icons.high_quality, onTap: () { Navigator.pop(context); onSelect('HI_RES', selectedService); }),
_QualityOption(title: 'Hi-Res FLAC Max', subtitle: '24-bit / up to 192kHz', icon: Icons.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS', selectedService); }),
const SizedBox(height: 16),
],
),
_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.four_k, onTap: () { Navigator.pop(context); onSelect('HI_RES_LOSSLESS'); }),
const SizedBox(height: 16),
],
),
),
),
);
@@ -254,6 +279,40 @@ class _QualityOption extends StatelessWidget {
}
}
class _ServiceChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ServiceChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected ? colorScheme.primaryContainer : colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
border: isSelected ? null : Border.all(color: colorScheme.outlineVariant.withValues(alpha: 0.5)),
),
child: Text(
label,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant,
),
),
),
),
);
}
}
class _TrackInfoHeader extends StatefulWidget {
final String trackName;
final String? artistName;
+18
View File
@@ -123,6 +123,24 @@ class AboutPage extends StatelessWidget {
),
),
// Support section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'Support'),
),
SliverToBoxAdapter(
child: SettingsGroup(
children: [
SettingsItem(
icon: Icons.coffee_outlined,
title: 'Buy me a coffee',
subtitle: 'Support development on Ko-fi',
onTap: () => _launchUrl(AppInfo.kofiUrl),
showDivider: false,
),
],
),
),
// App info section
const SliverToBoxAdapter(
child: SettingsSectionHeader(title: 'App'),
@@ -40,6 +40,14 @@ class AppearanceSettingsPage extends ConsumerWidget {
currentMode: themeSettings.themeMode,
onChanged: (mode) => ref.read(themeProvider.notifier).setThemeMode(mode),
),
SettingsSwitchItem(
icon: Icons.brightness_2,
title: 'AMOLED Dark',
subtitle: 'Pure black background for OLED screens',
value: themeSettings.useAmoled,
onChanged: (value) => ref.read(themeProvider.notifier).setUseAmoled(value),
showDivider: false,
),
],
),
),
@@ -1,6 +1,8 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -113,8 +115,10 @@ class DownloadSettingsPage extends ConsumerWidget {
SettingsItem(
icon: Icons.folder_outlined,
title: 'Download Directory',
subtitle: settings.downloadDirectory.isEmpty ? 'Music/SpotiFLAC' : settings.downloadDirectory,
onTap: () => _pickDirectory(ref),
subtitle: settings.downloadDirectory.isEmpty
? (Platform.isIOS ? 'App Documents Folder' : 'Music/SpotiFLAC')
: settings.downloadDirectory,
onTap: () => _pickDirectory(context, ref),
),
SettingsItem(
icon: Icons.create_new_folder_outlined,
@@ -161,9 +165,90 @@ class DownloadSettingsPage extends ConsumerWidget {
);
}
Future<void> _pickDirectory(WidgetRef ref) async {
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
Future<void> _pickDirectory(BuildContext context, WidgetRef ref) async {
if (Platform.isIOS) {
// iOS: Show options dialog
_showIOSDirectoryOptions(context, ref);
} else {
// Android: Use file picker
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
}
void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) {
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App Documents Folder'),
subtitle: const Text('Recommended - accessible via Files app'),
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async {
final dir = await getApplicationDocumentsDirectory();
ref.read(settingsProvider.notifier).setDownloadDirectory(dir.path);
if (ctx.mounted) Navigator.pop(ctx);
},
),
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: const Text('Choose from Files'),
subtitle: const Text('Select iCloud or other location'),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
ref.read(settingsProvider.notifier).setDownloadDirectory(result);
}
},
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
const SizedBox(width: 12),
Expanded(
child: Text(
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
),
),
],
),
),
),
const SizedBox(height: 8),
],
),
),
);
}
String _getFolderOrganizationLabel(String value) {
@@ -105,7 +105,10 @@ class OptionsSettingsPage extends ConsumerWidget {
subtitle: 'Notify when new version is available',
value: settings.checkForUpdates,
onChanged: (v) => ref.read(settingsProvider.notifier).setCheckForUpdates(v),
showDivider: false,
),
_UpdateChannelSelector(
currentChannel: settings.updateChannel,
onChanged: (v) => ref.read(settingsProvider.notifier).setUpdateChannel(v),
),
],
),
@@ -393,3 +396,76 @@ class _ConcurrentChip extends StatelessWidget {
);
}
}
class _UpdateChannelSelector extends StatelessWidget {
final String currentChannel;
final ValueChanged<String> onChanged;
const _UpdateChannelSelector({required this.currentChannel, required this.onChanged});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.all(20),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.new_releases, color: colorScheme.onSurfaceVariant, size: 24),
const SizedBox(width: 16),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('Update Channel', style: Theme.of(context).textTheme.bodyLarge),
const SizedBox(height: 2),
Text(currentChannel == 'preview' ? 'Get preview releases' : 'Stable releases only',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
])),
]),
const SizedBox(height: 16),
Row(children: [
_ChannelChip(label: 'Stable', isSelected: currentChannel == 'stable', onTap: () => onChanged('stable')),
const SizedBox(width: 8),
_ChannelChip(label: 'Preview', isSelected: currentChannel == 'preview', onTap: () => onChanged('preview')),
]),
const SizedBox(height: 12),
Row(children: [
Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant),
const SizedBox(width: 8),
Expanded(child: Text('Preview may contain bugs or incomplete features',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
]),
]),
);
}
}
class _ChannelChip extends StatelessWidget {
final String label;
final bool isSelected;
final VoidCallback onTap;
const _ChannelChip({required this.label, required this.isSelected, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final isDark = Theme.of(context).brightness == Brightness.dark;
final unselectedColor = isDark
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
: colorScheme.surfaceContainerHigh;
return Expanded(
child: Material(
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Center(child: Text(label, style: TextStyle(
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
),
),
),
);
}
}
+103 -21
View File
@@ -205,29 +205,35 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
setState(() => _isLoading = true);
try {
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Select Download Folder',
);
if (selectedDirectory != null) {
setState(() => _selectedDirectory = selectedDirectory);
if (Platform.isIOS) {
// iOS: Show options dialog
await _showIOSDirectoryOptions();
} else {
final defaultDir = await _getDefaultDirectory();
if (mounted) {
final useDefault = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Use Default Folder?'),
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
],
),
);
// Android: Use file picker
String? selectedDirectory = await FilePicker.platform.getDirectoryPath(
dialogTitle: 'Select Download Folder',
);
if (useDefault == true) {
setState(() => _selectedDirectory = defaultDir);
if (selectedDirectory != null) {
setState(() => _selectedDirectory = selectedDirectory);
} else {
final defaultDir = await _getDefaultDirectory();
if (mounted) {
final useDefault = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Use Default Folder?'),
content: Text('No folder selected. Would you like to use the default Music folder?\n\n$defaultDir'),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text('Use Default')),
],
),
);
if (useDefault == true) {
setState(() => _selectedDirectory = defaultDir);
}
}
}
}
@@ -236,6 +242,82 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
}
}
Future<void> _showIOSDirectoryOptions() async {
final colorScheme = Theme.of(context).colorScheme;
await showModalBottomSheet(
context: context,
backgroundColor: colorScheme.surfaceContainerHigh,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(28))),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 8),
child: Text('Download Location', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Text(
'On iOS, downloads are saved to the app\'s Documents folder which is accessible via the Files app.',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
),
),
ListTile(
leading: Icon(Icons.folder_special, color: colorScheme.primary),
title: const Text('App Documents Folder'),
subtitle: const Text('Recommended - accessible via Files app'),
trailing: Icon(Icons.check_circle, color: colorScheme.primary),
onTap: () async {
final dir = await _getDefaultDirectory();
setState(() => _selectedDirectory = dir);
if (ctx.mounted) Navigator.pop(ctx);
},
),
ListTile(
leading: Icon(Icons.cloud, color: colorScheme.onSurfaceVariant),
title: const Text('Choose from Files'),
subtitle: const Text('Select iCloud or other location'),
onTap: () async {
Navigator.pop(ctx);
// Note: iOS requires folder to have at least one file to be selectable
final result = await FilePicker.platform.getDirectoryPath();
if (result != null) {
setState(() => _selectedDirectory = result);
}
},
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(Icons.info_outline, size: 20, color: colorScheme.tertiary),
const SizedBox(width: 12),
Expanded(
child: Text(
'iOS limitation: Empty folders cannot be selected. Create a file inside first or use App Documents.',
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onTertiaryContainer),
),
),
],
),
),
),
const SizedBox(height: 8),
],
),
),
);
}
Future<String> _getDefaultDirectory() async {
if (Platform.isIOS) {
final appDir = await getApplicationDocumentsDirectory();
+9 -2
View File
@@ -759,17 +759,21 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
});
try {
// Add timeout to prevent infinite loading
final result = await PlatformBridge.getLyricsLRC(
item.spotifyId ?? '',
item.trackName,
item.artistName,
filePath: _fileExists ? item.filePath : null, // Try embedded lyrics first
).timeout(
const Duration(seconds: 20),
onTimeout: () => '', // Return empty string on timeout
);
if (mounted) {
if (result.isEmpty) {
setState(() {
_lyricsError = 'Lyrics not found';
_lyricsError = 'Lyrics not available for this track';
_lyricsLoading = false;
});
} else {
@@ -783,8 +787,11 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
}
} catch (e) {
if (mounted) {
final errorMsg = e.toString().contains('TimeoutException')
? 'Request timed out. Try again later.'
: 'Failed to load lyrics';
setState(() {
_lyricsError = 'Failed to load lyrics';
_lyricsError = errorMsg;
_lyricsLoading = false;
});
}
+19
View File
@@ -297,4 +297,23 @@ class PlatformBridge {
'client_secret': clientSecret,
});
}
/// Pre-warm track ID cache for album/playlist tracks
/// This runs in background and returns immediately
/// Speeds up subsequent downloads by caching ISRC Track ID mappings
static Future<void> preWarmTrackCache(List<Map<String, String>> tracks) async {
final tracksJson = jsonEncode(tracks);
await _channel.invokeMethod('preWarmTrackCache', {'tracks': tracksJson});
}
/// Get current track cache size
static Future<int> getTrackCacheSize() async {
final result = await _channel.invokeMethod('getTrackCacheSize');
return result as int;
}
/// Clear track ID cache
static Future<void> clearTrackCache() async {
await _channel.invokeMethod('clearTrackCache');
}
}
+50 -17
View File
@@ -12,6 +12,7 @@ class UpdateInfo {
final String downloadUrl;
final String? apkDownloadUrl;
final DateTime publishedAt;
final bool isPrerelease;
const UpdateInfo({
required this.version,
@@ -19,11 +20,13 @@ class UpdateInfo {
required this.downloadUrl,
this.apkDownloadUrl,
required this.publishedAt,
this.isPrerelease = false,
});
}
class UpdateChecker {
static const String _apiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest';
static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases';
static Future<String> _getDeviceArch() async {
if (!Platform.isAndroid) return 'unknown';
@@ -55,30 +58,59 @@ class UpdateChecker {
}
}
static Future<UpdateInfo?> checkForUpdate() async {
/// Check for updates based on channel preference
/// [channel] can be 'stable' or 'preview'
static Future<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
try {
final response = await http.get(
Uri.parse(_apiUrl),
headers: {'Accept': 'application/vnd.github.v3+json'},
).timeout(const Duration(seconds: 10));
Map<String, dynamic>? releaseData;
if (channel == 'preview') {
// For preview channel, get all releases and find the latest (including prereleases)
final response = await http.get(
Uri.parse('$_allReleasesApiUrl?per_page=10'),
headers: {'Accept': 'application/vnd.github.v3+json'},
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
_log.w('GitHub API returned ${response.statusCode}');
return null;
if (response.statusCode != 200) {
_log.w('GitHub API returned ${response.statusCode}');
return null;
}
final releases = jsonDecode(response.body) as List<dynamic>;
if (releases.isEmpty) {
_log.i('No releases found');
return null;
}
// First release is the latest (including prereleases)
releaseData = releases.first as Map<String, dynamic>;
} else {
// For stable channel, use /latest endpoint (excludes prereleases)
final response = await http.get(
Uri.parse(_latestApiUrl),
headers: {'Accept': 'application/vnd.github.v3+json'},
).timeout(const Duration(seconds: 10));
if (response.statusCode != 200) {
_log.w('GitHub API returned ${response.statusCode}');
return null;
}
releaseData = jsonDecode(response.body) as Map<String, dynamic>;
}
final data = jsonDecode(response.body) as Map<String, dynamic>;
final tagName = data['tag_name'] as String? ?? '';
final tagName = releaseData['tag_name'] as String? ?? '';
final latestVersion = tagName.replaceFirst('v', '');
final isPrerelease = releaseData['prerelease'] as bool? ?? false;
if (!_isNewerVersion(latestVersion, AppInfo.version)) {
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion)');
_log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)');
return null;
}
final body = data['body'] as String? ?? 'No changelog available';
final htmlUrl = data['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
final publishedAt = DateTime.tryParse(data['published_at'] as String? ?? '') ?? DateTime.now();
final body = releaseData['body'] as String? ?? 'No changelog available';
final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases';
final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now();
final deviceArch = await _getDeviceArch();
_log.d('Device architecture: $deviceArch');
@@ -87,7 +119,7 @@ class UpdateChecker {
String? arm32Url;
String? universalUrl;
final assets = data['assets'] as List<dynamic>? ?? [];
final assets = releaseData['assets'] as List<dynamic>? ?? [];
for (final asset in assets) {
final name = (asset['name'] as String? ?? '').toLowerCase();
if (name.endsWith('.apk')) {
@@ -117,7 +149,7 @@ class UpdateChecker {
apkUrl = universalUrl ?? arm64Url ?? arm32Url;
}
_log.i('Update available: $latestVersion, APK URL: $apkUrl');
_log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl');
return UpdateInfo(
version: latestVersion,
@@ -125,6 +157,7 @@ class UpdateChecker {
downloadUrl: htmlUrl,
apkDownloadUrl: apkUrl,
publishedAt: publishedAt,
isPrerelease: isPrerelease,
);
} catch (e) {
_log.e('Error checking for updates: $e');
+11 -9
View File
@@ -43,6 +43,7 @@ class AppTheme {
static ThemeData dark({
ColorScheme? dynamicScheme,
Color? seedColor,
bool isAmoled = false,
}) {
final scheme = dynamicScheme ??
ColorScheme.fromSeed(
@@ -53,7 +54,8 @@ class AppTheme {
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
appBarTheme: _appBarTheme(scheme),
scaffoldBackgroundColor: isAmoled ? Colors.black : null,
appBarTheme: _appBarTheme(scheme, isAmoled: isAmoled),
cardTheme: _cardTheme(scheme),
elevatedButtonTheme: _elevatedButtonTheme(scheme),
filledButtonTheme: _filledButtonTheme(scheme),
@@ -63,7 +65,7 @@ class AppTheme {
inputDecorationTheme: _inputDecorationTheme(scheme),
listTileTheme: _listTileTheme(scheme),
dialogTheme: _dialogTheme(scheme),
navigationBarTheme: _navigationBarTheme(scheme),
navigationBarTheme: _navigationBarTheme(scheme, isAmoled: isAmoled),
snackBarTheme: _snackBarTheme(scheme),
progressIndicatorTheme: _progressIndicatorTheme(scheme),
switchTheme: _switchTheme(scheme),
@@ -73,12 +75,12 @@ class AppTheme {
}
/// AppBar theme
static AppBarTheme _appBarTheme(ColorScheme scheme) => AppBarTheme(
static AppBarTheme _appBarTheme(ColorScheme scheme, {bool isAmoled = false}) => AppBarTheme(
elevation: 0,
scrolledUnderElevation: 3,
backgroundColor: scheme.surface,
scrolledUnderElevation: isAmoled ? 0 : 3,
backgroundColor: isAmoled ? Colors.black : scheme.surface,
foregroundColor: scheme.onSurface,
surfaceTintColor: scheme.surfaceTint,
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
centerTitle: true,
titleTextStyle: TextStyle(
color: scheme.onSurface,
@@ -180,12 +182,12 @@ class AppTheme {
);
/// Navigation bar theme
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme) =>
static NavigationBarThemeData _navigationBarTheme(ColorScheme scheme, {bool isAmoled = false}) =>
NavigationBarThemeData(
elevation: 0,
backgroundColor: scheme.surfaceContainer,
backgroundColor: isAmoled ? Colors.black : scheme.surfaceContainer,
indicatorColor: scheme.secondaryContainer,
surfaceTintColor: scheme.surfaceTint,
surfaceTintColor: isAmoled ? Colors.transparent : scheme.surfaceTint,
labelBehavior: NavigationDestinationLabelBehavior.alwaysShow,
);
+21 -1
View File
@@ -40,12 +40,32 @@ class DynamicColorWrapper extends ConsumerWidget {
);
}
// Apply AMOLED mode if enabled (pure black background)
if (themeSettings.useAmoled) {
darkScheme = _applyAmoledColors(darkScheme);
}
// Build themes
final lightTheme = AppTheme.light(dynamicScheme: lightScheme);
final darkTheme = AppTheme.dark(dynamicScheme: darkScheme);
final darkTheme = AppTheme.dark(dynamicScheme: darkScheme, isAmoled: themeSettings.useAmoled);
return builder(lightTheme, darkTheme, themeSettings.themeMode);
},
);
}
/// Apply AMOLED colors - pure black background with adjusted surface colors
ColorScheme _applyAmoledColors(ColorScheme scheme) {
return scheme.copyWith(
surface: Colors.black,
onSurface: Colors.white,
surfaceContainerLowest: Colors.black,
surfaceContainerLow: const Color(0xFF0A0A0A),
surfaceContainer: const Color(0xFF121212),
surfaceContainerHigh: const Color(0xFF1A1A1A),
surfaceContainerHighest: const Color(0xFF222222),
inverseSurface: Colors.white,
onInverseSurface: Colors.black,
);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none'
version: 2.0.7+37
version: 2.1.0-preview2+40
environment:
sdk: ^3.10.0
+1 -1
View File
@@ -1,7 +1,7 @@
name: spotiflac_android
description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music
publish_to: 'none'
version: 2.0.7+37
version: 2.1.0-preview2+40
environment:
sdk: ^3.10.0