From 03fd734048c798468b5efdede9f13d485efef163 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 19:55:02 +0700 Subject: [PATCH 01/33] perf: lazy extension VM init, incremental startup maintenance, and UI optimizations - Defer extension VM initialization until first use with lockReadyVM() pattern to eliminate TOCTOU races and reduce startup overhead - Add validateExtensionLoad() to catch JS errors at install time without keeping VM alive - Teardown VM on extension disable to free resources; re-init lazily on re-enable - Replace full orphan cleanup with incremental cursor-based pagination across launches - Batch DB writes (upsertBatch, replaceAll) with transactions for atomicity - Parse JSON natively on Kotlin side to avoid double-serialization over MethodChannel - Add identity-based memoization caches for unified items and path match keys in queue tab - Use ValueListenableBuilder for targeted embedded cover refreshes instead of full setState - Extract shared widgets (_buildAlbumGridItemCore, _buildFilterButton, _navigateWithUnfocus) - Use libraryCollectionsProvider selector and MediaQuery.paddingOf for fewer rebuilds - Simplify supporter chip tiers and localize remaining hardcoded strings --- README.md | 10 +- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 43 +- go_backend/exports.go | 22 +- go_backend/extension_manager.go | 355 ++++-- go_backend/extension_providers.go | 84 +- lib/providers/download_queue_provider.dart | 282 ++++- lib/providers/local_library_provider.dart | 20 +- lib/screens/home_tab.dart | 18 +- lib/screens/library_playlists_screen.dart | 44 +- lib/screens/library_tracks_folder_screen.dart | 42 +- lib/screens/queue_tab.dart | 1111 ++++++++--------- lib/screens/settings/donate_page.dart | 198 +-- lib/services/history_database.dart | 37 + lib/services/library_database.dart | 43 +- lib/services/platform_bridge.dart | 44 +- lib/utils/path_match_keys.dart | 15 +- lib/widgets/bottom_sheet_option_tile.dart | 40 + lib/widgets/download_service_picker.dart | 4 +- 18 files changed, 1279 insertions(+), 1133 deletions(-) create mode 100644 lib/widgets/bottom_sheet_option_tile.dart diff --git a/README.md b/README.md index af839c93..1ae27fbf 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,11 @@ In AltStore/SideStore, go to **Browse > Sources**, tap **+**, and paste the link +> [!NOTE] +> If SpotiFLAC is useful to you, consider supporting development: +> +> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet) + --- ## Contributors @@ -165,10 +170,5 @@ Interested in contributing? Check out the [Contributing Guide](CONTRIBUTING.md) | [dabmusic.xyz](https://dabmusic.xyz) | [AfkarXYZ](https://github.com/afkarxyz) | [LRCLib](https://lrclib.net) | [Paxsenix](https://lyrics.paxsenix.org) | [Cobalt](https://cobalt.tools) | | [qwkuns.me](https://qwkuns.me) | [SpotubeDL](https://spotubedl.com) | [Song.link](https://song.link) | [IDHS](https://github.com/sjdonado/idonthavespotify) | | -> [!NOTE] -> If SpotiFLAC is useful to you, consider supporting development: -> -> [![Ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/zarzet) - > [!TIP] > **Star the repo** to get notified about all new releases directly from GitHub. diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 3fba0054..e282cc52 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -27,6 +27,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject +import org.json.JSONTokener import java.io.File import java.io.FileInputStream import java.io.FileOutputStream @@ -413,6 +414,38 @@ class MainActivity: FlutterFragmentActivity() { } } + private fun parseJsonValue(value: Any?): Any? { + return when (value) { + null, JSONObject.NULL -> null + is JSONObject -> { + val map = LinkedHashMap() + val keys = value.keys() + while (keys.hasNext()) { + val key = keys.next() + map[key] = parseJsonValue(value.opt(key)) + } + map + } + is JSONArray -> { + val list = ArrayList() + for (i in 0 until value.length()) { + list.add(parseJsonValue(value.opt(i))) + } + list + } + is Number, is Boolean, is String -> value + else -> value.toString() + } + } + + private fun parseJsonPayload(payload: String): Any { + return try { + parseJsonValue(JSONTokener(payload).nextValue()) ?: payload + } catch (_: Exception) { + payload + } + } + private fun startDownloadProgressStream(sink: EventChannel.EventSink) { stopDownloadProgressStream() downloadProgressEventSink = sink @@ -425,7 +458,7 @@ class MainActivity: FlutterFragmentActivity() { } if (payload != lastDownloadProgressPayload) { lastDownloadProgressPayload = payload - sink.success(payload) + sink.success(parseJsonPayload(payload)) } } catch (e: Exception) { android.util.Log.w( @@ -457,7 +490,7 @@ class MainActivity: FlutterFragmentActivity() { } if (payload != lastLibraryScanProgressPayload) { lastLibraryScanProgressPayload = payload - sink.success(payload) + sink.success(parseJsonPayload(payload)) } } catch (e: Exception) { android.util.Log.w( @@ -2000,13 +2033,13 @@ class MainActivity: FlutterFragmentActivity() { val response = withContext(Dispatchers.IO) { Gobackend.getDownloadProgress() } - result.success(response) + result.success(parseJsonPayload(response)) } "getAllDownloadProgress" -> { val response = withContext(Dispatchers.IO) { Gobackend.getAllDownloadProgress() } - result.success(response) + result.success(parseJsonPayload(response)) } "initItemProgress" -> { val itemId = call.argument("item_id") ?: "" @@ -3298,7 +3331,7 @@ class MainActivity: FlutterFragmentActivity() { Gobackend.getLibraryScanProgressJSON() } } - result.success(response) + result.success(parseJsonPayload(response)) } "cancelLibraryScan" -> { withContext(Dispatchers.IO) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 39184350..a1cf605b 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -2187,12 +2187,6 @@ func LoadExtensionFromPath(filePath string) (string, error) { return "", err } - settingsStore := GetExtensionSettingsStore() - settings := settingsStore.GetAll(ext.ID) - if len(settings) > 0 { - manager.InitializeExtension(ext.ID, settings) - } - result := map[string]interface{}{ "id": ext.ID, "name": ext.Manifest.Name, @@ -2226,12 +2220,6 @@ func UpgradeExtensionFromPath(filePath string) (string, error) { return "", err } - settingsStore := GetExtensionSettingsStore() - settings := settingsStore.GetAll(ext.ID) - if len(settings) > 0 { - manager.InitializeExtension(ext.ID, settings) - } - result := map[string]interface{}{ "id": ext.ID, "display_name": ext.Manifest.DisplayName, @@ -3324,12 +3312,14 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du if !ext.Enabled { return "", fmt.Errorf("extension '%s' is disabled", extensionID) } + vm, err := ext.lockReadyVM() + if err != nil { + return "", err + } + defer ext.VMMu.Unlock() // Goja runtime is not thread-safe; guard direct extension.*() calls with VMMu // to avoid races with other provider calls (e.g. getAlbum/getPlaylist). - ext.VMMu.Lock() - defer ext.VMMu.Unlock() - script := fmt.Sprintf(` (function() { if (typeof extension !== 'undefined' && typeof extension.%s === 'function') { @@ -3339,7 +3329,7 @@ func callExtensionFunctionJSON(extensionID, functionName string, timeout time.Du })() `, functionName, functionName) - result, err := RunWithTimeoutAndRecover(ext.VM, script, timeout) + result, err := RunWithTimeoutAndRecover(vm, script, timeout) if err != nil { return "", fmt.Errorf("%s failed: %w", functionName, err) } diff --git a/go_backend/extension_manager.go b/go_backend/extension_manager.go index d2a1f8d3..8f9e5dfd 100644 --- a/go_backend/extension_manager.go +++ b/go_backend/extension_manager.go @@ -44,16 +44,76 @@ func compareVersions(v1, v2 string) int { } type LoadedExtension struct { - ID string `json:"id"` - Manifest *ExtensionManifest `json:"manifest"` - VM *goja.Runtime `json:"-"` - VMMu sync.Mutex `json:"-"` - runtime *ExtensionRuntime - Enabled bool `json:"enabled"` - Error string `json:"error,omitempty"` - DataDir string `json:"data_dir"` - SourceDir string `json:"source_dir"` - IconPath string `json:"icon_path"` + ID string `json:"id"` + Manifest *ExtensionManifest `json:"manifest"` + VM *goja.Runtime `json:"-"` + VMMu sync.Mutex `json:"-"` + runtime *ExtensionRuntime + initialized bool + Enabled bool `json:"enabled"` + Error string `json:"error,omitempty"` + DataDir string `json:"data_dir"` + SourceDir string `json:"source_dir"` + IconPath string `json:"icon_path"` +} + +func getExtensionInitSettings(extensionID string) map[string]interface{} { + settings := GetExtensionSettingsStore().GetAll(extensionID) + if len(settings) == 0 { + return settings + } + + filtered := make(map[string]interface{}, len(settings)) + for key, value := range settings { + if strings.HasPrefix(key, "_") { + continue + } + filtered[key] = value + } + return filtered +} + +func ensureRuntimeReadyLocked(ext *LoadedExtension, applyStoredSettings bool) error { + if ext.VM == nil || ext.runtime == nil { + if err := initializeVMLocked(ext); err != nil { + ext.Error = err.Error() + ext.Enabled = false + return err + } + } + + if applyStoredSettings && !ext.initialized { + settings := getExtensionInitSettings(ext.ID) + if len(settings) > 0 { + if err := initializeExtensionWithSettingsLocked(ext, settings); err != nil { + teardownVMLocked(ext) + ext.Error = err.Error() + ext.Enabled = false + return err + } + } else { + ext.initialized = true + } + } + + ext.Error = "" + return nil +} + +func (ext *LoadedExtension) ensureRuntimeReady() error { + ext.VMMu.Lock() + defer ext.VMMu.Unlock() + + return ensureRuntimeReadyLocked(ext, true) +} + +func (ext *LoadedExtension) lockReadyVM() (*goja.Runtime, error) { + ext.VMMu.Lock() + if err := ensureRuntimeReadyLocked(ext, true); err != nil { + ext.VMMu.Unlock() + return nil, err + } + return ext.VM, nil } type ExtensionManager struct { @@ -220,10 +280,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens SourceDir: extDir, } - if err := m.initializeVM(ext); err != nil { + if err := validateExtensionLoad(ext); err != nil { ext.Error = err.Error() ext.Enabled = false - GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err) + GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err) } m.extensions[manifest.Name] = ext @@ -232,7 +292,10 @@ func (m *ExtensionManager) LoadExtensionFromFile(filePath string) (*LoadedExtens return ext, nil } -func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { +func initializeVMLocked(ext *LoadedExtension) error { + ext.VM = nil + ext.runtime = nil + ext.initialized = false vm := goja.New() ext.VM = vm @@ -279,6 +342,136 @@ func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { return nil } +func (m *ExtensionManager) initializeVM(ext *LoadedExtension) error { + ext.VMMu.Lock() + defer ext.VMMu.Unlock() + return initializeVMLocked(ext) +} + +func initializeExtensionWithSettingsLocked( + ext *LoadedExtension, + settings map[string]interface{}, +) error { + if ext.VM == nil { + return fmt.Errorf("Extension failed to load. Please reinstall the extension") + } + + settingsJSON, err := json.Marshal(settings) + if err != nil { + return fmt.Errorf("Failed to save settings") + } + + script := fmt.Sprintf(` + (function() { + var settings = %s; + if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') { + try { + extension.initialize(settings); + return { success: true }; + } catch (e) { + return { success: false, error: e.toString() }; + } + } + return { success: true, message: 'no initialize function' }; + })() + `, string(settingsJSON)) + + result, err := ext.VM.RunString(script) + if err != nil { + ext.Error = fmt.Sprintf("initialize failed: %v", err) + ext.Enabled = false + GoLog("[Extension] Initialize error for %s: %v\n", ext.ID, err) + return err + } + + if result != nil && !goja.IsUndefined(result) { + exported := result.Export() + if resultMap, ok := exported.(map[string]interface{}); ok { + if success, ok := resultMap["success"].(bool); ok && !success { + errMsg := "unknown error" + if e, ok := resultMap["error"].(string); ok { + errMsg = e + } + ext.Error = errMsg + ext.Enabled = false + GoLog("[Extension] Initialize failed for %s: %s\n", ext.ID, errMsg) + return fmt.Errorf("initialize failed: %s", errMsg) + } + } + } + + ext.initialized = true + GoLog("[Extension] Initialized %s\n", ext.ID) + return nil +} + +func runCleanupLocked(ext *LoadedExtension) error { + if ext.VM != nil { + script := ` + (function() { + if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { + try { + extension.cleanup(); + return { success: true }; + } catch (e) { + return { success: false, error: e.toString() }; + } + } + return { success: true, message: 'no cleanup function' }; + })() + ` + + result, err := ext.VM.RunString(script) + if err != nil { + return err + } + + if result != nil && !goja.IsUndefined(result) { + exported := result.Export() + if resultMap, ok := exported.(map[string]interface{}); ok { + if success, ok := resultMap["success"].(bool); ok && !success { + errMsg := "unknown error" + if e, ok := resultMap["error"].(string); ok { + errMsg = e + } + return fmt.Errorf("cleanup failed: %s", errMsg) + } + } + } + + if result != nil && !goja.IsUndefined(result) && !goja.IsNull(result) { + GoLog("[Extension] Cleanup called for %s\n", ext.ID) + } + } + return nil +} + +func teardownVMLocked(ext *LoadedExtension) { + if err := runCleanupLocked(ext); err != nil { + GoLog("[Extension] Error calling cleanup for %s: %v\n", ext.ID, err) + } + if ext.runtime != nil { + if err := ext.runtime.flushStorageNow(); err != nil { + GoLog("[Extension] Failed to flush storage for %s: %v\n", ext.ID, err) + } + ext.runtime.closeStorageFlusher() + } + ext.runtime = nil + ext.VM = nil + ext.initialized = false +} + +func validateExtensionLoad(ext *LoadedExtension) error { + ext.VMMu.Lock() + defer ext.VMMu.Unlock() + + if err := initializeVMLocked(ext); err != nil { + return err + } + teardownVMLocked(ext) + return nil +} + func (m *ExtensionManager) UnloadExtension(extensionID string) error { m.mu.Lock() defer m.mu.Unlock() @@ -288,21 +481,9 @@ func (m *ExtensionManager) UnloadExtension(extensionID string) error { return fmt.Errorf("Extension not found") } - if ext.VM != nil { - cleanup, err := ext.VM.RunString("typeof extension !== 'undefined' && typeof extension.cleanup === 'function' ? extension.cleanup() : null") - if err != nil { - GoLog("[Extension] Error calling cleanup for %s: %v\n", extensionID, err) - } else if cleanup != nil && !goja.IsUndefined(cleanup) && !goja.IsNull(cleanup) { - GoLog("[Extension] Cleanup called for %s\n", extensionID) - } - } - if ext.runtime != nil { - if err := ext.runtime.flushStorageNow(); err != nil { - GoLog("[Extension] Failed to flush storage for %s: %v\n", extensionID, err) - } - ext.runtime.closeStorageFlusher() - ext.runtime = nil - } + ext.VMMu.Lock() + teardownVMLocked(ext) + ext.VMMu.Unlock() delete(m.extensions, extensionID) GoLog("[Extension] Unloaded extension: %s\n", extensionID) @@ -341,7 +522,21 @@ func (m *ExtensionManager) SetExtensionEnabled(extensionID string, enabled bool) return fmt.Errorf("Extension not found") } - ext.Enabled = enabled + if enabled { + ext.Enabled = true + if err := ext.ensureRuntimeReady(); err != nil { + store := GetExtensionSettingsStore() + ext.Enabled = false + _ = store.Set(extensionID, "_enabled", false) + return err + } + } else { + ext.Enabled = false + ext.Error = "" + ext.VMMu.Lock() + teardownVMLocked(ext) + ext.VMMu.Unlock() + } GoLog("[Extension] %s %s\n", extensionID, map[bool]string{true: "enabled", false: "disabled"}[enabled]) store := GetExtensionSettingsStore() @@ -436,10 +631,10 @@ func (m *ExtensionManager) loadExtensionFromDirectory(dirPath string) (*LoadedEx } } - if err := m.initializeVM(ext); err != nil { + if err := validateExtensionLoad(ext); err != nil { ext.Error = err.Error() ext.Enabled = false - GoLog("[Extension] Failed to initialize VM for %s: %v\n", manifest.Name, err) + GoLog("[Extension] Failed to validate extension %s: %v\n", manifest.Name, err) } m.extensions[manifest.Name] = ext @@ -590,10 +785,14 @@ func (m *ExtensionManager) UpgradeExtension(filePath string) (*LoadedExtension, SourceDir: extDir, } - if err := m.initializeVM(ext); err != nil { + if wasEnabled { + if err := ext.ensureRuntimeReady(); err != nil { + GoLog("[Extension] Failed to initialize upgraded extension %s: %v\n", newManifest.Name, err) + } + } else if err := validateExtensionLoad(ext); err != nil { ext.Error = err.Error() ext.Enabled = false - GoLog("[Extension] Failed to initialize VM for %s: %v\n", newManifest.Name, err) + GoLog("[Extension] Failed to validate upgraded extension %s: %v\n", newManifest.Name, err) } m.mu.Lock() @@ -790,56 +989,13 @@ func (m *ExtensionManager) InitializeExtension(extensionID string, settings map[ return fmt.Errorf("Extension not found") } - if ext.VM == nil { - return fmt.Errorf("Extension failed to load. Please reinstall the extension") - } + ext.VMMu.Lock() + defer ext.VMMu.Unlock() - settingsJSON, err := json.Marshal(settings) - if err != nil { - return fmt.Errorf("Failed to save settings") - } - - script := fmt.Sprintf(` - (function() { - var settings = %s; - if (typeof extension !== 'undefined' && typeof extension.initialize === 'function') { - try { - extension.initialize(settings); - return { success: true }; - } catch (e) { - return { success: false, error: e.toString() }; - } - } - return { success: true, message: 'no initialize function' }; - })() - `, string(settingsJSON)) - - result, err := ext.VM.RunString(script) - if err != nil { - ext.Error = fmt.Sprintf("initialize failed: %v", err) - ext.Enabled = false - GoLog("[Extension] Initialize error for %s: %v\n", extensionID, err) + if err := ensureRuntimeReadyLocked(ext, false); err != nil { return err } - - if result != nil && !goja.IsUndefined(result) { - exported := result.Export() - if resultMap, ok := exported.(map[string]interface{}); ok { - if success, ok := resultMap["success"].(bool); ok && !success { - errMsg := "unknown error" - if e, ok := resultMap["error"].(string); ok { - errMsg = e - } - ext.Error = errMsg - ext.Enabled = false - GoLog("[Extension] Initialize failed for %s: %s\n", extensionID, errMsg) - return fmt.Errorf("initialize failed: %s", errMsg) - } - } - } - - GoLog("[Extension] Initialized %s\n", extensionID) - return nil + return initializeExtensionWithSettingsLocked(ext, settings) } func (m *ExtensionManager) CleanupExtension(extensionID string) error { @@ -854,41 +1010,12 @@ func (m *ExtensionManager) CleanupExtension(extensionID string) error { if ext.VM == nil { return nil } - - script := ` - (function() { - if (typeof extension !== 'undefined' && typeof extension.cleanup === 'function') { - try { - extension.cleanup(); - return { success: true }; - } catch (e) { - return { success: false, error: e.toString() }; - } - } - return { success: true, message: 'no cleanup function' }; - })() - ` - - result, err := ext.VM.RunString(script) - if err != nil { + ext.VMMu.Lock() + defer ext.VMMu.Unlock() + if err := runCleanupLocked(ext); err != nil { GoLog("[Extension] Cleanup error for %s: %v\n", extensionID, err) return err } - - if result != nil && !goja.IsUndefined(result) { - exported := result.Export() - if resultMap, ok := exported.(map[string]interface{}); ok { - if success, ok := resultMap["success"].(bool); ok && !success { - errMsg := "unknown error" - if e, ok := resultMap["error"].(string); ok { - errMsg = e - } - GoLog("[Extension] Cleanup failed for %s: %s\n", extensionID, errMsg) - return fmt.Errorf("cleanup failed: %s", errMsg) - } - } - } - GoLog("[Extension] Cleaned up %s\n", extensionID) return nil } @@ -917,8 +1044,8 @@ func (m *ExtensionManager) InvokeAction(extensionID string, actionName string) ( return nil, fmt.Errorf("extension not found: %s", extensionID) } - if ext.VM == nil { - return nil, fmt.Errorf("extension VM not initialized") + if err := ext.ensureRuntimeReady(); err != nil { + return nil, err } if !ext.Enabled { diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index a3e46450..ecddfbcf 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -125,6 +125,15 @@ func NewExtensionProviderWrapper(ext *LoadedExtension) *ExtensionProviderWrapper } } +func (p *ExtensionProviderWrapper) lockReadyVM() error { + vm, err := p.extension.lockReadyVM() + if err != nil { + return err + } + p.vm = vm + return nil +} + func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSearchResult, error) { if !p.extension.Manifest.IsMetadataProvider() { return nil, fmt.Errorf("extension '%s' is not a metadata provider", p.extension.ID) @@ -133,8 +142,9 @@ func (p *ExtensionProviderWrapper) SearchTracks(query string, limit int) (*ExtSe if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -192,8 +202,9 @@ func (p *ExtensionProviderWrapper) GetTrack(trackID string) (*ExtTrackMetadata, if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -240,8 +251,9 @@ func (p *ExtensionProviderWrapper) GetAlbum(albumID string) (*ExtAlbumMetadata, if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -291,8 +303,9 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -345,8 +358,10 @@ func (p *ExtensionProviderWrapper) EnrichTrack(track *ExtTrackMetadata) (*ExtTra if !p.extension.Enabled { return track, nil } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + GoLog("[Extension] EnrichTrack init error for %s: %v\n", p.extension.ID, err) + return track, nil + } defer p.extension.VMMu.Unlock() trackJSON, err := json.Marshal(track) @@ -405,8 +420,9 @@ func (p *ExtensionProviderWrapper) CheckAvailability(isrc, trackName, artistName if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -452,8 +468,9 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -501,8 +518,13 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return &ExtDownloadResult{ + Success: false, + ErrorMessage: err.Error(), + ErrorType: "init_error", + }, nil + } defer p.extension.VMMu.Unlock() p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { @@ -1626,8 +1648,9 @@ func (p *ExtensionProviderWrapper) CustomSearch(query string, options map[string if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() if options == nil { @@ -1707,8 +1730,9 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() script := fmt.Sprintf(` @@ -1792,8 +1816,9 @@ func (p *ExtensionProviderWrapper) MatchTrack(sourceTrack map[string]interface{} if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() sourceJSON, _ := json.Marshal(sourceTrack) @@ -1862,8 +1887,9 @@ func (p *ExtensionProviderWrapper) PostProcess(filePath string, metadata map[str if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return &PostProcessResult{Success: false, Error: err.Error()}, nil + } defer p.extension.VMMu.Unlock() metadataJSON, _ := json.Marshal(metadata) @@ -1924,8 +1950,9 @@ func (p *ExtensionProviderWrapper) PostProcessV2(input PostProcessInput, metadat if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return &PostProcessResult{Success: false, Error: err.Error()}, nil + } defer p.extension.VMMu.Unlock() metadataJSON, _ := json.Marshal(metadata) @@ -2182,8 +2209,9 @@ func (p *ExtensionProviderWrapper) FetchLyrics(trackName, artistName, albumName if !p.extension.Enabled { return nil, fmt.Errorf("extension '%s' is disabled", p.extension.ID) } - - p.extension.VMMu.Lock() + if err := p.lockReadyVM(); err != nil { + return nil, err + } defer p.extension.VMMu.Unlock() // Use global variables to avoid JS injection issues with special characters in track/artist names diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index a457b3d2..5ee904db 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:path_provider/path_provider.dart'; import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/models/download_item.dart'; import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/models/track.dart'; @@ -262,8 +263,14 @@ class DownloadHistoryState { class DownloadHistoryNotifier extends Notifier { static const int _safRepairBatchSize = 20; static const int _safRepairMaxPerLaunch = 60; + static const int _orphanCleanupMaxPerLaunch = 80; static const int _audioMetadataBackfillMaxPerLaunch = 24; - static const _startupMaintenanceDelay = Duration(seconds: 2); + static const _startupMaintenanceDelay = Duration(seconds: 4); + static const _startupMaintenanceStepGap = Duration(milliseconds: 250); + static const _startupSafRepairCursorKey = + 'history_startup_saf_repair_cursor_v1'; + static const _startupOrphanCursorKey = 'history_startup_orphan_cursor_v1'; + static const _startupAudioCursorKey = 'history_startup_audio_cursor_v1'; final HistoryDatabase _db = HistoryDatabase.instance; bool _isLoaded = false; bool _isSafRepairInProgress = false; @@ -320,20 +327,29 @@ class DownloadHistoryNotifier extends Notifier { unawaited( Future.delayed(_startupMaintenanceDelay, () async { try { + final prefs = await SharedPreferences.getInstance(); + if (Platform.isAndroid) { await _repairMissingSafEntries( initialItems, maxItems: _safRepairMaxPerLaunch, + prefs: prefs, ); + await Future.delayed(_startupMaintenanceStepGap); } - await cleanupOrphanedDownloads(); + await _cleanupOrphanedDownloadsIncremental( + maxItems: _orphanCleanupMaxPerLaunch, + prefs: prefs, + ); + await Future.delayed(_startupMaintenanceStepGap); final currentItems = state.items; if (currentItems.isNotEmpty) { await _backfillAudioMetadata( currentItems, maxItems: _audioMetadataBackfillMaxPerLaunch, + prefs: prefs, ); } } catch (e, stack) { @@ -344,6 +360,34 @@ class DownloadHistoryNotifier extends Notifier { ); } + int _readStartupCursor( + SharedPreferences prefs, + String key, + int totalCount, + ) { + if (totalCount <= 0) { + return 0; + } + final cursor = prefs.getInt(key) ?? 0; + if (cursor < 0 || cursor >= totalCount) { + return 0; + } + return cursor; + } + + Future _writeStartupCursor( + SharedPreferences prefs, + String key, + int nextCursor, + int totalCount, + ) async { + if (totalCount <= 0 || nextCursor <= 0 || nextCursor >= totalCount) { + await prefs.remove(key); + return; + } + await prefs.setInt(key, nextCursor); + } + String _fileNameFromUri(String uri) { try { final parsed = Uri.parse(uri); @@ -357,6 +401,7 @@ class DownloadHistoryNotifier extends Notifier { Future _repairMissingSafEntries( List items, { required int maxItems, + required SharedPreferences prefs, }) async { if (_isSafRepairInProgress || items.isEmpty) { return; @@ -378,22 +423,37 @@ class DownloadHistoryNotifier extends Notifier { continue; } candidateIndexes.add(i); - if (candidateIndexes.length >= maxItems) break; } if (candidateIndexes.isEmpty) { + await prefs.remove(_startupSafRepairCursorKey); + _isSafRepairInProgress = false; + return; + } + + final startCursor = _readStartupCursor( + prefs, + _startupSafRepairCursorKey, + candidateIndexes.length, + ); + final endCursor = (startCursor + maxItems).clamp(0, candidateIndexes.length); + final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor); + + if (selectedIndexes.isEmpty) { + await prefs.remove(_startupSafRepairCursorKey); _isSafRepairInProgress = false; return; } final updatedItems = [...items]; + final persistedUpdates = >[]; var changed = false; var repairedCount = 0; var verifiedCount = 0; try { - for (var c = 0; c < candidateIndexes.length; c++) { - final i = candidateIndexes[c]; + for (var c = 0; c < selectedIndexes.length; c++) { + final i = selectedIndexes[c]; final item = items[i]; final rawPath = item.filePath.trim(); final isDirectSafUri = rawPath.isNotEmpty && isContentUri(rawPath); @@ -408,7 +468,7 @@ class DownloadHistoryNotifier extends Notifier { updatedItems[i] = verified; changed = true; verifiedCount++; - await _db.upsert(verified.toJson()); + persistedUpdates.add(verified.toJson()); continue; } } @@ -445,7 +505,7 @@ class DownloadHistoryNotifier extends Notifier { updatedItems[i] = updated; changed = true; repairedCount++; - await _db.upsert(updated.toJson()); + persistedUpdates.add(updated.toJson()); } catch (e) { _historyLog.w('Failed to repair SAF URI: $e'); } @@ -456,11 +516,18 @@ class DownloadHistoryNotifier extends Notifier { } if (changed) { + await _db.upsertBatch(persistedUpdates); state = state.copyWith(items: updatedItems); _historyLog.i( - 'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${candidateIndexes.length}', + 'SAF repair pass: verified=$verifiedCount, repaired=$repairedCount, checked=${selectedIndexes.length}', ); } + await _writeStartupCursor( + prefs, + _startupSafRepairCursorKey, + endCursor, + candidateIndexes.length, + ); } finally { _isSafRepairInProgress = false; } @@ -556,6 +623,7 @@ class DownloadHistoryNotifier extends Notifier { Future _backfillAudioMetadata( List items, { required int maxItems, + required SharedPreferences prefs, }) async { if (_isAudioMetadataBackfillInProgress || items.isEmpty) { return; @@ -563,15 +631,37 @@ class DownloadHistoryNotifier extends Notifier { _isAudioMetadataBackfillInProgress = true; try { + final candidateIndexes = []; + for (var i = 0; i < items.length; i++) { + if (_shouldBackfillAudioMetadata(items[i])) { + candidateIndexes.add(i); + } + } + + if (candidateIndexes.isEmpty) { + await prefs.remove(_startupAudioCursorKey); + return; + } + + final startCursor = _readStartupCursor( + prefs, + _startupAudioCursorKey, + candidateIndexes.length, + ); + final endCursor = (startCursor + maxItems).clamp(0, candidateIndexes.length); + final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor); + + if (selectedIndexes.isEmpty) { + await prefs.remove(_startupAudioCursorKey); + return; + } + + List? updatedItems; + final persistedUpdates = >[]; var refreshedCount = 0; - for (final item in items) { - if (refreshedCount >= maxItems) { - break; - } - if (!_shouldBackfillAudioMetadata(item)) { - continue; - } + for (final index in selectedIndexes) { + final item = items[index]; final probed = await _probeAudioMetadata( item.filePath, @@ -598,15 +688,29 @@ class DownloadHistoryNotifier extends Notifier { continue; } - await updateAudioMetadataForItem( - id: item.id, + final updated = item.copyWith( quality: resolvedQuality, bitDepth: resolvedBitDepth, sampleRate: resolvedSampleRate, ); + updatedItems ??= [...items]; + updatedItems[index] = updated; + persistedUpdates.add(updated.toJson()); refreshedCount++; } + if (persistedUpdates.isNotEmpty && updatedItems != null) { + await _db.upsertBatch(persistedUpdates); + state = state.copyWith(items: updatedItems); + } + + await _writeStartupCursor( + prefs, + _startupAudioCursorKey, + endCursor, + candidateIndexes.length, + ); + if (refreshedCount > 0) { _historyLog.i( 'Audio metadata backfill refreshed $refreshedCount items', @@ -801,11 +905,15 @@ class DownloadHistoryNotifier extends Notifier { return null; } - Future cleanupOrphanedDownloads() async { - _historyLog.i('Starting orphaned downloads cleanup...'); - - final entries = await _db.getAllEntriesWithPaths(); + Future< + ({ + List orphanedIds, + Map replacementPaths, + Map pathById, + }) + > _inspectOrphanedEntries(List> entries) async { final orphanedIds = []; + final replacementPaths = {}; final pathById = {}; const checkChunkSize = 16; @@ -824,14 +932,10 @@ class DownloadHistoryNotifier extends Notifier { try { if (await fileExists(filePath)) return MapEntry(id, true); - // Original file missing -- check for a converted sibling. final sibling = await _findConvertedSibling(filePath); if (sibling != null) { - _historyLog.i( - 'Found converted sibling for $id: $filePath → $sibling', - ); - // Update the stored path so future checks succeed immediately. - await _db.updateFilePath(id, sibling); + _historyLog.i('Found converted sibling for $id: $filePath -> $sibling'); + replacementPaths[id] = sibling; pathById[id] = sibling; return MapEntry(id, true); } @@ -853,21 +957,127 @@ class DownloadHistoryNotifier extends Notifier { } } - if (orphanedIds.isEmpty) { + return ( + orphanedIds: orphanedIds, + replacementPaths: replacementPaths, + pathById: pathById, + ); + } + + void _applyHistoryPathAndDeletionChanges({ + required List deletedIds, + required Map replacementPaths, + }) { + if (deletedIds.isEmpty && replacementPaths.isEmpty) { + return; + } + final deletedSet = deletedIds.toSet(); + final updatedItems = []; + for (final item in state.items) { + if (deletedSet.contains(item.id)) { + continue; + } + final replacementPath = replacementPaths[item.id]; + if (replacementPath != null && replacementPath != item.filePath) { + updatedItems.add(item.copyWith(filePath: replacementPath)); + } else { + updatedItems.add(item); + } + } + state = state.copyWith(items: updatedItems); + } + + Future _cleanupOrphanedDownloadsIncremental({ + required int maxItems, + required SharedPreferences prefs, + }) async { + final cursor = prefs.getInt(_startupOrphanCursorKey) ?? 0; + final safeCursor = cursor < 0 ? 0 : cursor; + final entries = await _db.getEntriesWithPathsPage( + limit: maxItems, + offset: safeCursor, + ); + if (entries.isEmpty) { + await prefs.remove(_startupOrphanCursorKey); + return 0; + } + + final result = await _inspectOrphanedEntries(entries); + for (final replacement in result.replacementPaths.entries) { + await _db.updateFilePath(replacement.key, replacement.value); + } + + final deletedCount = result.orphanedIds.isEmpty + ? 0 + : await _db.deleteByIds(result.orphanedIds); + + _applyHistoryPathAndDeletionChanges( + deletedIds: result.orphanedIds, + replacementPaths: result.replacementPaths, + ); + + if (entries.length < maxItems) { + await prefs.remove(_startupOrphanCursorKey); + } else { + final nextCursor = + safeCursor + entries.length - result.orphanedIds.length; + await prefs.setInt(_startupOrphanCursorKey, nextCursor); + } + + if (deletedCount > 0 || result.replacementPaths.isNotEmpty) { + _historyLog.i( + 'Startup orphan cleanup pass: removed=$deletedCount, repaired=${result.replacementPaths.length}, checked=${entries.length}', + ); + } + return deletedCount; + } + + Future cleanupOrphanedDownloads() async { + _historyLog.i('Starting orphaned downloads cleanup...'); + final orphanedIds = []; + final replacementPaths = {}; + const pageSize = 256; + var offset = 0; + + while (true) { + final entries = await _db.getEntriesWithPathsPage( + limit: pageSize, + offset: offset, + ); + if (entries.isEmpty) { + break; + } + + final result = await _inspectOrphanedEntries(entries); + orphanedIds.addAll(result.orphanedIds); + replacementPaths.addAll(result.replacementPaths); + + if (entries.length < pageSize) { + break; + } + offset += entries.length - result.orphanedIds.length; + } + + for (final replacement in replacementPaths.entries) { + await _db.updateFilePath(replacement.key, replacement.value); + } + + if (orphanedIds.isEmpty && replacementPaths.isEmpty) { _historyLog.i('No orphaned entries found'); return 0; } - final deletedCount = await _db.deleteByIds(orphanedIds); - - final orphanedSet = orphanedIds.toSet(); - state = state.copyWith( - items: state.items - .where((item) => !orphanedSet.contains(item.id)) - .toList(), + final deletedCount = orphanedIds.isEmpty + ? 0 + : await _db.deleteByIds(orphanedIds); + _applyHistoryPathAndDeletionChanges( + deletedIds: orphanedIds, + replacementPaths: replacementPaths, ); - _historyLog.i('Cleaned up $deletedCount orphaned entries'); + _historyLog.i( + 'Cleaned up $deletedCount orphaned entries and repaired ${replacementPaths.length} paths', + ); return deletedCount; } diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index ab5416c7..f63613c5 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -324,16 +324,9 @@ class LocalLibraryNotifier extends Notifier { _log.i('Skipped $skippedDownloads files already in download history'); } - // Full scan should replace library index entirely. - await _db.clearAll(); - if (items.isNotEmpty) { - await _db.upsertBatch(items.map((e) => e.toJson()).toList()); - } - final persistedItems = - (await _db.getAll()) - .map(LocalLibraryItem.fromJson) - .toList(growable: false) - ..sort(_compareLibraryItems); + // Full scan should replace library index atomically. + await _db.replaceAll(items.map((e) => e.toJson()).toList()); + final persistedItems = [...items]..sort(_compareLibraryItems); final now = DateTime.now(); try { @@ -502,11 +495,8 @@ class LocalLibraryNotifier extends Notifier { _log.i('Deleted $deleteCount items from database'); } - final items = - (await _db.getAll()) - .map(LocalLibraryItem.fromJson) - .toList(growable: false) - ..sort(_compareLibraryItems); + final items = currentByPath.values.toList(growable: false) + ..sort(_compareLibraryItems); final now = DateTime.now(); try { diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index ca022604..7758b273 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -280,13 +280,13 @@ class _HomeTabState extends ConsumerState double _exploreCardSize(BuildContext context) { final scale = _responsiveScale(context: context, min: 0.82, max: 1.08); final textScale = _effectiveTextScale(context); - return 120 * scale * (1 + (textScale - 1) * 0.12); + return 145 * scale * (1 + (textScale - 1) * 0.12); } double _exploreSectionHeight(BuildContext context) { final cardSize = _exploreCardSize(context); final textScale = _effectiveTextScale(context); - return cardSize + 55 + ((textScale - 1) * 12); + return cardSize + 58 + ((textScale - 1) * 12); } @override @@ -1485,7 +1485,7 @@ class _HomeTabState extends ConsumerState delegate: SliverChildBuilderDelegate((context, index) { if (hasGreeting && index == 0) { return Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Text( greeting, style: Theme.of(context).textTheme.headlineSmall?.copyWith( @@ -1500,7 +1500,7 @@ class _HomeTabState extends ConsumerState return _buildExploreSection(sections[sectionIndex], colorScheme); } - return const SizedBox(height: 16); + return const SizedBox(height: 24); }, childCount: totalCount), ), ]; @@ -1516,7 +1516,7 @@ class _HomeTabState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + padding: const EdgeInsets.fromLTRB(16, 20, 16, 12), child: Text( section.title, style: Theme.of( @@ -1579,7 +1579,7 @@ class _HomeTabState extends ConsumerState children: [ ClipRRect( borderRadius: BorderRadius.circular( - isArtist ? cardSize / 2 : 8, + isArtist ? cardSize / 2 : 10, ), child: item.coverUrl != null && item.coverUrl!.isNotEmpty ? CachedNetworkImage( @@ -1618,8 +1618,8 @@ class _HomeTabState extends ConsumerState maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: isArtist ? TextAlign.center : TextAlign.start, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w500, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, color: colorScheme.onSurface, ), ), @@ -1632,7 +1632,7 @@ class _HomeTabState extends ConsumerState overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, - fontSize: 11, + fontSize: 12, ), ), ], diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart index 503937f6..d3258424 100644 --- a/lib/screens/library_playlists_screen.dart +++ b/lib/screens/library_playlists_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; +import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; class LibraryPlaylistsScreen extends ConsumerWidget { @@ -210,7 +211,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { color: colorScheme.outlineVariant.withValues(alpha: 0.5), ), - _PlaylistOptionTile( + BottomSheetOptionTile( icon: Icons.edit_outlined, title: context.l10n.collectionRenamePlaylist, onTap: () { @@ -224,7 +225,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { }, ), - _PlaylistOptionTile( + BottomSheetOptionTile( icon: Icons.image_outlined, title: context.l10n.collectionPlaylistChangeCover, onTap: () { @@ -233,7 +234,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { }, ), - _PlaylistOptionTile( + BottomSheetOptionTile( icon: Icons.delete_outline, iconColor: colorScheme.error, title: context.l10n.collectionDeletePlaylist, @@ -543,40 +544,3 @@ class LibraryPlaylistsScreen extends ConsumerWidget { ); } } - -/// Styled like _OptionTile in track_collection_quick_actions.dart -class _PlaylistOptionTile extends StatelessWidget { - final IconData icon; - final Color? iconColor; - final String title; - final VoidCallback onTap; - - const _PlaylistOptionTile({ - required this.icon, - this.iconColor, - required this.title, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - icon, - color: iconColor ?? colorScheme.onPrimaryContainer, - size: 20, - ), - ), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), - onTap: onTap, - ); - } -} diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 2b792b0b..48847f63 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -15,6 +15,7 @@ import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/services/cover_cache_manager.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; class LibraryTracksFolderScreen extends ConsumerStatefulWidget { @@ -1392,7 +1393,7 @@ class _CollectionTrackTile extends ConsumerWidget { // Add to playlist (hidden in wishlist unless already downloaded) if (showAddToPlaylist) - _CollectionOptionTile( + BottomSheetOptionTile( icon: Icons.playlist_add, title: context.l10n.collectionAddToPlaylist, onTap: () { @@ -1402,7 +1403,7 @@ class _CollectionTrackTile extends ConsumerWidget { ), // Remove from folder / playlist - _CollectionOptionTile( + BottomSheetOptionTile( icon: Icons.remove_circle_outline, iconColor: colorScheme.error, title: mode == LibraryTracksFolderMode.playlist @@ -1542,43 +1543,6 @@ class _CollectionTrackTile extends ConsumerWidget { } } -/// Styled like _OptionTile in track_collection_quick_actions.dart -class _CollectionOptionTile extends StatelessWidget { - final IconData icon; - final Color? iconColor; - final String title; - final VoidCallback onTap; - - const _CollectionOptionTile({ - required this.icon, - this.iconColor, - required this.title, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), - leading: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - icon, - color: iconColor ?? colorScheme.onPrimaryContainer, - size: 20, - ), - ), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), - onTap: onTap, - ); - } -} - class _SelectionActionButton extends StatelessWidget { final IconData icon; final String label; diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 0e632411..f303813b 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -715,6 +715,9 @@ class _QueueTabState extends ConsumerState { static const int _maxCacheSize = 500; static const int _maxSearchIndexCacheSize = 4000; bool _embeddedCoverRefreshScheduled = false; + // Version counter to trigger targeted cover image rebuilds + // without rebuilding the entire widget tree via setState. + final ValueNotifier _embeddedCoverVersion = ValueNotifier(0); bool _isSelectionMode = false; final Set _selectedIds = {}; @@ -766,6 +769,12 @@ class _QueueTabState extends ConsumerState { String _localFilterQueryCache = ''; List _filteredLocalItemsCache = const []; final Map _unifiedItemsCache = {}; + List? _cachedUnifiedDownloadedSource; + List _cachedUnifiedDownloaded = const []; + List? _cachedUnifiedLocalSource; + List _cachedUnifiedLocal = const []; + List? _cachedDownloadedPathKeysSource; + Set _cachedDownloadedPathKeys = const {}; final Map _filterContentDataCache = {}; List? _filterCacheAllHistoryItems; _HistoryStats? _filterCacheHistoryStats; @@ -818,6 +827,7 @@ class _QueueTabState extends ConsumerState { } _fileExistsNotifiers.clear(); _alwaysMissingFileNotifier.dispose(); + _embeddedCoverVersion.dispose(); _filterPageController?.dispose(); _searchController.dispose(); _searchFocusNode.dispose(); @@ -898,12 +908,18 @@ class _QueueTabState extends ConsumerState { _historyStatsCache = historyStats; if (historyChanged) { _searchIndexCache.clear(); + _cachedUnifiedDownloadedSource = null; + _cachedUnifiedDownloaded = const []; + _cachedDownloadedPathKeysSource = null; + _cachedDownloadedPathKeys = const {}; } if (localChanged) { _localSearchIndexCache.clear(); _localFilterItemsCache = null; _localFilterQueryCache = ''; _filteredLocalItemsCache = const []; + _cachedUnifiedLocalSource = null; + _cachedUnifiedLocal = const []; } _unifiedItemsCache.clear(); _invalidateFilterContentCache(); @@ -952,6 +968,45 @@ class _QueueTabState extends ConsumerState { return searchKey; } + List _unifiedDownloadedItems( + List items, + ) { + if (identical(items, _cachedUnifiedDownloadedSource)) { + return _cachedUnifiedDownloaded; + } + final unified = items + .map(UnifiedLibraryItem.fromDownloadHistory) + .toList(growable: false); + _cachedUnifiedDownloadedSource = items; + _cachedUnifiedDownloaded = unified; + return unified; + } + + List _unifiedLocalItems(List items) { + if (identical(items, _cachedUnifiedLocalSource)) { + return _cachedUnifiedLocal; + } + final unified = items + .map(UnifiedLibraryItem.fromLocalLibrary) + .toList(growable: false); + _cachedUnifiedLocalSource = items; + _cachedUnifiedLocal = unified; + return unified; + } + + Set _downloadedPathKeys(List historyItems) { + if (identical(historyItems, _cachedDownloadedPathKeysSource)) { + return _cachedDownloadedPathKeys; + } + final keys = {}; + for (final item in historyItems) { + keys.addAll(buildPathMatchKeys(item.filePath)); + } + _cachedDownloadedPathKeysSource = historyItems; + _cachedDownloadedPathKeys = Set.unmodifiable(keys); + return _cachedDownloadedPathKeys; + } + List _filterLocalItems( List items, String query, @@ -1506,7 +1561,7 @@ class _QueueTabState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '$selectedCount selected', + context.l10n.selectionSelected(selectedCount), style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), @@ -1533,7 +1588,11 @@ class _QueueTabState extends ConsumerState { allSelected ? Icons.deselect : Icons.select_all, size: 20, ), - label: Text(allSelected ? 'Deselect' : 'Select All'), + label: Text( + allSelected + ? context.l10n.actionDeselect + : context.l10n.actionSelectAll, + ), style: TextButton.styleFrom( foregroundColor: colorScheme.primary, ), @@ -1712,7 +1771,9 @@ class _QueueTabState extends ConsumerState { WidgetsBinding.instance.addPostFrameCallback((_) { _embeddedCoverRefreshScheduled = false; if (mounted) { - setState(() {}); + // Increment version to trigger ValueListenableBuilder rebuilds + // on cover images only, instead of rebuilding the entire widget tree. + _embeddedCoverVersion.value++; } }); } @@ -2332,10 +2393,14 @@ class _QueueTabState extends ConsumerState { } } - void _navigateToDownloadedAlbum(_GroupedAlbum album) { + /// Navigate with unfocus pattern — unfocuses search before and after navigation. + void _navigateWithUnfocus(Route route) { _searchFocusNode.unfocus(); - Navigator.push( - context, + Navigator.of(context).push(route).then((_) => _searchFocusNode.unfocus()); + } + + void _navigateToDownloadedAlbum(_GroupedAlbum album) { + _navigateWithUnfocus( PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), @@ -2348,13 +2413,11 @@ class _QueueTabState extends ConsumerState { transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), ), - ).then((_) => _searchFocusNode.unfocus()); + ); } void _navigateToLocalAlbum(_GroupedLocalAlbum album) { - _searchFocusNode.unfocus(); - Navigator.push( - context, + _navigateWithUnfocus( PageRouteBuilder( transitionDuration: const Duration(milliseconds: 300), reverseTransitionDuration: const Duration(milliseconds: 250), @@ -2368,47 +2431,38 @@ class _QueueTabState extends ConsumerState { transitionsBuilder: (context, animation, secondaryAnimation, child) => FadeTransition(opacity: animation, child: child), ), - ).then((_) => _searchFocusNode.unfocus()); + ); } void _openWishlistFolder() { - _searchFocusNode.unfocus(); - Navigator.of(context) - .push( - MaterialPageRoute( - builder: (_) => const LibraryTracksFolderScreen( - mode: LibraryTracksFolderMode.wishlist, - ), - ), - ) - .then((_) => _searchFocusNode.unfocus()); + _navigateWithUnfocus( + MaterialPageRoute( + builder: (_) => const LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.wishlist, + ), + ), + ); } void _openLovedFolder() { - _searchFocusNode.unfocus(); - Navigator.of(context) - .push( - MaterialPageRoute( - builder: (_) => const LibraryTracksFolderScreen( - mode: LibraryTracksFolderMode.loved, - ), - ), - ) - .then((_) => _searchFocusNode.unfocus()); + _navigateWithUnfocus( + MaterialPageRoute( + builder: (_) => const LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.loved, + ), + ), + ); } void _openPlaylistById(String playlistId) { - _searchFocusNode.unfocus(); - Navigator.of(context) - .push( - MaterialPageRoute( - builder: (_) => LibraryTracksFolderScreen( - mode: LibraryTracksFolderMode.playlist, - playlistId: playlistId, - ), - ), - ) - .then((_) => _searchFocusNode.unfocus()); + _navigateWithUnfocus( + MaterialPageRoute( + builder: (_) => LibraryTracksFolderScreen( + mode: LibraryTracksFolderMode.playlist, + playlistId: playlistId, + ), + ), + ); } Future _showCreatePlaylistDialog(BuildContext context) async { @@ -2684,7 +2738,21 @@ class _QueueTabState extends ConsumerState { final localLibraryItems = localLibraryEnabled ? ref.watch(localLibraryProvider.select((s) => s.items)) : const []; - final collectionState = ref.watch(libraryCollectionsProvider); + // Watch with selector on key fields to reduce unnecessary rebuilds. + // LibraryCollectionsState doesn't implement == so watching without + // selector rebuilds on every provider notification. + ref.watch( + libraryCollectionsProvider.select( + (s) => ( + s.wishlistCount, + s.lovedCount, + s.playlistCount, + s.hasPlaylistTracks, + s.isLoaded, + ), + ), + ); + final collectionState = ref.read(libraryCollectionsProvider); final historyStats = ref.watch(_queueHistoryStatsProvider); final filteredGrouped = ref.watch( _queueFilteredAlbumsProvider( @@ -2733,20 +2801,27 @@ class _QueueTabState extends ConsumerState { ); } - final bottomPadding = MediaQuery.of(context).padding.bottom; + final bottomPadding = MediaQuery.paddingOf(context).bottom; final selectionItems = getFilterData( historyFilterMode, ).filteredUnifiedItems; - WidgetsBinding.instance.addPostFrameCallback((_) { - _syncSelectionOverlay( - items: selectionItems, - bottomPadding: bottomPadding, - ); - _syncPlaylistSelectionOverlay( - playlists: collectionState.playlists, - bottomPadding: bottomPadding, - ); - }); + // Only sync overlays when selection mode is active + if (_isSelectionMode || _isPlaylistSelectionMode) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (_isSelectionMode) { + _syncSelectionOverlay( + items: selectionItems, + bottomPadding: bottomPadding, + ); + } + if (_isPlaylistSelectionMode) { + _syncPlaylistSelectionOverlay( + playlists: collectionState.playlists, + bottomPadding: bottomPadding, + ); + } + }); + } return PopScope( canPop: !_isSelectionMode && !_isPlaylistSelectionMode, @@ -2982,9 +3057,7 @@ class _QueueTabState extends ConsumerState { return cached.items; } - final unifiedDownloaded = historyItems - .map((item) => UnifiedLibraryItem.fromDownloadHistory(item)) - .toList(growable: false); + final unifiedDownloaded = _unifiedDownloadedItems(historyItems); List localItemsForMerge; if (filterMode == 'all') { @@ -2999,14 +3072,8 @@ class _QueueTabState extends ConsumerState { localItemsForMerge = _filterLocalItems(localSingles, query); } - final unifiedLocal = localItemsForMerge - .map((item) => UnifiedLibraryItem.fromLocalLibrary(item)) - .toList(growable: false); - - final downloadedPathKeys = {}; - for (final item in unifiedDownloaded) { - downloadedPathKeys.addAll(buildPathMatchKeys(item.filePath)); - } + final unifiedLocal = _unifiedLocalItems(localItemsForMerge); + final downloadedPathKeys = _downloadedPathKeys(historyItems); final dedupedUnifiedLocal = []; for (final item in unifiedLocal) { @@ -3572,24 +3639,7 @@ class _QueueTabState extends ConsumerState { const Spacer(), // Filter button with long-press to reset if (!_isSelectionMode) - GestureDetector( - onLongPress: _activeFilterCount > 0 - ? _resetFilters - : null, - child: TextButton.icon( - onPressed: () => - _showFilterSheet(context, unifiedItems), - icon: Badge( - isLabelVisible: _activeFilterCount > 0, - label: Text('$_activeFilterCount'), - child: const Icon(Icons.filter_list, size: 18), - ), - label: Text(context.l10n.libraryFilterTitle), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ), + _buildFilterButton(context, unifiedItems), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( onPressed: () => _showCreatePlaylistDialog(context), @@ -3621,21 +3671,7 @@ class _QueueTabState extends ConsumerState { ), ), const Spacer(), - GestureDetector( - onLongPress: _activeFilterCount > 0 ? _resetFilters : null, - child: TextButton.icon( - onPressed: () => _showFilterSheet(context, unifiedItems), - icon: Badge( - isLabelVisible: _activeFilterCount > 0, - label: Text('$_activeFilterCount'), - child: const Icon(Icons.filter_list, size: 18), - ), - label: Text(context.l10n.libraryFilterTitle), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ), + _buildFilterButton(context, unifiedItems), ], ), ), @@ -3652,21 +3688,7 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ const Spacer(), - GestureDetector( - onLongPress: _activeFilterCount > 0 ? _resetFilters : null, - child: TextButton.icon( - onPressed: () => _showFilterSheet(context, unifiedItems), - icon: Badge( - isLabelVisible: _activeFilterCount > 0, - label: Text('$_activeFilterCount'), - child: const Icon(Icons.filter_list, size: 18), - ), - label: Text(context.l10n.libraryFilterTitle), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ), + _buildFilterButton(context, unifiedItems), ], ), ), @@ -3882,24 +3904,7 @@ class _QueueTabState extends ConsumerState { ), const Spacer(), if (!_isSelectionMode) - GestureDetector( - onLongPress: _activeFilterCount > 0 - ? _resetFilters - : null, - child: TextButton.icon( - onPressed: () => - _showFilterSheet(context, unifiedItems), - icon: Badge( - isLabelVisible: _activeFilterCount > 0, - label: Text('$_activeFilterCount'), - child: const Icon(Icons.filter_list, size: 18), - ), - label: Text(context.l10n.libraryFilterTitle), - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - ), - ), - ), + _buildFilterButton(context, unifiedItems), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) TextButton.icon( onPressed: () => _showCreatePlaylistDialog(context), @@ -4095,121 +4100,47 @@ class _QueueTabState extends ConsumerState { _GroupedAlbum album, ColorScheme colorScheme, ) { - final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( - album.sampleFilePath, - ); - return Semantics( - button: true, - label: - 'Open album ${album.albumName} by ${album.artistName}, ${album.tracks.length} tracks', - child: GestureDetector( - onTap: () => _navigateToDownloadedAlbum(album), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Stack( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(12), - child: embeddedCoverPath != null - ? Image.file( - File(embeddedCoverPath), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - cacheWidth: 300, - cacheHeight: 300, - errorBuilder: (context, error, stackTrace) => - Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, - ), - ), - ), - ) - : album.coverUrl != null - ? CachedNetworkImage( - imageUrl: album.coverUrl!, - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - memCacheWidth: 300, - memCacheHeight: 300, - cacheManager: CoverCacheManager.instance, - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, - ), - ), - ), - ), - Positioned( - right: 8, - bottom: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Icons.music_note, - size: 12, - color: colorScheme.onPrimaryContainer, - ), - const SizedBox(width: 4), - Text( - '${album.tracks.length}', - style: TextStyle( - color: colorScheme.onPrimaryContainer, - fontSize: 12, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ), - ), - const SizedBox(height: 8), - Text( - album.albumName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), - ), - ClickableArtistName( - artistName: album.artistName, - coverUrl: album.coverUrl, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ], - ), - ), + return ValueListenableBuilder( + valueListenable: _embeddedCoverVersion, + builder: (context, _, child) { + final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( + album.sampleFilePath, + ); + return _buildAlbumGridItemCore( + context: context, + albumName: album.albumName, + artistName: album.artistName, + trackCount: album.tracks.length, + colorScheme: colorScheme, + coverWidget: embeddedCoverPath != null + ? Image.file( + File(embeddedCoverPath), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + cacheWidth: 300, + cacheHeight: 300, + errorBuilder: (context, error, stackTrace) => + _albumPlaceholder(colorScheme), + ) + : album.coverUrl != null + ? CachedNetworkImage( + imageUrl: album.coverUrl!, + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + memCacheWidth: 300, + memCacheHeight: 300, + cacheManager: CoverCacheManager.instance, + ) + : null, + badgeColor: colorScheme.primaryContainer, + badgeTextColor: colorScheme.onPrimaryContainer, + badgeIcon: Icons.music_note, + coverUrl: album.coverUrl, + onTap: () => _navigateToDownloadedAlbum(album), + ); + }, ); } @@ -4219,12 +4150,58 @@ class _QueueTabState extends ConsumerState { _GroupedLocalAlbum album, ColorScheme colorScheme, ) { + return _buildAlbumGridItemCore( + context: context, + albumName: album.albumName, + artistName: album.artistName, + trackCount: album.tracks.length, + colorScheme: colorScheme, + coverWidget: album.coverPath != null + ? Image.file( + File(album.coverPath!), + fit: BoxFit.cover, + width: double.infinity, + height: double.infinity, + cacheWidth: 300, + cacheHeight: 300, + errorBuilder: (context, error, stackTrace) => + _albumPlaceholder(colorScheme), + ) + : null, + badgeColor: colorScheme.tertiaryContainer, + badgeTextColor: colorScheme.onTertiaryContainer, + badgeIcon: Icons.folder, + onTap: () => _navigateToLocalAlbum(album), + ); + } + + Widget _albumPlaceholder(ColorScheme colorScheme) { + return Container( + color: colorScheme.surfaceContainerHighest, + child: Center( + child: Icon(Icons.album, color: colorScheme.onSurfaceVariant, size: 48), + ), + ); + } + + Widget _buildAlbumGridItemCore({ + required BuildContext context, + required String albumName, + required String artistName, + required int trackCount, + required ColorScheme colorScheme, + required Widget? coverWidget, + required Color badgeColor, + required Color badgeTextColor, + required IconData badgeIcon, + required VoidCallback onTap, + String? coverUrl, + }) { return Semantics( button: true, - label: - 'Open local album ${album.albumName} by ${album.artistName}, ${album.tracks.length} tracks', + label: 'Open album $albumName by $artistName, $trackCount tracks', child: GestureDetector( - onTap: () => _navigateToLocalAlbum(album), + onTap: onTap, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -4233,38 +4210,8 @@ class _QueueTabState extends ConsumerState { children: [ ClipRRect( borderRadius: BorderRadius.circular(12), - child: album.coverPath != null - ? Image.file( - File(album.coverPath!), - fit: BoxFit.cover, - width: double.infinity, - height: double.infinity, - cacheWidth: 300, - cacheHeight: 300, - errorBuilder: (context, error, stackTrace) => - Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, - ), - ), - ), - ) - : Container( - color: colorScheme.surfaceContainerHighest, - child: Center( - child: Icon( - Icons.album, - color: colorScheme.onSurfaceVariant, - size: 48, - ), - ), - ), + child: coverWidget ?? _albumPlaceholder(colorScheme), ), - // "Local" badge instead of track count Positioned( right: 8, bottom: 8, @@ -4274,22 +4221,18 @@ class _QueueTabState extends ConsumerState { vertical: 4, ), decoration: BoxDecoration( - color: colorScheme.tertiaryContainer, + color: badgeColor, borderRadius: BorderRadius.circular(12), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon( - Icons.folder, - size: 12, - color: colorScheme.onTertiaryContainer, - ), + Icon(badgeIcon, size: 12, color: badgeTextColor), const SizedBox(width: 4), Text( - '${album.tracks.length}', + '$trackCount', style: TextStyle( - color: colorScheme.onTertiaryContainer, + color: badgeTextColor, fontSize: 12, fontWeight: FontWeight.bold, ), @@ -4303,7 +4246,7 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(height: 8), Text( - album.albumName, + albumName, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of( @@ -4311,7 +4254,8 @@ class _QueueTabState extends ConsumerState { ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), ), ClickableArtistName( - artistName: album.artistName, + artistName: artistName, + coverUrl: coverUrl, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall?.copyWith( @@ -5433,14 +5377,14 @@ class _QueueTabState extends ConsumerState { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '$selectedCount selected', + context.l10n.selectionSelected(selectedCount), style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.bold), ), Text( allSelected - ? 'All tracks selected' - : 'Tap tracks to select', + ? context.l10n.selectionAllSelected + : context.l10n.downloadedAlbumTapToSelect, style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: colorScheme.onSurfaceVariant), ), @@ -5460,7 +5404,11 @@ class _QueueTabState extends ConsumerState { allSelected ? Icons.deselect : Icons.select_all, size: 20, ), - label: Text(allSelected ? 'Deselect' : 'Select All'), + label: Text( + allSelected + ? context.l10n.actionDeselect + : context.l10n.actionSelectAll, + ), style: TextButton.styleFrom( foregroundColor: colorScheme.primary, ), @@ -5528,7 +5476,7 @@ class _QueueTabState extends ConsumerState { label: Text( selectedCount > 0 ? 'Delete $selectedCount ${selectedCount == 1 ? 'track' : 'tracks'}' - : 'Select tracks to delete', + : context.l10n.selectionSelectToDelete, ), style: FilledButton.styleFrom( backgroundColor: selectedCount > 0 @@ -5833,115 +5781,81 @@ class _QueueTabState extends ConsumerState { } } - /// Build cover image widget for unified library item - /// Supports network URLs (from downloads) and local file paths (from library scan) - Widget _buildUnifiedCoverImage( - UnifiedLibraryItem item, - ColorScheme colorScheme, - double size, + /// Reusable filter button with badge showing active filter count. + Widget _buildFilterButton( + BuildContext context, + List unifiedItems, ) { - final isDownloaded = item.source == LibraryItemSource.downloaded; - if (isDownloaded) { - final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( - item.filePath, - ); - if (embeddedCoverPath != null) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file( - File(embeddedCoverPath), - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: (size * 2).toInt(), - cacheHeight: (size * 2).toInt(), - errorBuilder: (context, error, stackTrace) => - _buildPlaceholderCover(colorScheme, size, isDownloaded), - ), - ); - } - } - - // Network URL cover (downloaded items) - if (item.coverUrl != null) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: item.coverUrl!, - width: size, - height: size, - fit: BoxFit.cover, - memCacheWidth: (size * 2).toInt(), - memCacheHeight: (size * 2).toInt(), - cacheManager: CoverCacheManager.instance, - placeholder: (context, url) => Container( - width: size, - height: size, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), - errorWidget: (context, url, error) => Container( - width: size, - height: size, - color: colorScheme.surfaceContainerHighest, - child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), - ), + return GestureDetector( + onLongPress: _activeFilterCount > 0 ? _resetFilters : null, + child: TextButton.icon( + onPressed: () => _showFilterSheet(context, unifiedItems), + icon: Badge( + isLabelVisible: _activeFilterCount > 0, + label: Text('$_activeFilterCount'), + child: const Icon(Icons.filter_list, size: 18), ), - ); - } - - // Local file cover (from library scan) - if (item.localCoverPath != null && item.localCoverPath!.isNotEmpty) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.file( - File(item.localCoverPath!), - width: size, - height: size, - fit: BoxFit.cover, - cacheWidth: (size * 2).toInt(), - cacheHeight: (size * 2).toInt(), - errorBuilder: (context, error, stackTrace) => - _buildPlaceholderCover(colorScheme, size, isDownloaded), - ), - ); - } - - // Placeholder (no cover) - return _buildPlaceholderCover(colorScheme, size, isDownloaded); - } - - /// Build placeholder cover image - Widget _buildPlaceholderCover( - ColorScheme colorScheme, - double size, - bool isDownloaded, - ) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - color: isDownloaded - ? colorScheme.surfaceContainerHighest - : colorScheme.secondaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.music_note, - color: isDownloaded - ? colorScheme.onSurfaceVariant - : colorScheme.onSecondaryContainer, - size: size * 0.4, + label: Text(context.l10n.libraryFilterTitle), + style: TextButton.styleFrom(visualDensity: VisualDensity.compact), ), ); } - /// Build cover image for unified grid item (fills container) - Widget _buildUnifiedGridCoverImage( + /// Build cover image widget for unified library item. + /// When [size] is provided, renders at fixed dimensions (list mode). + /// When [size] is null, fills the parent container (grid mode). + Widget _buildUnifiedCoverImage( + UnifiedLibraryItem item, + ColorScheme colorScheme, [ + double? size, + ]) { + final isDownloaded = item.source == LibraryItemSource.downloaded; + + // For downloaded items, listen to embedded cover version so the cover + // updates after async extraction completes. + if (isDownloaded) { + return ValueListenableBuilder( + valueListenable: _embeddedCoverVersion, + builder: (context, _, child) => + _buildUnifiedCoverImageInner(item, colorScheme, isDownloaded, size), + ); + } + + return _buildUnifiedCoverImageInner(item, colorScheme, isDownloaded, size); + } + + Widget _buildUnifiedCoverImageInner( UnifiedLibraryItem item, ColorScheme colorScheme, - ) { - final isDownloaded = item.source == LibraryItemSource.downloaded; + bool isDownloaded, [ + double? size, + ]) { + final cacheSize = size != null ? (size * 2).toInt() : 200; + final iconSize = size != null ? size * 0.4 : 32.0; + + Widget buildPlaceholder({bool isLocal = false}) { + final bgColor = (isDownloaded && !isLocal) + ? colorScheme.surfaceContainerHighest + : colorScheme.secondaryContainer; + final fgColor = (isDownloaded && !isLocal) + ? colorScheme.onSurfaceVariant + : colorScheme.onSecondaryContainer; + return Container( + width: size, + height: size, + decoration: size != null + ? BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(8), + ) + : null, + color: size != null ? null : bgColor, + child: Center( + child: Icon(Icons.music_note, color: fgColor, size: iconSize), + ), + ); + } + if (isDownloaded) { final embeddedCoverPath = _resolveDownloadedEmbeddedCoverPath( item.filePath, @@ -5951,17 +5865,12 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(8), child: Image.file( File(embeddedCoverPath), + width: size, + height: size, fit: BoxFit.cover, - cacheWidth: 200, - cacheHeight: 200, - errorBuilder: (context, error, stackTrace) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - size: 32, - ), - ), + cacheWidth: cacheSize, + cacheHeight: cacheSize, + errorBuilder: (context, error, stackTrace) => buildPlaceholder(), ), ); } @@ -5973,26 +5882,14 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(8), child: CachedNetworkImage( imageUrl: item.coverUrl!, + width: size, + height: size, fit: BoxFit.cover, - memCacheWidth: 200, - memCacheHeight: 200, + memCacheWidth: cacheSize, + memCacheHeight: cacheSize, cacheManager: CoverCacheManager.instance, - placeholder: (context, url) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - size: 32, - ), - ), - errorWidget: (context, url, error) => Container( - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - size: 32, - ), - ), + placeholder: (context, url) => buildPlaceholder(), + errorWidget: (context, url, error) => buildPlaceholder(), ), ); } @@ -6003,36 +5900,24 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(8), child: Image.file( File(item.localCoverPath!), + width: size, + height: size, fit: BoxFit.cover, - cacheWidth: 200, - cacheHeight: 200, - errorBuilder: (context, error, stackTrace) => Container( - color: colorScheme.secondaryContainer, - child: Icon( - Icons.music_note, - color: colorScheme.onSecondaryContainer, - size: 32, - ), - ), + cacheWidth: cacheSize, + cacheHeight: cacheSize, + errorBuilder: (context, error, stackTrace) => + buildPlaceholder(isLocal: true), ), ); } // Placeholder (no cover) + if (size != null) { + return buildPlaceholder(); + } return ClipRRect( borderRadius: BorderRadius.circular(8), - child: Container( - color: isDownloaded - ? colorScheme.surfaceContainerHighest - : colorScheme.secondaryContainer, - child: Icon( - Icons.music_note, - color: isDownloaded - ? colorScheme.onSurfaceVariant - : colorScheme.onSecondaryContainer, - size: 32, - ), - ), + child: buildPlaceholder(), ); } @@ -6059,193 +5944,201 @@ class _QueueTabState extends ConsumerState { ? colorScheme.onPrimaryContainer : colorScheme.onSecondaryContainer; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - color: isSelected - ? colorScheme.primaryContainer.withValues(alpha: 0.3) - : null, - child: InkWell( - onTap: _isSelectionMode - ? () => _toggleSelection(item.id) - : isDownloaded - ? () => _navigateToHistoryMetadataScreen(item.historyItem!) - : item.localItem != null - ? () => _navigateToLocalMetadataScreen(item.localItem!) - : () => _openFile( - item.filePath, - title: item.trackName, - artist: item.artistName, - album: item.albumName, - coverUrl: item.coverUrl ?? item.localCoverPath ?? '', - ), - onLongPress: _isSelectionMode - ? null - : () => _enterSelectionMode(item.id), - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - if (_isSelectionMode) ...[ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, + return Semantics( + label: '${item.trackName} by ${item.artistName}', + selected: isSelected, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + color: isSelected + ? colorScheme.primaryContainer.withValues(alpha: 0.3) + : null, + child: InkWell( + onTap: _isSelectionMode + ? () => _toggleSelection(item.id) + : isDownloaded + ? () => _navigateToHistoryMetadataScreen(item.historyItem!) + : item.localItem != null + ? () => _navigateToLocalMetadataScreen(item.localItem!) + : () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: item.coverUrl ?? item.localCoverPath ?? '', + ), + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(item.id), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + if (_isSelectionMode) ...[ + Semantics( + checked: isSelected, + label: isSelected ? 'Deselect track' : 'Select track', + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon( + Icons.check, + color: colorScheme.onPrimary, + size: 16, + ) + : null, ), ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, - ), + const SizedBox(width: 12), + ], + // Cover image - supports network URL and local file path + _buildUnifiedCoverImage(item, colorScheme, 56), const SizedBox(width: 12), - ], - // Cover image - supports network URL and local file path - _buildUnifiedCoverImage(item, colorScheme, 56), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.trackName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 2), - ClickableArtistName( - artistName: item.artistName, - coverUrl: item.coverUrl, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - // Source badge - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: sourceColor, - borderRadius: BorderRadius.circular(4), - ), - child: Text( - sourceLabel, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: sourceTextColor, - fontSize: 10, - fontWeight: FontWeight.w500, - ), - ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.trackName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w600, ), - const SizedBox(width: 8), - Flexible( - child: Text( - dateStr, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.onSurfaceVariant - .withValues(alpha: 0.7), - ), - ), + ), + const SizedBox(height: 2), + ClickableArtistName( + artistName: item.artistName, + coverUrl: item.coverUrl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colorScheme.onSurfaceVariant, ), - if (item.quality != null && - item.quality!.isNotEmpty) ...[ - const SizedBox(width: 8), + ), + const SizedBox(height: 2), + Row( + children: [ + // Source badge Container( padding: const EdgeInsets.symmetric( horizontal: 6, vertical: 2, ), decoration: BoxDecoration( - color: item.quality!.startsWith('24') - ? colorScheme.tertiaryContainer - : colorScheme.surfaceContainerHighest, + color: sourceColor, borderRadius: BorderRadius.circular(4), ), child: Text( - item.quality!, + sourceLabel, style: Theme.of(context).textTheme.labelSmall ?.copyWith( - color: item.quality!.startsWith('24') - ? colorScheme.onTertiaryContainer - : colorScheme.onSurfaceVariant, + color: sourceTextColor, fontSize: 10, fontWeight: FontWeight.w500, ), ), ), - ], - ], - ), - ], - ), - ), - const SizedBox(width: 8), - - if (!_isSelectionMode) - ValueListenableBuilder( - valueListenable: fileExistsListenable, - builder: (context, fileExists, child) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (fileExists) - IconButton( - onPressed: () => _openFile( - item.filePath, - title: item.trackName, - artist: item.artistName, - album: item.albumName, - coverUrl: - item.coverUrl ?? item.localCoverPath ?? '', + const SizedBox(width: 8), + Flexible( + child: Text( + dateStr, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.onSurfaceVariant + .withValues(alpha: 0.7), + ), ), - icon: Icon( - Icons.play_arrow, - color: colorScheme.primary, - ), - tooltip: context.l10n.tooltipPlay, - style: IconButton.styleFrom( - backgroundColor: colorScheme.primaryContainer - .withValues(alpha: 0.3), - ), - ) - else - Icon( - Icons.error_outline, - color: colorScheme.error, - size: 20, ), - ], - ); - }, + if (item.quality != null && + item.quality!.isNotEmpty) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: item.quality!.startsWith('24') + ? colorScheme.tertiaryContainer + : colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + item.quality!, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: item.quality!.startsWith('24') + ? colorScheme.onTertiaryContainer + : colorScheme.onSurfaceVariant, + fontSize: 10, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + ], + ), ), - ], + const SizedBox(width: 8), + + if (!_isSelectionMode) + ValueListenableBuilder( + valueListenable: fileExistsListenable, + builder: (context, fileExists, child) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (fileExists) + IconButton( + onPressed: () => _openFile( + item.filePath, + title: item.trackName, + artist: item.artistName, + album: item.albumName, + coverUrl: + item.coverUrl ?? item.localCoverPath ?? '', + ), + icon: Icon( + Icons.play_arrow, + color: colorScheme.primary, + ), + tooltip: context.l10n.tooltipPlay, + style: IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer + .withValues(alpha: 0.3), + ), + ) + else + Icon( + Icons.error_outline, + color: colorScheme.error, + size: 20, + ), + ], + ); + }, + ), + ], + ), ), ), ), @@ -6286,7 +6179,7 @@ class _QueueTabState extends ConsumerState { children: [ AspectRatio( aspectRatio: 1, - child: _buildUnifiedGridCoverImage(item, colorScheme), + child: _buildUnifiedCoverImage(item, colorScheme), ), // Source badge (top-right) Positioned( diff --git a/lib/screens/settings/donate_page.dart b/lib/screens/settings/donate_page.dart index 5ba2fcce..16b629c4 100644 --- a/lib/screens/settings/donate_page.dart +++ b/lib/screens/settings/donate_page.dart @@ -477,122 +477,40 @@ class _CryptoWalletItem extends StatelessWidget { } } -int _cr(String v) { - int r = 0x1F; - for (final c in v.codeUnits) { - r = (r * 31 + c) & 0x7FFFFFFF; - } - return r; -} - -// Highlighted supporters (hashes of names). -const _cv = {1211573191, 1003219236}; - -// Diamond tier supporters ($50+ donors). -const _dv = {560908930}; - -enum _SupporterTier { normal, gold, diamond } - -_SupporterTier _tierOf(String name) { - final h = _cr(name); - if (_dv.contains(h)) return _SupporterTier.diamond; - if (_cv.contains(h)) return _SupporterTier.gold; - return _SupporterTier.normal; -} - -class _SupporterChip extends StatefulWidget { +class _SupporterChip extends StatelessWidget { final String name; final ColorScheme colorScheme; const _SupporterChip({required this.name, required this.colorScheme}); - @override - State<_SupporterChip> createState() => _SupporterChipState(); -} - -class _SupporterChipState extends State<_SupporterChip> - with SingleTickerProviderStateMixin { - late final _SupporterTier _tier; - AnimationController? _shimmerController; - - @override - void initState() { - super.initState(); - _tier = _tierOf(widget.name); - if (_tier == _SupporterTier.diamond) { - _shimmerController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 2400), - )..repeat(); - } - } - - @override - void dispose() { - _shimmerController?.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - final isDark = Theme.of(context).brightness == Brightness.dark; - - if (_tier == _SupporterTier.diamond) { - return _buildDiamondChip(isDark); - } - - final isGold = _tier == _SupporterTier.gold; - const goldChipColor = Color(0xFFFFF8DC); - const goldAccentColor = Color(0xFFB8860B); - const goldDarkChipColor = Color(0xFF3A3000); - - final chipColor = isGold - ? goldChipColor - : widget.colorScheme.secondaryContainer; - final accentColor = isGold ? goldAccentColor : widget.colorScheme.primary; - final effectiveChipColor = isGold && isDark ? goldDarkChipColor : chipColor; - return Material( - color: effectiveChipColor, + color: colorScheme.secondaryContainer, borderRadius: BorderRadius.circular(20), - child: Container( - decoration: isGold - ? BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: accentColor.withValues(alpha: 0.4), - width: 1, - ), - ) - : null, + child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Row( mainAxisSize: MainAxisSize.min, children: [ CircleAvatar( radius: 10, - backgroundColor: accentColor.withValues(alpha: 0.2), - child: isGold - ? Icon(Icons.star_rounded, size: 12, color: accentColor) - : Text( - widget.name.isNotEmpty - ? widget.name[0].toUpperCase() - : '?', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: accentColor, - ), - ), + backgroundColor: colorScheme.primary.withValues(alpha: 0.2), + child: Text( + name.isNotEmpty ? name[0].toUpperCase() : '?', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: colorScheme.primary, + ), + ), ), const SizedBox(width: 8), Text( - widget.name, + name, style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: isGold - ? accentColor - : widget.colorScheme.onSecondaryContainer, - fontWeight: isGold ? FontWeight.w600 : FontWeight.w500, + color: colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w500, ), ), ], @@ -600,92 +518,6 @@ class _SupporterChipState extends State<_SupporterChip> ), ); } - - Widget _buildDiamondChip(bool isDark) { - const diamondLight = Color(0xFFE8F4FD); - const diamondDark = Color(0xFF0D2B3E); - const diamondAccent = Color(0xFF4FC3F7); - const diamondHighlight = Color(0xFFB3E5FC); - - final chipBg = isDark ? diamondDark : diamondLight; - - return AnimatedBuilder( - animation: _shimmerController!, - builder: (context, child) { - final t = _shimmerController!.value; - return Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(20), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - gradient: LinearGradient( - begin: Alignment(-2.0 + 4.0 * t, 0.0), - end: Alignment(-1.0 + 4.0 * t, 0.0), - colors: [ - chipBg, - isDark - ? diamondAccent.withValues(alpha: 0.18) - : diamondHighlight.withValues(alpha: 0.7), - chipBg, - ], - stops: const [0.0, 0.5, 1.0], - ), - border: Border.all( - color: diamondAccent.withValues( - alpha: 0.5 + 0.3 * (0.5 - (t - 0.5).abs()), - ), - width: 1.2, - ), - boxShadow: [ - BoxShadow( - color: diamondAccent.withValues( - alpha: 0.15 + 0.1 * (0.5 - (t - 0.5).abs()), - ), - blurRadius: 8, - spreadRadius: 0, - ), - ], - ), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 20, - height: 20, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - diamondAccent.withValues(alpha: 0.3), - diamondAccent.withValues(alpha: 0.15), - ], - ), - ), - child: const Icon( - Icons.diamond_rounded, - size: 12, - color: diamondAccent, - ), - ), - const SizedBox(width: 8), - Text( - widget.name, - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: isDark ? diamondHighlight : diamondAccent, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ); - }, - ); - } } class _NoticeLine extends StatelessWidget { diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 9e648bc7..59b7f883 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -328,6 +328,20 @@ class HistoryDatabase { ); } + Future upsertBatch(List> items) async { + if (items.isEmpty) return; + final db = await database; + final batch = db.batch(); + for (final json in items) { + batch.insert( + 'history', + _jsonToDbRow(json), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } + /// Get all history items ordered by download date (newest first) Future>> getAll({int? limit, int? offset}) async { final db = await database; @@ -532,6 +546,29 @@ class HistoryDatabase { return rows.map((r) => Map.from(r)).toList(); } + Future>> getEntriesWithPathsPage({ + required int limit, + int offset = 0, + }) async { + final db = await database; + final rows = await db.query( + 'history', + columns: [ + 'id', + 'file_path', + 'storage_mode', + 'download_tree_uri', + 'saf_relative_dir', + 'saf_file_name', + ], + where: 'file_path IS NOT NULL AND file_path != ""', + orderBy: 'downloaded_at DESC, id DESC', + limit: limit, + offset: offset, + ); + return rows.map((r) => Map.from(r)).toList(); + } + /// Delete multiple entries by IDs Future deleteByIds(List ids) async { if (ids.isEmpty) return 0; diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 47b3ab19..a7eb9701 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -255,20 +255,41 @@ class LibraryDatabase { Future upsertBatch(List> items) async { if (items.isEmpty) return; final db = await database; - final batch = db.batch(); - - for (final json in items) { - batch.insert( - 'library', - _jsonToDbRow(json), - conflictAlgorithm: ConflictAlgorithm.replace, - ); - } - - await batch.commit(noResult: true); + await db.transaction((txn) async { + final batch = txn.batch(); + for (final json in items) { + batch.insert( + 'library', + _jsonToDbRow(json), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); _log.i('Batch inserted ${items.length} items'); } + Future replaceAll(List> items) async { + final db = await database; + await db.transaction((txn) async { + await txn.delete('library'); + if (items.isEmpty) { + return; + } + + final batch = txn.batch(); + for (final json in items) { + batch.insert( + 'library', + _jsonToDbRow(json), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + }); + _log.i('Replaced library with ${items.length} items'); + } + Future>> getAll({int? limit, int? offset}) async { final db = await database; final rows = await db.query( diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index e2c1df83..0c4447cf 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -83,24 +83,18 @@ class PlatformBridge { static Future> getDownloadProgress() async { final result = await _channel.invokeMethod('getDownloadProgress'); - return jsonDecode(result as String) as Map; + return _decodeMapResult(result); } static Future> getAllDownloadProgress() async { final result = await _channel.invokeMethod('getAllDownloadProgress'); - return jsonDecode(result as String) as Map; + return _decodeMapResult(result); } static Stream> downloadProgressStream() { - return _downloadProgressEvents.receiveBroadcastStream().map((event) { - if (event is String) { - return jsonDecode(event) as Map; - } - if (event is Map) { - return Map.from(event); - } - return const {}; - }); + return _downloadProgressEvents + .receiveBroadcastStream() + .map(_decodeMapResult); } static Future exitApp() async { @@ -1186,19 +1180,13 @@ class PlatformBridge { /// Get current library scan progress static Future> getLibraryScanProgress() async { final result = await _channel.invokeMethod('getLibraryScanProgress'); - return jsonDecode(result as String) as Map; + return _decodeMapResult(result); } static Stream> libraryScanProgressStream() { - return _libraryScanProgressEvents.receiveBroadcastStream().map((event) { - if (event is String) { - return jsonDecode(event) as Map; - } - if (event is Map) { - return Map.from(event); - } - return const {}; - }); + return _libraryScanProgressEvents + .receiveBroadcastStream() + .map(_decodeMapResult); } /// Cancel ongoing library scan @@ -1206,6 +1194,20 @@ class PlatformBridge { await _channel.invokeMethod('cancelLibraryScan'); } + static Map _decodeMapResult(dynamic result) { + if (result is Map) { + return Map.from(result); + } + if (result is String) { + if (result.isEmpty) return const {}; + final decoded = jsonDecode(result); + if (decoded is Map) { + return Map.from(decoded); + } + } + return const {}; + } + // MARK: - iOS Security-Scoped Bookmark /// Create a security-scoped bookmark from a filesystem path picked by diff --git a/lib/utils/path_match_keys.dart b/lib/utils/path_match_keys.dart index 0df1c023..3c6969ec 100644 --- a/lib/utils/path_match_keys.dart +++ b/lib/utils/path_match_keys.dart @@ -22,6 +22,9 @@ const _audioExtensions = [ '.aac', ]; +const _maxPathMatchKeyCacheSize = 6000; +final Map> _pathMatchKeyCache = >{}; + /// Strips a trailing audio extension from [path] if present. /// Returns the path without extension, or `null` if no known audio extension /// was found. @@ -41,6 +44,11 @@ Set buildPathMatchKeys(String? filePath) { final cleaned = raw.startsWith('EXISTS:') ? raw.substring(7).trim() : raw; if (cleaned.isEmpty) return const {}; + final cached = _pathMatchKeyCache.remove(cleaned); + if (cached != null) { + _pathMatchKeyCache[cleaned] = cached; + return cached; + } final keys = {}; final visited = {}; @@ -118,7 +126,12 @@ Set buildPathMatchKeys(String? filePath) { } keys.addAll(extensionStrippedKeys); - return keys; + final result = Set.unmodifiable(keys); + _pathMatchKeyCache[cleaned] = result; + while (_pathMatchKeyCache.length > _maxPathMatchKeyCacheSize) { + _pathMatchKeyCache.remove(_pathMatchKeyCache.keys.first); + } + return result; } Iterable _androidEquivalentPaths(String path) { diff --git a/lib/widgets/bottom_sheet_option_tile.dart b/lib/widgets/bottom_sheet_option_tile.dart new file mode 100644 index 00000000..7f599a47 --- /dev/null +++ b/lib/widgets/bottom_sheet_option_tile.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +/// Reusable option tile for bottom sheets. +/// Used in playlist options, track options, cover options, etc. +class BottomSheetOptionTile extends StatelessWidget { + final IconData icon; + final Color? iconColor; + final String title; + final VoidCallback onTap; + + const BottomSheetOptionTile({ + super.key, + required this.icon, + this.iconColor, + required this.title, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 4), + leading: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: iconColor ?? colorScheme.onPrimaryContainer, + size: 20, + ), + ), + title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)), + onTap: onTap, + ); + } +} diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 523970b9..7c0a2599 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -303,7 +303,9 @@ class _DownloadServicePickerState extends ConsumerState { ), for (final ext in downloadExtensions) _ServiceChip( - label: ext.displayName, + label: widget.recommendedService == ext.id + ? '${ext.displayName} (Recommended)' + : ext.displayName, isSelected: _selectedService == ext.id, onTap: () => setState(() => _selectedService = ext.id), iconPath: ext.iconPath, From c8d605fdee80e92648014a883f018d6798a23dfa Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 20:05:24 +0700 Subject: [PATCH 02/33] fix: add ValueListenableBuilder for embedded cover refresh and localize hardcoded queue strings --- lib/l10n/app_localizations.dart | 84 ++++++++++++++++++++++++++++++ lib/l10n/app_localizations_de.dart | 62 ++++++++++++++++++++++ lib/l10n/app_localizations_en.dart | 62 ++++++++++++++++++++++ lib/l10n/app_localizations_es.dart | 62 ++++++++++++++++++++++ lib/l10n/app_localizations_fr.dart | 62 ++++++++++++++++++++++ lib/l10n/app_localizations_hi.dart | 62 ++++++++++++++++++++++ lib/l10n/app_localizations_id.dart | 62 ++++++++++++++++++++++ lib/l10n/app_localizations_ja.dart | 62 ++++++++++++++++++++++ lib/l10n/app_localizations_ko.dart | 62 ++++++++++++++++++++++ lib/l10n/app_localizations_nl.dart | 62 ++++++++++++++++++++++ lib/l10n/app_localizations_pt.dart | 62 ++++++++++++++++++++++ lib/l10n/app_localizations_ru.dart | 62 ++++++++++++++++++++++ lib/l10n/app_localizations_tr.dart | 62 ++++++++++++++++++++++ lib/l10n/app_localizations_zh.dart | 62 ++++++++++++++++++++++ lib/l10n/arb/app_en.arb | 72 +++++++++++++++++++++++++ lib/screens/queue_tab.dart | 30 +++++------ 16 files changed, 977 insertions(+), 15 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 2fd44b83..8be00df1 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5084,6 +5084,90 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Empty only'** String get editMetadataSelectEmpty; + + /// Header for active downloads section with count + /// + /// In en, this message translates to: + /// **'Downloading ({count})'** + String queueDownloadingCount(int count); + + /// Header label for downloaded items section in library + /// + /// In en, this message translates to: + /// **'Downloaded'** + String get queueDownloadedHeader; + + /// Shown while filter results are being computed + /// + /// In en, this message translates to: + /// **'Filtering...'** + String get queueFilteringIndicator; + + /// Track count label with plural support + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 track} other{{count} tracks}}'** + String queueTrackCount(int count); + + /// Album count label with plural support + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 album} other{{count} albums}}'** + String queueAlbumCount(int count); + + /// Empty state title when no album downloads exist + /// + /// In en, this message translates to: + /// **'No album downloads'** + String get queueEmptyAlbums; + + /// Empty state subtitle for album downloads + /// + /// In en, this message translates to: + /// **'Download multiple tracks from an album to see them here'** + String get queueEmptyAlbumsSubtitle; + + /// Empty state title when no single track downloads exist + /// + /// In en, this message translates to: + /// **'No single downloads'** + String get queueEmptySingles; + + /// Empty state subtitle for single track downloads + /// + /// In en, this message translates to: + /// **'Single track downloads will appear here'** + String get queueEmptySinglesSubtitle; + + /// Empty state title when download history is empty + /// + /// In en, this message translates to: + /// **'No download history'** + String get queueEmptyHistory; + + /// Empty state subtitle for download history + /// + /// In en, this message translates to: + /// **'Downloaded tracks will appear here'** + String get queueEmptyHistorySubtitle; + + /// Shown when all playlists are selected in selection mode + /// + /// In en, this message translates to: + /// **'All playlists selected'** + String get selectionAllPlaylistsSelected; + + /// Hint shown in playlist selection mode + /// + /// In en, this message translates to: + /// **'Tap playlists to select'** + String get selectionTapPlaylistsToSelect; + + /// Hint shown when no playlists are selected for deletion + /// + /// In en, this message translates to: + /// **'Select playlists to delete'** + String get selectionSelectPlaylistsToDelete; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index d56bb267..2db9eedb 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -2995,4 +2995,66 @@ class AppLocalizationsDe extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 594df94e..77d56ebc 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -2963,4 +2963,66 @@ class AppLocalizationsEn extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 4dbe9944..165d9242 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -2963,6 +2963,68 @@ class AppLocalizationsEs extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index e80c2576..f4c4251b 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -2964,4 +2964,66 @@ class AppLocalizationsFr extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index a387be72..15c12b13 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -2962,4 +2962,66 @@ class AppLocalizationsHi extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 12ee009f..2d8ae360 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -2972,4 +2972,66 @@ class AppLocalizationsId extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index b0b279ec..508a09e1 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -2949,4 +2949,66 @@ class AppLocalizationsJa extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 97230226..a7785ece 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -2942,4 +2942,66 @@ class AppLocalizationsKo extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 533e9b57..213c2dc9 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -2962,4 +2962,66 @@ class AppLocalizationsNl extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 64890c8c..1456e984 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -2963,6 +2963,68 @@ class AppLocalizationsPt extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index d56adafe..592f73a9 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3022,4 +3022,66 @@ class AppLocalizationsRu extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index d4edbff2..fe1dbad7 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -2968,4 +2968,66 @@ class AppLocalizationsTr extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index c478d001..df0493e1 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -2963,6 +2963,68 @@ class AppLocalizationsZh extends AppLocalizations { @override String get editMetadataSelectEmpty => 'Empty only'; + + @override + String queueDownloadingCount(int count) { + return 'Downloading ($count)'; + } + + @override + String get queueDownloadedHeader => 'Downloaded'; + + @override + String get queueFilteringIndicator => 'Filtering...'; + + @override + String queueTrackCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count tracks', + one: '1 track', + ); + return '$_temp0'; + } + + @override + String queueAlbumCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count albums', + one: '1 album', + ); + return '$_temp0'; + } + + @override + String get queueEmptyAlbums => 'No album downloads'; + + @override + String get queueEmptyAlbumsSubtitle => + 'Download multiple tracks from an album to see them here'; + + @override + String get queueEmptySingles => 'No single downloads'; + + @override + String get queueEmptySinglesSubtitle => + 'Single track downloads will appear here'; + + @override + String get queueEmptyHistory => 'No download history'; + + @override + String get queueEmptyHistorySubtitle => 'Downloaded tracks will appear here'; + + @override + String get selectionAllPlaylistsSelected => 'All playlists selected'; + + @override + String get selectionTapPlaylistsToSelect => 'Tap playlists to select'; + + @override + String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; } /// The translations for Chinese, as used in China (`zh_CN`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7e882ab2..807f9427 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -3903,5 +3903,77 @@ "editMetadataSelectEmpty": "Empty only", "@editMetadataSelectEmpty": { "description": "Button to select only fields that are currently empty" + }, + + "queueDownloadingCount": "Downloading ({count})", + "@queueDownloadingCount": { + "description": "Header for active downloads section with count", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "queueDownloadedHeader": "Downloaded", + "@queueDownloadedHeader": { + "description": "Header label for downloaded items section in library" + }, + "queueFilteringIndicator": "Filtering...", + "@queueFilteringIndicator": { + "description": "Shown while filter results are being computed" + }, + "queueTrackCount": "{count, plural, =1{1 track} other{{count} tracks}}", + "@queueTrackCount": { + "description": "Track count label with plural support", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "queueAlbumCount": "{count, plural, =1{1 album} other{{count} albums}}", + "@queueAlbumCount": { + "description": "Album count label with plural support", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "queueEmptyAlbums": "No album downloads", + "@queueEmptyAlbums": { + "description": "Empty state title when no album downloads exist" + }, + "queueEmptyAlbumsSubtitle": "Download multiple tracks from an album to see them here", + "@queueEmptyAlbumsSubtitle": { + "description": "Empty state subtitle for album downloads" + }, + "queueEmptySingles": "No single downloads", + "@queueEmptySingles": { + "description": "Empty state title when no single track downloads exist" + }, + "queueEmptySinglesSubtitle": "Single track downloads will appear here", + "@queueEmptySinglesSubtitle": { + "description": "Empty state subtitle for single track downloads" + }, + "queueEmptyHistory": "No download history", + "@queueEmptyHistory": { + "description": "Empty state title when download history is empty" + }, + "queueEmptyHistorySubtitle": "Downloaded tracks will appear here", + "@queueEmptyHistorySubtitle": { + "description": "Empty state subtitle for download history" + }, + "selectionAllPlaylistsSelected": "All playlists selected", + "@selectionAllPlaylistsSelected": { + "description": "Shown when all playlists are selected in selection mode" + }, + "selectionTapPlaylistsToSelect": "Tap playlists to select", + "@selectionTapPlaylistsToSelect": { + "description": "Hint shown in playlist selection mode" + }, + "selectionSelectPlaylistsToDelete": "Select playlists to delete", + "@selectionSelectPlaylistsToDelete": { + "description": "Hint shown when no playlists are selected for deletion" } } diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index f303813b..0a32e550 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -1567,8 +1567,8 @@ class _QueueTabState extends ConsumerState { ), Text( allSelected - ? 'All playlists selected' - : 'Tap playlists to select', + ? context.l10n.selectionAllPlaylistsSelected + : context.l10n.selectionTapPlaylistsToSelect, style: Theme.of(context).textTheme.bodySmall ?.copyWith(color: colorScheme.onSurfaceVariant), ), @@ -1643,7 +1643,7 @@ class _QueueTabState extends ConsumerState { label: Text( selectedCount > 0 ? 'Delete $selectedCount ${selectedCount == 1 ? 'playlist' : 'playlists'}' - : 'Select playlists to delete', + : context.l10n.selectionSelectPlaylistsToDelete, ), style: FilledButton.styleFrom( backgroundColor: selectedCount > 0 @@ -3169,7 +3169,7 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ Text( - 'Downloading ($queueCount)', + context.l10n.queueDownloadingCount(queueCount), style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, ), @@ -3631,7 +3631,7 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ Text( - '$totalTrackCount ${totalTrackCount == 1 ? 'track' : 'tracks'}', + context.l10n.queueTrackCount(totalTrackCount), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -3665,7 +3665,7 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ Text( - '$totalAlbumCount ${totalAlbumCount == 1 ? 'album' : 'albums'}', + context.l10n.queueAlbumCount(totalAlbumCount), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -3699,7 +3699,7 @@ class _QueueTabState extends ConsumerState { child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( - 'Downloaded', + context.l10n.queueDownloadedHeader, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), @@ -3723,7 +3723,7 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(width: 12), Text( - 'Filtering...', + context.l10n.queueFilteringIndicator, style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -3897,7 +3897,7 @@ class _QueueTabState extends ConsumerState { child: Row( children: [ Text( - '$totalTrackCount ${totalTrackCount == 1 ? 'track' : 'tracks'}', + context.l10n.queueTrackCount(totalTrackCount), style: Theme.of(context).textTheme.bodyMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), @@ -4056,18 +4056,18 @@ class _QueueTabState extends ConsumerState { switch (filterMode) { case 'albums': - message = 'No album downloads'; - subtitle = 'Download multiple tracks from an album to see them here'; + message = context.l10n.queueEmptyAlbums; + subtitle = context.l10n.queueEmptyAlbumsSubtitle; icon = Icons.album; break; case 'singles': - message = 'No single downloads'; - subtitle = 'Single track downloads will appear here'; + message = context.l10n.queueEmptySingles; + subtitle = context.l10n.queueEmptySinglesSubtitle; icon = Icons.music_note; break; default: - message = 'No download history'; - subtitle = 'Downloaded tracks will appear here'; + message = context.l10n.queueEmptyHistory; + subtitle = context.l10n.queueEmptyHistorySubtitle; icon = Icons.history; } From 0423e36d3497069d63d687b0968191bb1bdb25b2 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 20:08:53 +0700 Subject: [PATCH 03/33] chore: bump version to 3.9.1+116 --- lib/constants/app_info.dart | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 94e71f92..68006386 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart'; /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.9.0'; - static const String buildNumber = '115'; + static const String version = '3.9.1'; + static const String buildNumber = '116'; static const String fullVersion = '$version+$buildNumber'; /// Shows "Internal" in debug builds, actual version in release. diff --git a/pubspec.yaml b/pubspec.yaml index 2bbf902a..762a061b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer publish_to: "none" -version: 3.9.0+115 +version: 3.9.1+116 environment: sdk: ^3.10.0 From 969361664585a3ef4fc97696c96fe70f75ac6e10 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 20:50:33 +0700 Subject: [PATCH 04/33] fix: route tidal/qobuz items from Recent Access to built-in screens instead of extension screens --- lib/screens/home_tab.dart | 12 +++++++++--- lib/utils/clickable_metadata.dart | 12 ++++++++++-- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 7758b273..b135b86c 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -2122,7 +2122,9 @@ class _HomeTabState extends ConsumerState if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && - item.providerId != 'spotify') { + item.providerId != 'spotify' && + item.providerId != 'tidal' && + item.providerId != 'qobuz') { Navigator.push( context, MaterialPageRoute( @@ -2162,7 +2164,9 @@ class _HomeTabState extends ConsumerState } else if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && - item.providerId != 'spotify') { + item.providerId != 'spotify' && + item.providerId != 'tidal' && + item.providerId != 'qobuz') { Navigator.push( context, MaterialPageRoute( @@ -2210,7 +2214,9 @@ class _HomeTabState extends ConsumerState if (item.providerId != null && item.providerId!.isNotEmpty && item.providerId != 'deezer' && - item.providerId != 'spotify') { + item.providerId != 'spotify' && + item.providerId != 'tidal' && + item.providerId != 'qobuz') { Navigator.push( context, MaterialPageRoute( diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart index e7f6d8d7..a4ec68ca 100644 --- a/lib/utils/clickable_metadata.dart +++ b/lib/utils/clickable_metadata.dart @@ -225,11 +225,19 @@ void _pushAlbumScreen( String? coverUrl, String? extensionId, }) { + // Built-in providers (tidal, qobuz, deezer) use AlbumScreen which + // detects the provider from the album ID prefix. Only true JS extensions + // should use ExtensionAlbumScreen. + const builtInProviders = {'tidal', 'qobuz', 'deezer'}; + final isExtension = + extensionId != null && !builtInProviders.contains(extensionId); + final resolvedExtensionId = extensionId; + _pushViaPreferredNavigator( context, - (context) => extensionId != null + (context) => isExtension && resolvedExtensionId != null ? ExtensionAlbumScreen( - extensionId: extensionId, + extensionId: resolvedExtensionId, albumId: albumId, albumName: albumName, coverUrl: coverUrl, From d8bbeb1e6739dca15a69d819e77c6917234a74bf Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 21:16:53 +0700 Subject: [PATCH 05/33] perf: use Tidal/Qobuz metadata for Deezer track resolution --- lib/providers/download_queue_provider.dart | 118 +++++++++++++++++++-- 1 file changed, 108 insertions(+), 10 deletions(-) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 5ee904db..97528c79 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -360,11 +360,7 @@ class DownloadHistoryNotifier extends Notifier { ); } - int _readStartupCursor( - SharedPreferences prefs, - String key, - int totalCount, - ) { + int _readStartupCursor(SharedPreferences prefs, String key, int totalCount) { if (totalCount <= 0) { return 0; } @@ -436,7 +432,10 @@ class DownloadHistoryNotifier extends Notifier { _startupSafRepairCursorKey, candidateIndexes.length, ); - final endCursor = (startCursor + maxItems).clamp(0, candidateIndexes.length); + final endCursor = (startCursor + maxItems).clamp( + 0, + candidateIndexes.length, + ); final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor); if (selectedIndexes.isEmpty) { @@ -648,7 +647,10 @@ class DownloadHistoryNotifier extends Notifier { _startupAudioCursorKey, candidateIndexes.length, ); - final endCursor = (startCursor + maxItems).clamp(0, candidateIndexes.length); + final endCursor = (startCursor + maxItems).clamp( + 0, + candidateIndexes.length, + ); final selectedIndexes = candidateIndexes.sublist(startCursor, endCursor); if (selectedIndexes.isEmpty) { @@ -911,7 +913,8 @@ class DownloadHistoryNotifier extends Notifier { Map replacementPaths, Map pathById, }) - > _inspectOrphanedEntries(List> entries) async { + > + _inspectOrphanedEntries(List> entries) async { final orphanedIds = []; final replacementPaths = {}; final pathById = {}; @@ -934,7 +937,9 @@ class DownloadHistoryNotifier extends Notifier { final sibling = await _findConvertedSibling(filePath); if (sibling != null) { - _historyLog.i('Found converted sibling for $id: $filePath -> $sibling'); + _historyLog.i( + 'Found converted sibling for $id: $filePath -> $sibling', + ); replacementPaths[id] = sibling; pathById[id] = sibling; return MapEntry(id, true); @@ -3894,13 +3899,106 @@ class DownloadQueueNotifier extends Notifier { } } + // For tidal:/qobuz: tracks without ISRC, resolve ISRC from provider + // API directly (faster than SongLink and avoids rate limits). + if (deezerTrackId == null && + (trackToDownload.isrc == null || + trackToDownload.isrc!.isEmpty || + !_isValidISRC(trackToDownload.isrc!)) && + (trackToDownload.id.startsWith('tidal:') || + trackToDownload.id.startsWith('qobuz:'))) { + try { + final colonIdx = trackToDownload.id.indexOf(':'); + final provider = trackToDownload.id.substring(0, colonIdx); + final providerTrackId = trackToDownload.id.substring(colonIdx + 1); + + _log.d('No ISRC, fetching from $provider API: $providerTrackId'); + final providerData = provider == 'tidal' + ? await PlatformBridge.getTidalMetadata('track', providerTrackId) + : await PlatformBridge.getQobuzMetadata('track', providerTrackId); + + final trackData = providerData['track'] as Map?; + if (trackData != null) { + final resolvedIsrc = normalizeOptionalString( + trackData['isrc'] as String?, + ); + + if (resolvedIsrc != null && _isValidISRC(resolvedIsrc)) { + _log.d('Resolved ISRC from $provider: $resolvedIsrc'); + + // Enrich track with provider metadata + final provReleaseDate = normalizeOptionalString( + trackData['release_date'] as String?, + ); + final provTrackNum = trackData['track_number'] as int?; + final provDiscNum = trackData['disc_number'] as int?; + + trackToDownload = Track( + id: trackToDownload.id, + name: trackToDownload.name, + artistName: trackToDownload.artistName, + albumName: trackToDownload.albumName, + albumArtist: trackToDownload.albumArtist, + artistId: trackToDownload.artistId, + albumId: trackToDownload.albumId, + coverUrl: normalizeCoverReference(trackToDownload.coverUrl), + duration: trackToDownload.duration, + isrc: resolvedIsrc, + trackNumber: + (trackToDownload.trackNumber != null && + trackToDownload.trackNumber! > 0) + ? trackToDownload.trackNumber + : provTrackNum, + discNumber: + (trackToDownload.discNumber != null && + trackToDownload.discNumber! > 0) + ? trackToDownload.discNumber + : provDiscNum, + releaseDate: trackToDownload.releaseDate ?? provReleaseDate, + deezerId: trackToDownload.deezerId, + availability: trackToDownload.availability, + albumType: trackToDownload.albumType, + totalTracks: trackToDownload.totalTracks, + source: trackToDownload.source, + ); + + // Search Deezer by the resolved ISRC + try { + final deezerResult = await PlatformBridge.searchDeezerByISRC( + resolvedIsrc, + ); + if (deezerResult['success'] == true && + deezerResult['track_id'] != null) { + deezerTrackId = deezerResult['track_id'].toString(); + _log.d( + 'Found Deezer track ID via $provider ISRC: $deezerTrackId', + ); + } + } catch (e) { + _log.w('Failed to search Deezer by $provider ISRC: $e'); + } + } + } + } catch (e) { + _log.w('Failed to resolve ISRC from provider: $e'); + } + + if (shouldAbortWork('during provider ISRC resolution')) { + return; + } + } + // Fallback: Use SongLink to convert Spotify ID to Deezer ID + // Skip for tidal:/qobuz: IDs – they are not Spotify URLs and the + // provider ISRC resolution above already handles them. if (!selectedExtensionDownloadProvider && deezerTrackId == null && !shouldSkipExtensionSongLinkPrelookup && trackToDownload.id.isNotEmpty && !trackToDownload.id.startsWith('deezer:') && - !trackToDownload.id.startsWith('extension:')) { + !trackToDownload.id.startsWith('extension:') && + !trackToDownload.id.startsWith('tidal:') && + !trackToDownload.id.startsWith('qobuz:')) { try { String spotifyId = trackToDownload.id; if (spotifyId.startsWith('spotify:track:')) { From e487817f21aa778e6beaa1b96d6181d06f8f17bf Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 21:40:36 +0700 Subject: [PATCH 06/33] feat: add sorting options for search results --- lib/l10n/app_localizations.dart | 60 +++++ lib/l10n/app_localizations_de.dart | 30 +++ lib/l10n/app_localizations_en.dart | 30 +++ lib/l10n/app_localizations_es.dart | 30 +++ lib/l10n/app_localizations_fr.dart | 30 +++ lib/l10n/app_localizations_hi.dart | 30 +++ lib/l10n/app_localizations_id.dart | 30 +++ lib/l10n/app_localizations_ja.dart | 30 +++ lib/l10n/app_localizations_ko.dart | 30 +++ lib/l10n/app_localizations_nl.dart | 30 +++ lib/l10n/app_localizations_pt.dart | 30 +++ lib/l10n/app_localizations_ru.dart | 30 +++ lib/l10n/app_localizations_tr.dart | 30 +++ lib/l10n/app_localizations_zh.dart | 30 +++ lib/l10n/arb/app_en.arb | 40 ++++ lib/screens/home_tab.dart | 341 ++++++++++++++++++++++++++--- 16 files changed, 802 insertions(+), 29 deletions(-) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 8be00df1..7b6273a4 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -1432,6 +1432,66 @@ abstract class AppLocalizations { /// **'Playlists'** String get searchPlaylists; + /// Bottom sheet title for search sort options + /// + /// In en, this message translates to: + /// **'Sort Results'** + String get searchSortTitle; + + /// Sort option - default API order + /// + /// In en, this message translates to: + /// **'Default'** + String get searchSortDefault; + + /// Sort option - title ascending + /// + /// In en, this message translates to: + /// **'Title (A-Z)'** + String get searchSortTitleAZ; + + /// Sort option - title descending + /// + /// In en, this message translates to: + /// **'Title (Z-A)'** + String get searchSortTitleZA; + + /// Sort option - artist ascending + /// + /// In en, this message translates to: + /// **'Artist (A-Z)'** + String get searchSortArtistAZ; + + /// Sort option - artist descending + /// + /// In en, this message translates to: + /// **'Artist (Z-A)'** + String get searchSortArtistZA; + + /// Sort option - shortest duration first + /// + /// In en, this message translates to: + /// **'Duration (Shortest)'** + String get searchSortDurationShort; + + /// Sort option - longest duration first + /// + /// In en, this message translates to: + /// **'Duration (Longest)'** + String get searchSortDurationLong; + + /// Sort option - oldest release first + /// + /// In en, this message translates to: + /// **'Release Date (Oldest)'** + String get searchSortDateOldest; + + /// Sort option - newest release first + /// + /// In en, this message translates to: + /// **'Release Date (Newest)'** + String get searchSortDateNewest; + /// Tooltip - play button /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 2db9eedb..f64670cd 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -772,6 +772,36 @@ class AppLocalizationsDe extends AppLocalizations { @override String get searchPlaylists => 'Playlisten'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Abspielen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 77d56ebc..e1e437db 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -759,6 +759,36 @@ class AppLocalizationsEn extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 165d9242..dbaa5d7f 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -759,6 +759,36 @@ class AppLocalizationsEs extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index f4c4251b..9f0a9b86 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -761,6 +761,36 @@ class AppLocalizationsFr extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 15c12b13..a63fe541 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -759,6 +759,36 @@ class AppLocalizationsHi extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 2d8ae360..67f85972 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -762,6 +762,36 @@ class AppLocalizationsId extends AppLocalizations { @override String get searchPlaylists => 'Playlist'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Putar'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 508a09e1..c759276c 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -754,6 +754,36 @@ class AppLocalizationsJa extends AppLocalizations { @override String get searchPlaylists => 'プレイリスト'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => '再生'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index a7785ece..6d1993e2 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -741,6 +741,36 @@ class AppLocalizationsKo extends AppLocalizations { @override String get searchPlaylists => '재생목록들'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => '재생'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 213c2dc9..054c9667 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -759,6 +759,36 @@ class AppLocalizationsNl extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 1456e984..1c9aba43 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -759,6 +759,36 @@ class AppLocalizationsPt extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 592f73a9..8348e0b7 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -773,6 +773,36 @@ class AppLocalizationsRu extends AppLocalizations { @override String get searchPlaylists => 'Плейлисты'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Воспроизвести'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index fe1dbad7..70180433 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -764,6 +764,36 @@ class AppLocalizationsTr extends AppLocalizations { @override String get searchPlaylists => 'Çalma Listeleri'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Oynat'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index df0493e1..e52acfad 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -759,6 +759,36 @@ class AppLocalizationsZh extends AppLocalizations { @override String get searchPlaylists => 'Playlists'; + @override + String get searchSortTitle => 'Sort Results'; + + @override + String get searchSortDefault => 'Default'; + + @override + String get searchSortTitleAZ => 'Title (A-Z)'; + + @override + String get searchSortTitleZA => 'Title (Z-A)'; + + @override + String get searchSortArtistAZ => 'Artist (A-Z)'; + + @override + String get searchSortArtistZA => 'Artist (Z-A)'; + + @override + String get searchSortDurationShort => 'Duration (Shortest)'; + + @override + String get searchSortDurationLong => 'Duration (Longest)'; + + @override + String get searchSortDateOldest => 'Release Date (Oldest)'; + + @override + String get searchSortDateNewest => 'Release Date (Newest)'; + @override String get tooltipPlay => 'Play'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 807f9427..ed71d48f 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -999,6 +999,46 @@ "@searchPlaylists": { "description": "Search result category - playlists" }, + "searchSortTitle": "Sort Results", + "@searchSortTitle": { + "description": "Bottom sheet title for search sort options" + }, + "searchSortDefault": "Default", + "@searchSortDefault": { + "description": "Sort option - default API order" + }, + "searchSortTitleAZ": "Title (A-Z)", + "@searchSortTitleAZ": { + "description": "Sort option - title ascending" + }, + "searchSortTitleZA": "Title (Z-A)", + "@searchSortTitleZA": { + "description": "Sort option - title descending" + }, + "searchSortArtistAZ": "Artist (A-Z)", + "@searchSortArtistAZ": { + "description": "Sort option - artist ascending" + }, + "searchSortArtistZA": "Artist (Z-A)", + "@searchSortArtistZA": { + "description": "Sort option - artist descending" + }, + "searchSortDurationShort": "Duration (Shortest)", + "@searchSortDurationShort": { + "description": "Sort option - shortest duration first" + }, + "searchSortDurationLong": "Duration (Longest)", + "@searchSortDurationLong": { + "description": "Sort option - longest duration first" + }, + "searchSortDateOldest": "Release Date (Oldest)", + "@searchSortDateOldest": { + "description": "Sort option - oldest release first" + }, + "searchSortDateNewest": "Release Date (Newest)", + "@searchSortDateNewest": { + "description": "Sort option - newest release first" + }, "tooltipPlay": "Play", "@tooltipPlay": { "description": "Tooltip - play button" diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index b135b86c..65177c94 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -83,6 +83,18 @@ class _SearchResultBuckets { }); } +enum _SearchSortOption { + defaultOrder, + titleAsc, + titleDesc, + artistAsc, + artistDesc, + durationAsc, + durationDesc, + dateAsc, + dateDesc, +} + const _homeHistoryPreviewLimit = 48; class _HomeHistoryPreview { @@ -244,6 +256,7 @@ class _HomeTabState extends ConsumerState Map? _thumbnailSizesCache; List? _searchBucketsSourceTracks; _SearchResultBuckets? _searchBucketsCache; + _SearchSortOption _searchSortOption = _SearchSortOption.defaultOrder; double _responsiveScale({ required BuildContext context, @@ -564,6 +577,7 @@ class _HomeTabState extends ConsumerState '${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}'; if (_lastSearchQuery == searchKey) return; _lastSearchQuery = searchKey; + _searchSortOption = _SearchSortOption.defaultOrder; final isBuiltInProvider = searchProvider != null && @@ -2399,6 +2413,168 @@ class _HomeTabState extends ConsumerState ); } + // ── Search result sorting ────────────────────────────────────────────── + + String _sortOptionLabel(_SearchSortOption option) { + switch (option) { + case _SearchSortOption.defaultOrder: + return context.l10n.searchSortDefault; + case _SearchSortOption.titleAsc: + return context.l10n.searchSortTitleAZ; + case _SearchSortOption.titleDesc: + return context.l10n.searchSortTitleZA; + case _SearchSortOption.artistAsc: + return context.l10n.searchSortArtistAZ; + case _SearchSortOption.artistDesc: + return context.l10n.searchSortArtistZA; + case _SearchSortOption.durationAsc: + return context.l10n.searchSortDurationShort; + case _SearchSortOption.durationDesc: + return context.l10n.searchSortDurationLong; + case _SearchSortOption.dateAsc: + return context.l10n.searchSortDateOldest; + case _SearchSortOption.dateDesc: + return context.l10n.searchSortDateNewest; + } + } + + void _showSortOptions(ColorScheme colorScheme) { + var tempSort = _searchSortOption; + showModalBottomSheet( + context: context, + useRootNavigator: true, + isScrollControlled: true, + backgroundColor: colorScheme.surfaceContainerLow, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (ctx) => StatefulBuilder( + builder: (ctx, setSheetState) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Row( + children: [ + Text( + context.l10n.searchSortTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + TextButton( + onPressed: () => setSheetState( + () => tempSort = _SearchSortOption.defaultOrder, + ), + child: Text(context.l10n.libraryFilterReset), + ), + ], + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + runSpacing: 8, + children: _SearchSortOption.values.map((option) { + return FilterChip( + label: Text(_sortOptionLabel(option)), + selected: tempSort == option, + showCheckmark: false, + onSelected: (_) => + setSheetState(() => tempSort = option), + ); + }).toList(), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () { + Navigator.pop(ctx); + if (_searchSortOption != tempSort) { + setState(() { + _searchSortOption = tempSort; + }); + } + }, + child: Text(context.l10n.libraryFilterApply), + ), + ), + ], + ), + ), + ); + }, + ), + ); + } + + List _applySortToList( + List items, + String Function(T) getName, + String Function(T) getArtist, + int Function(T) getDuration, + String? Function(T) getDate, + ) { + if (_searchSortOption == _SearchSortOption.defaultOrder) return items; + final sorted = List.of(items); + switch (_searchSortOption) { + case _SearchSortOption.defaultOrder: + break; + case _SearchSortOption.titleAsc: + sorted.sort( + (a, b) => + getName(a).toLowerCase().compareTo(getName(b).toLowerCase()), + ); + case _SearchSortOption.titleDesc: + sorted.sort( + (a, b) => + getName(b).toLowerCase().compareTo(getName(a).toLowerCase()), + ); + case _SearchSortOption.artistAsc: + sorted.sort( + (a, b) => + getArtist(a).toLowerCase().compareTo(getArtist(b).toLowerCase()), + ); + case _SearchSortOption.artistDesc: + sorted.sort( + (a, b) => + getArtist(b).toLowerCase().compareTo(getArtist(a).toLowerCase()), + ); + case _SearchSortOption.durationAsc: + sorted.sort((a, b) => getDuration(a).compareTo(getDuration(b))); + case _SearchSortOption.durationDesc: + sorted.sort((a, b) => getDuration(b).compareTo(getDuration(a))); + case _SearchSortOption.dateAsc: + sorted.sort((a, b) { + final da = getDate(a) ?? ''; + final db = getDate(b) ?? ''; + return da.compareTo(db); + }); + case _SearchSortOption.dateDesc: + sorted.sort((a, b) { + final da = getDate(a) ?? ''; + final db = getDate(b) ?? ''; + return db.compareTo(da); + }); + } + return sorted; + } + List _buildSearchResults({ required List tracks, required List? searchArtists, @@ -2423,6 +2599,61 @@ class _HomeTabState extends ConsumerState final playlistItems = buckets.playlistItems; final artistItems = buckets.artistItems; + // Apply sorting to each list. + final sortedArtists = searchArtists != null && searchArtists.isNotEmpty + ? _applySortToList( + searchArtists, + (a) => a.name, + (a) => a.name, + (a) => 0, + (a) => null, + ) + : searchArtists; + + final sortedAlbums = searchAlbums != null && searchAlbums.isNotEmpty + ? _applySortToList( + searchAlbums, + (a) => a.name, + (a) => a.artists, + (a) => 0, + (a) => a.releaseDate, + ) + : searchAlbums; + + final sortedPlaylists = + searchPlaylists != null && searchPlaylists.isNotEmpty + ? _applySortToList( + searchPlaylists, + (p) => p.name, + (p) => p.owner, + (p) => 0, + (p) => null, + ) + : searchPlaylists; + + // For tracks we need paired sorting (track + original index). + List sortedTracks; + List sortedTrackIndexes; + if (realTracks.isNotEmpty && + _searchSortOption != _SearchSortOption.defaultOrder) { + final paired = List.generate( + realTracks.length, + (i) => (realTracks[i], realTrackIndexes[i]), + ); + final sortedPairs = _applySortToList<(Track, int)>( + paired, + (p) => p.$1.name, + (p) => p.$1.artistName, + (p) => p.$1.duration, + (p) => p.$1.releaseDate, + ); + sortedTracks = sortedPairs.map((p) => p.$1).toList(); + sortedTrackIndexes = sortedPairs.map((p) => p.$2).toList(); + } else { + sortedTracks = realTracks; + sortedTrackIndexes = realTrackIndexes; + } + final slivers = [ if (error != null) SliverToBoxAdapter( @@ -2440,24 +2671,29 @@ class _HomeTabState extends ConsumerState ), ]; - if (searchArtists != null && searchArtists.isNotEmpty) { + // Track whether the sort button has been shown yet (show on first section). + bool sortButtonShown = false; + + if (sortedArtists != null && sortedArtists.isNotEmpty) { slivers.addAll( _buildVirtualizedResultSection( title: context.l10n.searchArtists, - itemCount: searchArtists.length, + itemCount: sortedArtists.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _SearchArtistItemWidget( - key: ValueKey('search-artist-${searchArtists[index].id}'), - artist: searchArtists[index], + key: ValueKey('search-artist-${sortedArtists[index].id}'), + artist: sortedArtists[index], showDivider: showDivider, onTap: () => _navigateToArtist( - searchArtists[index].id, - searchArtists[index].name, - searchArtists[index].imageUrl, + sortedArtists[index].id, + sortedArtists[index].name, + sortedArtists[index].imageUrl, ), ), ), ); + sortButtonShown = true; } if (artistItems.isNotEmpty) { @@ -2466,6 +2702,7 @@ class _HomeTabState extends ConsumerState title: context.l10n.searchArtists, itemCount: artistItems.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _CollectionItemWidget( key: ValueKey('artist-${artistItems[index].id}'), item: artistItems[index], @@ -2474,22 +2711,25 @@ class _HomeTabState extends ConsumerState ), ), ); + sortButtonShown = true; } - if (searchAlbums != null && searchAlbums.isNotEmpty) { + if (sortedAlbums != null && sortedAlbums.isNotEmpty) { slivers.addAll( _buildVirtualizedResultSection( title: context.l10n.searchAlbums, - itemCount: searchAlbums.length, + itemCount: sortedAlbums.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _SearchAlbumItemWidget( - key: ValueKey('search-album-${searchAlbums[index].id}'), - album: searchAlbums[index], + key: ValueKey('search-album-${sortedAlbums[index].id}'), + album: sortedAlbums[index], showDivider: showDivider, - onTap: () => _navigateToSearchAlbum(searchAlbums[index]), + onTap: () => _navigateToSearchAlbum(sortedAlbums[index]), ), ), ); + sortButtonShown = true; } if (albumItems.isNotEmpty) { @@ -2498,6 +2738,7 @@ class _HomeTabState extends ConsumerState title: context.l10n.searchAlbums, itemCount: albumItems.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _CollectionItemWidget( key: ValueKey('album-${albumItems[index].id}'), item: albumItems[index], @@ -2506,22 +2747,25 @@ class _HomeTabState extends ConsumerState ), ), ); + sortButtonShown = true; } - if (searchPlaylists != null && searchPlaylists.isNotEmpty) { + if (sortedPlaylists != null && sortedPlaylists.isNotEmpty) { slivers.addAll( _buildVirtualizedResultSection( title: context.l10n.searchPlaylists, - itemCount: searchPlaylists.length, + itemCount: sortedPlaylists.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _SearchPlaylistItemWidget( - key: ValueKey('search-playlist-${searchPlaylists[index].id}'), - playlist: searchPlaylists[index], + key: ValueKey('search-playlist-${sortedPlaylists[index].id}'), + playlist: sortedPlaylists[index], showDivider: showDivider, - onTap: () => _navigateToSearchPlaylist(searchPlaylists[index]), + onTap: () => _navigateToSearchPlaylist(sortedPlaylists[index]), ), ), ); + sortButtonShown = true; } if (playlistItems.isNotEmpty) { @@ -2530,6 +2774,7 @@ class _HomeTabState extends ConsumerState title: context.l10n.searchPlaylists, itemCount: playlistItems.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _CollectionItemWidget( key: ValueKey('playlist-${playlistItems[index].id}'), item: playlistItems[index], @@ -2538,20 +2783,22 @@ class _HomeTabState extends ConsumerState ), ), ); + sortButtonShown = true; } - if (realTracks.isNotEmpty) { + if (sortedTracks.isNotEmpty) { slivers.addAll( _buildVirtualizedResultSection( title: context.l10n.searchSongs, - itemCount: realTracks.length, + itemCount: sortedTracks.length, colorScheme: colorScheme, + showSortButton: !sortButtonShown, itemBuilder: (index, showDivider) => _TrackItemWithStatus( - key: ValueKey(realTracks[index].id), - track: realTracks[index], - index: realTrackIndexes[index], + key: ValueKey(sortedTracks[index].id), + track: sortedTracks[index], + index: sortedTrackIndexes[index], showDivider: showDivider, - onDownload: () => _downloadTrack(realTrackIndexes[index]), + onDownload: () => _downloadTrack(sortedTrackIndexes[index]), searchExtensionId: searchExtensionId, showLocalLibraryIndicator: showLocalLibraryIndicator, thumbnailSizesByExtensionId: thumbnailSizesByExtensionId, @@ -2569,6 +2816,7 @@ class _HomeTabState extends ConsumerState required int itemCount, required ColorScheme colorScheme, required Widget Function(int index, bool showDivider) itemBuilder, + bool showSortButton = false, }) { final sectionColor = Theme.of(context).brightness == Brightness.dark ? Color.alphaBlend( @@ -2580,12 +2828,47 @@ class _HomeTabState extends ConsumerState return [ SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), - child: Text( - title, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), + padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), + child: Row( + children: [ + Expanded( + child: Text( + title, + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + if (showSortButton) + SizedBox( + height: 32, + child: TextButton.icon( + onPressed: () => _showSortOptions(colorScheme), + icon: Icon( + Icons.swap_vert, + size: 18, + color: _searchSortOption != _SearchSortOption.defaultOrder + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + label: Text( + _searchSortOption != _SearchSortOption.defaultOrder + ? _sortOptionLabel(_searchSortOption) + : context.l10n.libraryFilterSort, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: + _searchSortOption != _SearchSortOption.defaultOrder + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 8), + visualDensity: VisualDensity.compact, + ), + ), + ), + ], ), ), ), From 49c2501fbc9cc5a228981d7c2c095815b5f023ff Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 21:47:31 +0700 Subject: [PATCH 07/33] refactor: use pointer returns and unified forceRefresh in ExtensionStore --- go_backend/exports.go | 6 +----- go_backend/extension_store.go | 27 +++++++++++++++++---------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/go_backend/exports.go b/go_backend/exports.go index a1cf605b..f26f263e 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -3214,11 +3214,7 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { return "", fmt.Errorf("extension store not initialized") } - if forceRefresh { - store.FetchRegistry(true) - } - - extensions, err := store.GetExtensionsWithStatus() + extensions, err := store.getExtensionsWithStatus(forceRefresh) if err != nil { return "", err } diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 22ac2d0a..ed8915ab 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -97,8 +97,8 @@ type StoreExtensionResponse struct { HasUpdate bool `json:"has_update"` } -func (e *StoreExtension) ToResponse() StoreExtensionResponse { - return StoreExtensionResponse{ +func (e *StoreExtension) ToResponse() *StoreExtensionResponse { + return &StoreExtensionResponse{ ID: e.ID, Name: e.Name, DisplayName: e.getDisplayName(), @@ -289,8 +289,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error return ®istry, nil } -func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, error) { - registry, err := s.FetchRegistry(false) +func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExtensionResponse, error) { + registry, err := s.FetchRegistry(forceRefresh) if err != nil { return nil, err } @@ -304,22 +304,29 @@ func (s *ExtensionStore) GetExtensionsWithStatus() ([]StoreExtensionResponse, er } } - result := make([]StoreExtensionResponse, len(registry.Extensions)) - for i, ext := range registry.Extensions { - resp := ext.ToResponse() + LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed)) + result := make([]*StoreExtensionResponse, 0, len(registry.Extensions)) + for i := range registry.Extensions { + ext := ®istry.Extensions[i] + resp := ext.ToResponse() if installedVersion, ok := installed[ext.ID]; ok { resp.IsInstalled = true resp.InstalledVersion = installedVersion resp.HasUpdate = compareVersions(ext.Version, installedVersion) > 0 } - result[i] = resp + result = append(result, resp) } + LogDebug("ExtensionStore", "Built store response payload for %d extensions", len(result)) return result, nil } +func (s *ExtensionStore) GetExtensionsWithStatus() ([]*StoreExtensionResponse, error) { + return s.getExtensionsWithStatus(false) +} + func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error { registry, err := s.FetchRegistry(false) if err != nil { @@ -470,7 +477,7 @@ func (s *ExtensionStore) GetCategories() []string { } } -func (s *ExtensionStore) SearchExtensions(query string, category string) ([]StoreExtensionResponse, error) { +func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*StoreExtensionResponse, error) { extensions, err := s.GetExtensionsWithStatus() if err != nil { return nil, err @@ -480,7 +487,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]Stor return extensions, nil } - var result []StoreExtensionResponse + result := make([]*StoreExtensionResponse, 0, len(extensions)) queryLower := toLower(query) for _, ext := range extensions { From 66d714d368fbcd4cd0cad575c01329663b9faa68 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 22:27:22 +0700 Subject: [PATCH 08/33] fix: unify search bar, filter chips, tab navigation, and clean up comments --- go_backend/deezer.go | 3 +- go_backend/exports.go | 2 - go_backend/httputil.go | 5 - go_backend/lyrics.go | 6 - go_backend/youtube.go | 5 - lib/constants/app_info.dart | 2 +- lib/main.dart | 3 - lib/providers/download_queue_provider.dart | 6 +- lib/screens/home_tab.dart | 39 +- lib/screens/main_shell.dart | 58 ++- lib/screens/queue_tab.dart | 577 +++++++++++---------- lib/screens/store_tab.dart | 61 ++- 12 files changed, 394 insertions(+), 373 deletions(-) diff --git a/go_backend/deezer.go b/go_backend/deezer.go index a389f3c8..3d76bcbf 100644 --- a/go_backend/deezer.go +++ b/go_backend/deezer.go @@ -1181,7 +1181,7 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa for attempt := 0; attempt <= deezerMaxRetries; attempt++ { if attempt > 0 { - delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) // Exponential backoff + delay := deezerRetryDelay * time.Duration(1<<(attempt-1)) GoLog("[Deezer] Retry %d/%d after %v...\n", attempt, deezerMaxRetries, delay) time.Sleep(delay) } @@ -1194,7 +1194,6 @@ func (c *DeezerClient) getJSON(ctx context.Context, endpoint string, dst interfa lastErr = err errStr := err.Error() - // Check if error is retryable isRetryable := strings.Contains(errStr, "timeout") || strings.Contains(errStr, "connection reset") || strings.Contains(errStr, "connection refused") || diff --git a/go_backend/exports.go b/go_backend/exports.go index f26f263e..3c9ec59d 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -48,7 +48,6 @@ func CheckAvailability(spotifyID, isrc string) (string, error) { } // SetSongLinkNetworkOptions is kept for backward compatibility. -// It now applies global network compatibility options for all backend API requests. func SetSongLinkNetworkOptions(allowHTTP, insecureTLS bool) { SetNetworkCompatibilityOptions(allowHTTP, insecureTLS) } @@ -910,7 +909,6 @@ func EditFileMetadata(filePath, metadataJSON string) (string, error) { return string(jsonBytes), nil } - // MP3/Opus: return metadata for Dart-side FFmpeg embedding resp := map[string]any{ "success": true, "method": "ffmpeg", diff --git a/go_backend/httputil.go b/go_backend/httputil.go index 05e4af8f..b3a4c752 100644 --- a/go_backend/httputil.go +++ b/go_backend/httputil.go @@ -300,14 +300,11 @@ func DoRequestWithRetry(client *http.Client, req *http.Request, config RetryConf continue } - // Check for ISP blocking via HTTP status codes - // Some ISPs return 403 or 451 when blocking content if resp.StatusCode == 403 || resp.StatusCode == 451 { body, _ := io.ReadAll(resp.Body) resp.Body.Close() bodyStr := strings.ToLower(string(body)) - // Check if response looks like ISP blocking page ispBlockingIndicators := []string{ "blocked", "forbidden", "access denied", "not available in your", "restricted", "censored", "unavailable for legal", "blocked by", @@ -518,7 +515,6 @@ func IsISPBlocking(err error, requestURL string) *ISPBlockingError { return nil } -// Returns true if ISP blocking was detected func CheckAndLogISPBlocking(err error, requestURL string, tag string) bool { ispErr := IsISPBlocking(err, requestURL) if ispErr != nil { @@ -553,7 +549,6 @@ func extractDomain(rawURL string) string { return "unknown" } -// If ISP blocking is detected, returns a more descriptive error func WrapErrorWithISPCheck(err error, requestURL string, tag string) error { if err == nil { return nil diff --git a/go_backend/lyrics.go b/go_backend/lyrics.go index 60d3aa85..ef3d855e 100644 --- a/go_backend/lyrics.go +++ b/go_backend/lyrics.go @@ -83,7 +83,6 @@ func SetLyricsProviderOrder(providers []string) { return } - // Validate provider names validNames := map[string]bool{ LyricsProviderSpotifyAPI: true, LyricsProviderLRCLIB: true, @@ -105,7 +104,6 @@ func SetLyricsProviderOrder(providers []string) { GoLog("[Lyrics] Provider order set to: %v\n", valid) } -// GetLyricsProviderOrder returns the current lyrics provider order. func GetLyricsProviderOrder() []string { lyricsProvidersMu.RLock() defer lyricsProvidersMu.RUnlock() @@ -119,7 +117,6 @@ func GetLyricsProviderOrder() []string { return result } -// GetAvailableLyricsProviders returns metadata about all available providers. func GetAvailableLyricsProviders() []map[string]interface{} { return []map[string]interface{}{ {"id": LyricsProviderSpotifyAPI, "name": "Spotify Lyrics API", "has_proxy_dependency": true, "description": "Spotify-sourced lyrics via Paxsenix"}, @@ -140,7 +137,6 @@ func normalizeLyricsFetchOptions(opts LyricsFetchOptions) LyricsFetchOptions { return opts } -// SetLyricsFetchOptions sets provider-specific lyric fetch behavior. func SetLyricsFetchOptions(opts LyricsFetchOptions) { normalized := normalizeLyricsFetchOptions(opts) @@ -156,7 +152,6 @@ func SetLyricsFetchOptions(opts LyricsFetchOptions) { ) } -// GetLyricsFetchOptions returns current provider-specific lyric fetch behavior. func GetLyricsFetchOptions() LyricsFetchOptions { lyricsFetchOptionsMu.RLock() defer lyricsFetchOptionsMu.RUnlock() @@ -667,7 +662,6 @@ func (c *LyricsClient) FetchLyricsAllSources(spotifyID, trackName, artistName st GoLog("[Lyrics] Searching for: %s - %s (providers: %v)\n", artistName, trackName, providerOrder) - // Cascade through all configured built-in providers for _, providerName := range providerOrder { GoLog("[Lyrics] Trying provider: %s\n", providerName) diff --git a/go_backend/youtube.go b/go_backend/youtube.go index 3bb65f5a..abffd2ff 100644 --- a/go_backend/youtube.go +++ b/go_backend/youtube.go @@ -542,7 +542,6 @@ func searchYouTubeMusicViaExtension(artistName, trackName string) string { extManager := GetExtensionManager() searchProviders := extManager.GetSearchProviders() - // Find the ytmusic-spotiflac extension var ytProvider *ExtensionProviderWrapper for _, p := range searchProviders { if p.extension.ID == "ytmusic-spotiflac" { @@ -569,7 +568,6 @@ func searchYouTubeMusicViaExtension(artistName, trackName string) string { return "" } - // Find the first track result (item_type == "track" with a valid video ID) for _, track := range results { if track.ItemType != "" && track.ItemType != "track" { continue @@ -610,7 +608,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Fallback: Try Spotify ID via SongLink if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) { GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID) songlink := NewSongLinkClient() @@ -622,7 +619,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Fallback: Try Deezer ID via SongLink if youtubeURL == "" && req.DeezerID != "" { GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID) songlink := NewSongLinkClient() @@ -634,7 +630,6 @@ func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { } } - // Fallback: Try ISRC via SongLink if youtubeURL == "" && req.ISRC != "" { GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC) songlink := NewSongLinkClient() diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 68006386..309147fc 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -10,7 +10,7 @@ class AppInfo { /// Shows "Internal" in debug builds, actual version in release. static String get displayVersion => kDebugMode ? 'Internal' : version; - static const String appName = 'SpotiFLAC'; + static const String appName = 'SpotiFLAC Mobile'; static const String copyright = '© 2026 SpotiFLAC'; static const String mobileAuthor = 'zarzet'; diff --git a/lib/main.dart b/lib/main.dart index 89d5d93c..56c94ae2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -192,11 +192,9 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> if (settings.localLibraryPath.isEmpty) return; if (settings.localLibraryAutoScan == 'off') return; - // Don't start a scan if one is already running. final libraryState = ref.read(localLibraryProvider); if (libraryState.isScanning) return; - // Determine cooldown based on auto-scan mode. final now = DateTime.now(); final prefs = await SharedPreferences.getInstance(); final lastScanned = readLocalLibraryLastScannedAt(prefs); @@ -220,7 +218,6 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> } } - // All checks passed -- start an incremental scan. final iosBookmark = settings.localLibraryBookmark; ref .read(localLibraryProvider.notifier) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 97528c79..574b8256 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -874,8 +874,9 @@ class DownloadHistoryNotifier extends Notifier { await _db.upsert(updated.toJson()); } - /// Remove history entries where the file no longer exists on disk - /// Returns the number of orphaned entries removed + /// Remove history entries where the file no longer exists on disk. + /// Returns the number of orphaned entries removed. + /// Audio file extensions that the app commonly produces or converts between. static const _audioExtensions = [ '.flac', @@ -891,7 +892,6 @@ class DownloadHistoryNotifier extends Notifier { /// different audio extension exists (e.g. the user converted .flac → .opus). /// Returns the path of the first match found, or `null` if none exist. Future _findConvertedSibling(String originalPath) async { - // Strip the current extension to get the base path. final dotIndex = originalPath.lastIndexOf('.'); if (dotIndex < 0) return null; final basePath = originalPath.substring(0, dotIndex); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 65177c94..890fbd09 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -3124,16 +3124,6 @@ class _HomeTabState extends ConsumerState _triggerSearchWithFilter(null); }, showCheckmark: false, - selectedColor: colorScheme.primaryContainer, - backgroundColor: colorScheme.surfaceContainerHighest, - labelStyle: TextStyle( - color: selectedFilter == null - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: selectedFilter == null - ? FontWeight.w600 - : FontWeight.normal, - ), ), ), ...filters.map((filter) { @@ -3148,24 +3138,8 @@ class _HomeTabState extends ConsumerState _triggerSearchWithFilter(filter.id); }, showCheckmark: false, - selectedColor: colorScheme.primaryContainer, - backgroundColor: colorScheme.surfaceContainerHighest, - labelStyle: TextStyle( - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.normal, - ), avatar: filter.icon != null - ? Icon( - _getFilterIcon(filter.icon!), - size: 18, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - ) + ? Icon(_getFilterIcon(filter.icon!), size: 18) : null, ), ); @@ -3220,15 +3194,11 @@ class _HomeTabState extends ConsumerState fillColor: colorScheme.surfaceContainerHighest, border: OutlineInputBorder( borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.outline.withValues(alpha: 0.5), - ), + borderSide: BorderSide(color: colorScheme.outlineVariant), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), - borderSide: BorderSide( - color: colorScheme.outline.withValues(alpha: 0.5), - ), + borderSide: BorderSide(color: colorScheme.outlineVariant), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), @@ -3276,6 +3246,9 @@ class _HomeTabState extends ConsumerState ), ), onSubmitted: (_) => _onSearchSubmitted(), + onTapOutside: (_) { + FocusScope.of(context).unfocus(); + }, ); } diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 0440a08c..5e2a118a 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -31,9 +31,11 @@ class MainShell extends ConsumerStatefulWidget { ConsumerState createState() => _MainShellState(); } -class _MainShellState extends ConsumerState { +class _MainShellState extends ConsumerState + with SingleTickerProviderStateMixin { int _currentIndex = 0; late final PageController _pageController; + late final AnimationController _tabJumpTransitionController; bool _hasCheckedUpdate = false; StreamSubscription? _shareSubscription; DateTime? _lastBackPress; @@ -48,6 +50,11 @@ class _MainShellState extends ConsumerState { void initState() { super.initState(); _pageController = PageController(initialPage: _currentIndex); + _tabJumpTransitionController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 180), + value: 1, + ); ShellNavigationService.syncState( currentTabIndex: _currentIndex, showStoreTab: false, @@ -229,6 +236,7 @@ class _MainShellState extends ConsumerState { void dispose() { _shareSubscription?.cancel(); _pageController.dispose(); + _tabJumpTransitionController.dispose(); super.dispose(); } @@ -251,6 +259,8 @@ class _MainShellState extends ConsumerState { } if (_currentIndex != index) { + final previousIndex = _currentIndex; + final isNonAdjacentJump = (previousIndex - index).abs() > 1; final shouldResetHome = index == 0; HapticFeedback.selectionClick(); setState(() => _currentIndex = index); @@ -265,11 +275,19 @@ class _MainShellState extends ConsumerState { if (shouldResetHome) { _resetHomeToMain(); } - _pageController.animateToPage( - index, - duration: const Duration(milliseconds: 250), - curve: Curves.easeOutCubic, - ); + // Jump directly when skipping intermediate tabs to avoid + // sliding through them. For those jumps, keep a short fade-in + // so the transition still feels intentional. + if (isNonAdjacentJump) { + _pageController.jumpToPage(index); + _tabJumpTransitionController.forward(from: 0); + } else { + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } } } @@ -504,15 +522,27 @@ class _MainShellState extends ConsumerState { return true; }, child: Scaffold( - body: PageView.builder( - controller: _pageController, - itemCount: tabs.length, - onPageChanged: _onPageChanged, - physics: const NeverScrollableScrollPhysics(), - itemBuilder: (context, index) => _KeepAliveTabPage( - key: ValueKey('page-$index'), - child: tabs[index], + body: AnimatedBuilder( + animation: _tabJumpTransitionController, + child: PageView.builder( + controller: _pageController, + itemCount: tabs.length, + onPageChanged: _onPageChanged, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) => _KeepAliveTabPage( + key: ValueKey('page-$index'), + child: tabs[index], + ), ), + builder: (context, child) { + final t = Curves.easeOutCubic.transform( + _tabJumpTransitionController.value, + ); + return Opacity( + opacity: t, + child: Transform.scale(scale: 0.985 + (0.015 * t), child: child), + ); + }, ), bottomNavigationBar: NavigationBar( selectedIndex: _currentIndex.clamp(0, maxIndex), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 0a32e550..21b58379 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -2898,6 +2898,7 @@ class _QueueTabState extends ConsumerState { prefixIcon: const Icon(Icons.search), suffixIcon: _searchQuery.isNotEmpty ? IconButton( + tooltip: 'Clear', icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); @@ -2912,26 +2913,24 @@ class _QueueTabState extends ConsumerState { borderRadius: BorderRadius.circular(28), borderSide: BorderSide( color: colorScheme.outlineVariant, - width: 1, ), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide( color: colorScheme.outlineVariant, - width: 1.5, ), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(28), borderSide: BorderSide( color: colorScheme.primary, - width: 2.5, + width: 2, ), ), contentPadding: const EdgeInsets.symmetric( horizontal: 20, - vertical: 12, + vertical: 16, ), ), onChanged: _onSearchChanged, @@ -3355,238 +3354,91 @@ class _QueueTabState extends ConsumerState { ); } - /// Build a collection item at [index] for the unified "All" tab grid view. - /// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists. + /// Returns the visible collection entries, hiding Wishlist/Loved when empty. + List<_CollectionEntry> _getVisibleCollectionEntries( + LibraryCollectionsState collectionState, + ) { + final entries = <_CollectionEntry>[]; + if (collectionState.wishlistCount > 0) { + entries.add(_CollectionEntry.wishlist); + } + if (collectionState.lovedCount > 0) { + entries.add(_CollectionEntry.loved); + } + for (var i = 0; i < collectionState.playlists.length; i++) { + entries.add(_CollectionEntry.playlist(i)); + } + return entries; + } + + /// Build a collection item for the unified "All" tab grid view. Widget _buildAllTabGridCollectionItem({ required BuildContext context, required ColorScheme colorScheme, - required int index, + required _CollectionEntry entry, required LibraryCollectionsState collectionState, List filteredUnifiedItems = const [], }) { - if (index == 0) { - return _buildCollectionGridItem( - context: context, - colorScheme: colorScheme, - icon: Icons.add_circle_outline, - iconColor: Colors.white, - iconBgColor: const Color(0xFF1DB954), - title: context.l10n.collectionWishlist, - count: collectionState.wishlistCount, - onTap: _openWishlistFolder, - ); - } else if (index == 1) { - return _buildCollectionGridItem( - context: context, - colorScheme: colorScheme, - icon: Icons.favorite, - iconColor: Colors.white, - iconBgColor: const Color(0xFF8C67AC), - title: context.l10n.collectionLoved, - count: collectionState.lovedCount, - onTap: _openLovedFolder, - ); - } else { - final playlist = collectionState.playlists[index - 2]; - final isSelected = _selectedPlaylistIds.contains(playlist.id); - return DragTarget( - onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, - onAcceptWithDetails: (details) { - _onTrackDroppedOnPlaylist( - context, - details.data, - playlist.id, - playlist.name, - allItems: filteredUnifiedItems, - ); - }, - builder: (context, candidateData, rejectedData) { - final isHovering = candidateData.isNotEmpty; - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: isHovering - ? BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colorScheme.primary, width: 2), - color: colorScheme.primary.withValues(alpha: 0.1), - ) - : null, - child: Stack( - children: [ - _buildCollectionGridItem( - context: context, - colorScheme: colorScheme, - coverWidget: _buildPlaylistCover( - context, - playlist, - colorScheme, - ), - title: playlist.name, - count: playlist.tracks.length, - onTap: _isPlaylistSelectionMode - ? () => _togglePlaylistSelection(playlist.id) - : () => _openPlaylistById(playlist.id), - onLongPress: _isPlaylistSelectionMode - ? () => _togglePlaylistSelection(playlist.id) - : () => _enterPlaylistSelectionMode(playlist.id), - ), - if (_isPlaylistSelectionMode) - Positioned( - left: 0, - top: 0, - right: 0, - child: IgnorePointer( - child: AspectRatio( - aspectRatio: 1, - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.3) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - ), - ), - ), - ), - if (_isPlaylistSelectionMode) - Positioned( - top: 4, - right: 4, - child: IgnorePointer( - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : colorScheme.surface.withValues(alpha: 0.85), - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - size: 16, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 16, height: 16), - ), - ), - ), - ], - ), - ); - }, - ); - } - } - - /// Build a collection item at [index] for the unified "All" tab list view. - /// Index 0 = Wishlist, 1 = Loved, 2+ = individual playlists. - Widget _buildAllTabListCollectionItem({ - required BuildContext context, - required ColorScheme colorScheme, - required int index, - required LibraryCollectionsState collectionState, - List filteredUnifiedItems = const [], - }) { - if (index == 0) { - return _buildCollectionListItem( - context: context, - colorScheme: colorScheme, - icon: Icons.add_circle_outline, - iconColor: Colors.white, - iconBgColor: const Color(0xFF1DB954), - title: context.l10n.collectionWishlist, - subtitle: - '${context.l10n.collectionFoldersTitle} • ${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}', - onTap: _openWishlistFolder, - ); - } else if (index == 1) { - return _buildCollectionListItem( - context: context, - colorScheme: colorScheme, - icon: Icons.favorite, - iconColor: Colors.white, - iconBgColor: const Color(0xFF8C67AC), - title: context.l10n.collectionLoved, - subtitle: - '${context.l10n.collectionFoldersTitle} • ${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}', - onTap: _openLovedFolder, - ); - } else { - final playlist = collectionState.playlists[index - 2]; - final isSelected = _selectedPlaylistIds.contains(playlist.id); - return DragTarget( - onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, - onAcceptWithDetails: (details) { - _onTrackDroppedOnPlaylist( - context, - details.data, - playlist.id, - playlist.name, - allItems: filteredUnifiedItems, - ); - }, - builder: (context, candidateData, rejectedData) { - final isHovering = candidateData.isNotEmpty; - return AnimatedContainer( - duration: const Duration(milliseconds: 150), - decoration: isHovering - ? BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: colorScheme.primary, width: 2), - color: colorScheme.primary.withValues(alpha: 0.1), - ) - : null, - child: Row( - children: [ - if (_isPlaylistSelectionMode) - GestureDetector( - onTap: () => _togglePlaylistSelection(playlist.id), - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.only(left: 8), - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - size: 18, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 18, height: 18), - ), - ), - ), - Expanded( - child: _buildCollectionListItem( + switch (entry.type) { + case _CollectionEntryType.wishlist: + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.add_circle_outline, + iconColor: Colors.white, + iconBgColor: const Color(0xFF1DB954), + title: context.l10n.collectionWishlist, + count: collectionState.wishlistCount, + onTap: _openWishlistFolder, + ); + case _CollectionEntryType.loved: + return _buildCollectionGridItem( + context: context, + colorScheme: colorScheme, + icon: Icons.favorite, + iconColor: Colors.white, + iconBgColor: const Color(0xFF8C67AC), + title: context.l10n.collectionLoved, + count: collectionState.lovedCount, + onTap: _openLovedFolder, + ); + case _CollectionEntryType.playlist: + final playlist = collectionState.playlists[entry.playlistIndex]; + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return DragTarget( + onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, + onAcceptWithDetails: (details) { + _onTrackDroppedOnPlaylist( + context, + details.data, + playlist.id, + playlist.name, + allItems: filteredUnifiedItems, + ); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: isHovering + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.primary, width: 2), + color: colorScheme.primary.withValues(alpha: 0.1), + ) + : null, + child: Stack( + children: [ + _buildCollectionGridItem( context: context, colorScheme: colorScheme, coverWidget: _buildPlaylistCover( context, playlist, colorScheme, - 56, ), title: playlist.name, - subtitle: - '${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}', + count: playlist.tracks.length, onTap: _isPlaylistSelectionMode ? () => _togglePlaylistSelection(playlist.id) : () => _openPlaylistById(playlist.id), @@ -3594,12 +3446,176 @@ class _QueueTabState extends ConsumerState { ? () => _togglePlaylistSelection(playlist.id) : () => _enterPlaylistSelectionMode(playlist.id), ), - ), - ], - ), - ); - }, - ); + if (_isPlaylistSelectionMode) + Positioned( + left: 0, + top: 0, + right: 0, + child: IgnorePointer( + child: AspectRatio( + aspectRatio: 1, + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.3) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ), + if (_isPlaylistSelectionMode) + Positioned( + top: 4, + right: 4, + child: IgnorePointer( + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : colorScheme.surface.withValues(alpha: 0.85), + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon( + Icons.check, + size: 16, + color: colorScheme.onPrimary, + ) + : const SizedBox(width: 16, height: 16), + ), + ), + ), + ], + ), + ); + }, + ); + } + } + + /// Build a collection item for the unified "All" tab list view. + Widget _buildAllTabListCollectionItem({ + required BuildContext context, + required ColorScheme colorScheme, + required _CollectionEntry entry, + required LibraryCollectionsState collectionState, + List filteredUnifiedItems = const [], + }) { + switch (entry.type) { + case _CollectionEntryType.wishlist: + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.add_circle_outline, + iconColor: Colors.white, + iconBgColor: const Color(0xFF1DB954), + title: context.l10n.collectionWishlist, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${collectionState.wishlistCount} ${collectionState.wishlistCount == 1 ? 'track' : 'tracks'}', + onTap: _openWishlistFolder, + ); + case _CollectionEntryType.loved: + return _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + icon: Icons.favorite, + iconColor: Colors.white, + iconBgColor: const Color(0xFF8C67AC), + title: context.l10n.collectionLoved, + subtitle: + '${context.l10n.collectionFoldersTitle} • ${collectionState.lovedCount} ${collectionState.lovedCount == 1 ? 'track' : 'tracks'}', + onTap: _openLovedFolder, + ); + case _CollectionEntryType.playlist: + final playlist = collectionState.playlists[entry.playlistIndex]; + final isSelected = _selectedPlaylistIds.contains(playlist.id); + return DragTarget( + onWillAcceptWithDetails: (_) => !_isPlaylistSelectionMode, + onAcceptWithDetails: (details) { + _onTrackDroppedOnPlaylist( + context, + details.data, + playlist.id, + playlist.name, + allItems: filteredUnifiedItems, + ); + }, + builder: (context, candidateData, rejectedData) { + final isHovering = candidateData.isNotEmpty; + return AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: isHovering + ? BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: colorScheme.primary, width: 2), + color: colorScheme.primary.withValues(alpha: 0.1), + ) + : null, + child: Row( + children: [ + if (_isPlaylistSelectionMode) + GestureDetector( + onTap: () => _togglePlaylistSelection(playlist.id), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.only(left: 8), + child: Container( + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary + : Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: isSelected + ? colorScheme.primary + : colorScheme.outline, + width: 2, + ), + ), + child: isSelected + ? Icon( + Icons.check, + size: 18, + color: colorScheme.onPrimary, + ) + : const SizedBox(width: 18, height: 18), + ), + ), + ), + Expanded( + child: _buildCollectionListItem( + context: context, + colorScheme: colorScheme, + coverWidget: _buildPlaylistCover( + context, + playlist, + colorScheme, + 56, + ), + title: playlist.name, + subtitle: + '${playlist.tracks.length} ${playlist.tracks.length == 1 ? 'track' : 'tracks'}', + onTap: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _openPlaylistById(playlist.id), + onLongPress: _isPlaylistSelectionMode + ? () => _togglePlaylistSelection(playlist.id) + : () => _enterPlaylistSelectionMode(playlist.id), + ), + ), + ], + ), + ); + }, + ); } } @@ -3789,13 +3805,15 @@ class _QueueTabState extends ConsumerState { ), delegate: SliverChildBuilderDelegate( (context, index) { - final collectionCount = - 2 + collectionState.playlists.length; + final collectionEntries = _getVisibleCollectionEntries( + collectionState, + ); + final collectionCount = collectionEntries.length; if (index < collectionCount) { return _buildAllTabGridCollectionItem( context: context, colorScheme: colorScheme, - index: index, + entry: collectionEntries[index], collectionState: collectionState, filteredUnifiedItems: filteredUnifiedItems, ); @@ -3831,8 +3849,7 @@ class _QueueTabState extends ConsumerState { return const SizedBox.shrink(); }, childCount: - 2 + - collectionState.playlists.length + + _getVisibleCollectionEntries(collectionState).length + filteredUnifiedItems.length, ), ), @@ -3841,12 +3858,15 @@ class _QueueTabState extends ConsumerState { SliverList( delegate: SliverChildBuilderDelegate( (context, index) { - final collectionCount = 2 + collectionState.playlists.length; + final collectionEntries = _getVisibleCollectionEntries( + collectionState, + ); + final collectionCount = collectionEntries.length; if (index < collectionCount) { return _buildAllTabListCollectionItem( context: context, colorScheme: colorScheme, - index: index, + entry: collectionEntries[index], collectionState: collectionState, filteredUnifiedItems: filteredUnifiedItems, ); @@ -3882,8 +3902,7 @@ class _QueueTabState extends ConsumerState { return const SizedBox.shrink(); }, childCount: - 2 + - collectionState.playlists.length + + _getVisibleCollectionEntries(collectionState).length + filteredUnifiedItems.length, ), ), @@ -6372,6 +6391,20 @@ class _QueueItemSliverRow extends ConsumerWidget { } } +enum _CollectionEntryType { wishlist, loved, playlist } + +class _CollectionEntry { + final _CollectionEntryType type; + final int playlistIndex; + + const _CollectionEntry._(this.type, [this.playlistIndex = -1]); + + static const wishlist = _CollectionEntry._(_CollectionEntryType.wishlist); + static const loved = _CollectionEntry._(_CollectionEntryType.loved); + static _CollectionEntry playlist(int index) => + _CollectionEntry._(_CollectionEntryType.playlist, index); +} + class _FilterChip extends StatelessWidget { final String label; final int count; @@ -6389,52 +6422,36 @@ class _FilterChip extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - return Material( - color: isSelected - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(20), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: TextStyle( - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, - ), + return FilterChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(label), + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isSelected + ? colorScheme.primary.withValues(alpha: 0.2) + : colorScheme.outline.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Text( + count.toString(), + style: TextStyle( + fontSize: 11, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w500, ), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary.withValues(alpha: 0.2) - : colorScheme.outline.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(10), - ), - child: Text( - count.toString(), - style: TextStyle( - fontSize: 11, - color: isSelected - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - ), - ], + ), ), - ), + ], ), + selected: isSelected, + onSelected: (_) => onTap(), + showCheckmark: false, ); } } diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 63256832..41a9143c 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -58,7 +58,9 @@ class _StoreTabState extends ConsumerState { final downloadingId = ref.watch( storeProvider.select((s) => s.downloadingId), ); - final hasRegistryUrl = ref.watch(storeProvider.select((s) => s.hasRegistryUrl)); + final hasRegistryUrl = ref.watch( + storeProvider.select((s) => s.hasRegistryUrl), + ); final registryUrl = ref.watch(storeProvider.select((s) => s.registryUrl)); final filteredExtensions = StoreState( extensions: extensions, @@ -139,7 +141,7 @@ class _StoreTabState extends ConsumerState { prefixIcon: const Icon(Icons.search), suffixIcon: value.text.isNotEmpty ? IconButton( - tooltip: 'Clear search', + tooltip: 'Clear', icon: const Icon(Icons.clear), onPressed: () { _searchController.clear(); @@ -151,23 +153,37 @@ class _StoreTabState extends ConsumerState { : null, border: OutlineInputBorder( borderRadius: BorderRadius.circular(28), - borderSide: BorderSide.none, + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: colorScheme.outlineVariant, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: colorScheme.primary, + width: 2, + ), ), filled: true, - fillColor: - Theme.of(context).brightness == Brightness.dark - ? Color.alphaBlend( - Colors.white.withValues(alpha: 0.08), - colorScheme.surface, - ) - : colorScheme.surfaceContainerHighest, + fillColor: colorScheme.surfaceContainerHighest, contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + horizontal: 20, + vertical: 16, ), ), onChanged: (value) { - ref.read(storeProvider.notifier).setSearchQuery(value); + ref + .read(storeProvider.notifier) + .setSearchQuery(value); + }, + onTapOutside: (_) { + FocusScope.of(context).unfocus(); }, ); }, @@ -231,7 +247,8 @@ class _StoreTabState extends ConsumerState { _CategoryChip( label: context.l10n.storeFilterIntegration, icon: Icons.link, - isSelected: selectedCategory == StoreCategory.integration, + isSelected: + selectedCategory == StoreCategory.integration, onTap: () => ref .read(storeProvider.notifier) .setCategory(StoreCategory.integration), @@ -309,9 +326,9 @@ class _StoreTabState extends ConsumerState { const SizedBox(height: 24), Text( context.l10n.storeAddRepoTitle, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold), textAlign: TextAlign.center, ), const SizedBox(height: 32), @@ -347,7 +364,11 @@ class _StoreTabState extends ConsumerState { ), child: Row( children: [ - Icon(Icons.error_outline, size: 20, color: colorScheme.onErrorContainer), + Icon( + Icons.error_outline, + size: 20, + color: colorScheme.onErrorContainer, + ), const SizedBox(width: 8), Expanded( child: Text( @@ -503,7 +524,9 @@ class _StoreTabState extends ConsumerState { ), const SizedBox(height: 16), Text( - hasFilters ? context.l10n.storeEmptyNoResults : context.l10n.storeEmptyNoExtensions, + hasFilters + ? context.l10n.storeEmptyNoResults + : context.l10n.storeEmptyNoExtensions, style: Theme.of(context).textTheme.titleMedium?.copyWith( color: colorScheme.onSurfaceVariant, ), From 1125c757fe9c59b93dbff44d5225246584402275 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 22:33:04 +0700 Subject: [PATCH 09/33] fix: remove unintended home reset on tab switch --- lib/screens/main_shell.dart | 8 -------- 1 file changed, 8 deletions(-) diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 5e2a118a..61feab23 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -261,7 +261,6 @@ class _MainShellState extends ConsumerState if (_currentIndex != index) { final previousIndex = _currentIndex; final isNonAdjacentJump = (previousIndex - index).abs() > 1; - final shouldResetHome = index == 0; HapticFeedback.selectionClick(); setState(() => _currentIndex = index); final showStore = ref.read( @@ -272,9 +271,6 @@ class _MainShellState extends ConsumerState showStoreTab: showStore, ); FocusManager.instance.primaryFocus?.unfocus(); - if (shouldResetHome) { - _resetHomeToMain(); - } // Jump directly when skipping intermediate tabs to avoid // sliding through them. For those jumps, keep a short fade-in // so the transition still feels intentional. @@ -292,7 +288,6 @@ class _MainShellState extends ConsumerState } void _onPageChanged(int index) { - final previousIndex = _currentIndex; if (_currentIndex != index) { setState(() => _currentIndex = index); final showStore = ref.read( @@ -303,9 +298,6 @@ class _MainShellState extends ConsumerState showStoreTab: showStore, ); FocusManager.instance.primaryFocus?.unfocus(); - if (index == 0 && previousIndex != 0) { - _resetHomeToMain(); - } } } From 85d3e58a2690b3556a93596551e76e297a12974b Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 23:17:45 +0700 Subject: [PATCH 10/33] fix: hi-res cover art for Tidal/Qobuz and album metadata override --- go_backend/cover.go | 36 ++++++++++++++++++++++++++++++++++++ go_backend/qobuz.go | 29 ++++++++++++++++++++++++----- go_backend/tidal.go | 12 +++++------- 3 files changed, 65 insertions(+), 12 deletions(-) diff --git a/go_backend/cover.go b/go_backend/cover.go index 10c89963..02d1f03f 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -17,6 +17,8 @@ const ( // Deezer CDN supports these sizes: 56, 250, 500, 1000, 1400, 1800 var deezerSizeRegex = regexp.MustCompile(`/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$`) +var tidalSizeRegex = regexp.MustCompile(`/\d+x\d+\.jpg$`) + func convertSmallToMedium(imageURL string) string { if strings.Contains(imageURL, spotifySize300) { return strings.Replace(imageURL, spotifySize300, spotifySize640, 1) @@ -96,6 +98,16 @@ func upgradeToMaxQuality(coverURL string) string { return upgradeDeezerCover(coverURL) } + // Tidal CDN upgrade: 1280x1280 → origin + if strings.Contains(coverURL, "resources.tidal.com") { + return upgradeTidalCover(coverURL) + } + + // Qobuz CDN upgrade: _600 → _max + if strings.Contains(coverURL, "static.qobuz.com") { + return upgradeQobuzCover(coverURL) + } + return coverURL } @@ -111,6 +123,30 @@ func upgradeDeezerCover(coverURL string) string { return upgraded } +func upgradeTidalCover(coverURL string) string { + if !strings.Contains(coverURL, "resources.tidal.com") { + return coverURL + } + + upgraded := tidalSizeRegex.ReplaceAllString(coverURL, "/origin.jpg") + if upgraded != coverURL { + GoLog("[Cover] Tidal: upgraded to origin resolution") + } + return upgraded +} + +func upgradeQobuzCover(coverURL string) string { + if !strings.Contains(coverURL, "static.qobuz.com") { + return coverURL + } + + upgraded := qobuzImageSizeRe.ReplaceAllString(coverURL, "_max.jpg") + if upgraded != coverURL { + GoLog("[Cover] Qobuz: upgraded to max resolution") + } + return upgraded +} + func GetCoverFromSpotify(imageURL string, maxQuality bool) string { if imageURL == "" { return "" diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 1c4970cf..23a24d75 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -262,26 +262,35 @@ func qobuzTrackDisplayTitle(track *QobuzTrack) string { return fmt.Sprintf("%s (%s)", title, version) } +var qobuzImageSizeRe = regexp.MustCompile(`_\d+\.jpg$`) + +func qobuzUpscaleImageURL(url string) string { + if url == "" { + return "" + } + return qobuzImageSizeRe.ReplaceAllString(url, "_max.jpg") +} + func qobuzTrackAlbumImage(track *QobuzTrack) string { if track == nil { return "" } - return qobuzFirstNonEmpty( + return qobuzUpscaleImageURL(qobuzFirstNonEmpty( track.Album.Image.Large, track.Album.Image.Small, track.Album.Image.Thumbnail, - ) + )) } func qobuzAlbumImage(album *qobuzAlbumDetails) string { if album == nil { return "" } - return qobuzFirstNonEmpty( + return qobuzUpscaleImageURL(qobuzFirstNonEmpty( album.Image.Large, album.Image.Small, album.Image.Thumbnail, - ) + )) } func qobuzTrackArtistID(track *QobuzTrack) string { @@ -936,7 +945,17 @@ func (q *QobuzDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay tracks := make([]AlbumTrackMetadata, 0, len(album.Tracks.Items)) for i := range album.Tracks.Items { - tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(&album.Tracks.Items[i])) + track := &album.Tracks.Items[i] + track.Album.ID = album.ID + track.Album.Title = album.Title + track.Album.ReleaseDate = album.ReleaseDateOriginal + track.Album.Image = qobuzImageSet{ + Thumbnail: album.Image.Thumbnail, + Small: album.Image.Small, + Large: album.Image.Large, + } + track.Album.TracksCount = album.TracksCount + tracks = append(tracks, qobuzTrackToAlbumTrackMetadata(track)) } return &AlbumResponsePayload{ diff --git a/go_backend/tidal.go b/go_backend/tidal.go index 34de563e..d9738e41 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -1015,13 +1015,11 @@ func (t *TidalDownloader) GetAlbumMetadata(resourceID string) (*AlbumResponsePay tracks := make([]AlbumTrackMetadata, 0, len(itemsModule.PagedList.Items)) for _, item := range itemsModule.PagedList.Items { track := item.Item - if track.Album.ID == 0 { - track.Album.ID = headerModule.Album.ID - track.Album.Title = headerModule.Album.Title - track.Album.Cover = headerModule.Album.Cover - track.Album.ReleaseDate = headerModule.Album.ReleaseDate - track.Album.URL = headerModule.Album.URL - } + track.Album.ID = headerModule.Album.ID + track.Album.Title = headerModule.Album.Title + track.Album.Cover = headerModule.Album.Cover + track.Album.ReleaseDate = headerModule.Album.ReleaseDate + track.Album.URL = headerModule.Album.URL tracks = append(tracks, tidalTrackToAlbumTrackMetadata(&track)) } From 5340ca7b164e117354a2c0433e36dfeb692ae371 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 23:23:14 +0700 Subject: [PATCH 11/33] chore: bump version to 4.1.0+117 --- lib/constants/app_info.dart | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 309147fc..c489cd2e 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart'; /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.9.1'; - static const String buildNumber = '116'; + static const String version = '4.1.0'; + static const String buildNumber = '117'; static const String fullVersion = '$version+$buildNumber'; /// Shows "Internal" in debug builds, actual version in release. diff --git a/pubspec.yaml b/pubspec.yaml index 762a061b..3a146a5d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer publish_to: "none" -version: 3.9.1+116 +version: 4.1.0+117 environment: sdk: ^3.10.0 From 091e3fadd9ee3362016a68675d768dfaaf2c4751 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 26 Mar 2026 01:11:29 +0700 Subject: [PATCH 12/33] feat: add audio quality analysis widget and fix USLT lyrics detection --- go_backend/audio_metadata.go | 8 +- go_backend/audio_metadata_mp3_test.go | 133 ++++ go_backend/go.mod | 2 +- go_backend/go.sum | 18 - lib/l10n/app_localizations.dart | 78 ++ lib/l10n/app_localizations_de.dart | 40 + lib/l10n/app_localizations_en.dart | 40 + lib/l10n/app_localizations_es.dart | 40 + lib/l10n/app_localizations_fr.dart | 40 + lib/l10n/app_localizations_hi.dart | 40 + lib/l10n/app_localizations_id.dart | 40 + lib/l10n/app_localizations_ja.dart | 40 + lib/l10n/app_localizations_ko.dart | 40 + lib/l10n/app_localizations_nl.dart | 40 + lib/l10n/app_localizations_pt.dart | 40 + lib/l10n/app_localizations_ru.dart | 40 + lib/l10n/app_localizations_tr.dart | 40 + lib/l10n/app_localizations_zh.dart | 40 + lib/l10n/arb/app_en.arb | 52 ++ lib/screens/track_metadata_screen.dart | 6 + lib/services/platform_bridge.dart | 12 +- lib/widgets/audio_analysis_widget.dart | 1019 ++++++++++++++++++++++++ 22 files changed, 1822 insertions(+), 26 deletions(-) create mode 100644 go_backend/audio_metadata_mp3_test.go create mode 100644 lib/widgets/audio_analysis_widget.dart diff --git a/go_backend/audio_metadata.go b/go_backend/audio_metadata.go index 7a906f8e..8f1b2921 100644 --- a/go_backend/audio_metadata.go +++ b/go_backend/audio_metadata.go @@ -498,7 +498,13 @@ func extractUserTextFrame(data []byte) (string, string) { func isLyricsDescription(description string) bool { switch strings.ToLower(strings.TrimSpace(description)) { - case "lyrics", "lyric", "unsyncedlyrics", "unsynced lyrics", "lrc": + case + "lyrics", + "lyric", + "unsyncedlyrics", + "unsynced lyrics", + "uslt", + "lrc": return true default: return false diff --git a/go_backend/audio_metadata_mp3_test.go b/go_backend/audio_metadata_mp3_test.go new file mode 100644 index 00000000..b63f7b28 --- /dev/null +++ b/go_backend/audio_metadata_mp3_test.go @@ -0,0 +1,133 @@ +package gobackend + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func ffmpegCommand(args ...string) *exec.Cmd { + if ffmpegPath, err := exec.LookPath("ffmpeg"); err == nil { + return exec.Command(ffmpegPath, args...) + } + return exec.Command("ffmpeg", args...) +} + +func runFFmpegTestCommand(t *testing.T, args ...string) { + t.Helper() + cmd := ffmpegCommand(args...) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("ffmpeg failed: %v\n%s", err, string(output)) + } +} + +func TestExtractLyricsReadsMp3AfterCoverEmbed(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not available") + } + + tempDir := t.TempDir() + sourceFlac := filepath.Join(tempDir, "source.flac") + baseMp3 := filepath.Join(tempDir, "base.mp3") + finalMp3 := filepath.Join(tempDir, "final.mp3") + coverPath := filepath.Join(tempDir, "cover.jpg") + lyrics := "[ti:Test Song]\n[ar:Test Artist]\n[00:00.00]Hello from embedded lyrics" + + runFFmpegTestCommand( + t, + "-y", + "-f", + "lavfi", + "-i", + "sine=frequency=440:duration=1", + "-c:a", + "flac", + sourceFlac, + ) + + runFFmpegTestCommand( + t, + "-y", + "-f", + "lavfi", + "-i", + "color=c=red:s=32x32:d=1", + "-frames:v", + "1", + coverPath, + ) + + runFFmpegTestCommand( + t, + "-y", + "-i", + sourceFlac, + "-b:a", + "320k", + "-metadata", + "title=Test Song", + "-metadata", + "artist=Test Artist", + "-metadata", + "lyrics="+lyrics, + baseMp3, + ) + + runFFmpegTestCommand( + t, + "-y", + "-i", + baseMp3, + "-i", + coverPath, + "-map", + "0:a", + "-map_metadata", + "-1", + "-map", + "1:0", + "-c:v:0", + "copy", + "-id3v2_version", + "3", + "-metadata", + "title=Test Song", + "-metadata", + "artist=Test Artist", + "-metadata", + "lyrics="+lyrics, + "-metadata:s:v", + "title=Album cover", + "-metadata:s:v", + "comment=Cover (front)", + "-c:a", + "copy", + finalMp3, + ) + + meta, err := ReadID3Tags(finalMp3) + if err != nil { + t.Fatalf("ReadID3Tags failed: %v", err) + } + if meta == nil { + t.Fatalf("ReadID3Tags returned nil metadata") + } + + embeddedLyrics, err := ExtractLyrics(finalMp3) + if err != nil { + t.Fatalf("ExtractLyrics failed: %v (metadata=%+v)", err, meta) + } + if !strings.Contains(embeddedLyrics, "Hello from embedded lyrics") { + t.Fatalf("embedded lyrics missing, got %q (metadata=%+v)", embeddedLyrics, meta) + } + if !strings.Contains(meta.Lyrics, "Hello from embedded lyrics") { + t.Fatalf("ReadID3Tags lyrics missing, got %+v", meta) + } + + if _, err := os.Stat(finalMp3); err != nil { + t.Fatalf("expected final mp3 to exist: %v", err) + } +} diff --git a/go_backend/go.mod b/go_backend/go.mod index fe67fe7e..7b1e584a 100644 --- a/go_backend/go.mod +++ b/go_backend/go.mod @@ -12,6 +12,7 @@ require ( github.com/refraction-networking/utls v1.8.2 golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 golang.org/x/net v0.50.0 + golang.org/x/text v0.34.0 ) require ( @@ -24,6 +25,5 @@ require ( golang.org/x/mod v0.33.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.34.0 // indirect golang.org/x/tools v0.42.0 // indirect ) diff --git a/go_backend/go.sum b/go_backend/go.sum index 50e29433..3b71ae9b 100644 --- a/go_backend/go.sum +++ b/go_backend/go.sum @@ -6,8 +6,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo= github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3 h1:bVp3yUzvSAJzu9GqID+Z96P+eu5TKnIMJSV4QaZMauM= -github.com/dop251/goja v0.0.0-20260106131823-651366fbe6e3/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5 h1:QckvTXtu55YMopmVeDrPQ/r+T6xjw8KMCmE3UgUldkw= github.com/dop251/goja v0.0.0-20260216154549-8b74ce4618c5/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/go-flac/flacpicture/v2 v2.0.2 h1:HCaJIVZpxnpdWs6G3ECEVRelzqS5xOi1Ba1AGmtXbzE= @@ -30,36 +28,20 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= -golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3 h1:NiJtT7g4ncNFVjVZMAYNBrPSNhIjFYPj8UKA8MEw2A4= -golang.org/x/mobile v0.0.0-20260204172633-1dceadbbeea3/go.mod h1:wReH3Q1agKmmLapipWFnd4NSs8KPz3fK6mSEZjXLkrg= -golang.org/x/mobile v0.0.0-20260209203831-923679eb55af h1:VqXrZNyqFISxo0rNDFZQlRDRIp7RXSJDeh/LbrK+W1k= -golang.org/x/mobile v0.0.0-20260209203831-923679eb55af/go.mod h1:tbwefIr7RlQD1OpZ0KEZ9nux/uiihAOGdafgZfJkmII= golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864 h1:cTVynMSsMYgbUrtia2HB1jrhdUwQNtQti91vUCyjMp4= golang.org/x/mobile v0.0.0-20260211191516-dcd2a3258864/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ= -golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= -golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= -golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= -golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 7b6273a4..eb50e497 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -5228,6 +5228,84 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Select playlists to delete'** String get selectionSelectPlaylistsToDelete; + + /// Title for audio analysis section + /// + /// In en, this message translates to: + /// **'Audio Quality Analysis'** + String get audioAnalysisTitle; + + /// Description for audio analysis tap-to-analyze prompt + /// + /// In en, this message translates to: + /// **'Verify lossless quality with spectrum analysis'** + String get audioAnalysisDescription; + + /// Loading text while analyzing audio + /// + /// In en, this message translates to: + /// **'Analyzing audio...'** + String get audioAnalysisAnalyzing; + + /// Sample rate metric label + /// + /// In en, this message translates to: + /// **'Sample Rate'** + String get audioAnalysisSampleRate; + + /// Bit depth metric label + /// + /// In en, this message translates to: + /// **'Bit Depth'** + String get audioAnalysisBitDepth; + + /// Channels metric label + /// + /// In en, this message translates to: + /// **'Channels'** + String get audioAnalysisChannels; + + /// Duration metric label + /// + /// In en, this message translates to: + /// **'Duration'** + String get audioAnalysisDuration; + + /// Nyquist frequency metric label + /// + /// In en, this message translates to: + /// **'Nyquist'** + String get audioAnalysisNyquist; + + /// File size metric label + /// + /// In en, this message translates to: + /// **'Size'** + String get audioAnalysisFileSize; + + /// Dynamic range metric label + /// + /// In en, this message translates to: + /// **'Dynamic Range'** + String get audioAnalysisDynamicRange; + + /// Peak amplitude metric label + /// + /// In en, this message translates to: + /// **'Peak'** + String get audioAnalysisPeak; + + /// RMS level metric label + /// + /// In en, this message translates to: + /// **'RMS'** + String get audioAnalysisRms; + + /// Total samples metric label + /// + /// In en, this message translates to: + /// **'Samples'** + String get audioAnalysisSamples; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index f64670cd..5ca628be 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3087,4 +3087,44 @@ class AppLocalizationsDe extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index e1e437db..d15c8b31 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3055,4 +3055,44 @@ class AppLocalizationsEn extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index dbaa5d7f..5c35784d 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3055,6 +3055,46 @@ class AppLocalizationsEs extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } /// The translations for Spanish Castilian, as used in Spain (`es_ES`). diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 9f0a9b86..591427f1 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3056,4 +3056,44 @@ class AppLocalizationsFr extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index a63fe541..e69d5e81 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -3054,4 +3054,44 @@ class AppLocalizationsHi extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 67f85972..3fc89c44 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -3064,4 +3064,44 @@ class AppLocalizationsId extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index c759276c..d4af16ec 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3041,4 +3041,44 @@ class AppLocalizationsJa extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 6d1993e2..e9af2187 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3034,4 +3034,44 @@ class AppLocalizationsKo extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 054c9667..521ab98b 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3054,4 +3054,44 @@ class AppLocalizationsNl extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 1c9aba43..b81bdbdc 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3055,6 +3055,46 @@ class AppLocalizationsPt extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } /// The translations for Portuguese, as used in Portugal (`pt_PT`). diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 8348e0b7..13af38da 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3114,4 +3114,44 @@ class AppLocalizationsRu extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 70180433..7050af84 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -3060,4 +3060,44 @@ class AppLocalizationsTr extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index e52acfad..04a1fd43 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3055,6 +3055,46 @@ class AppLocalizationsZh extends AppLocalizations { @override String get selectionSelectPlaylistsToDelete => 'Select playlists to delete'; + + @override + String get audioAnalysisTitle => 'Audio Quality Analysis'; + + @override + String get audioAnalysisDescription => + 'Verify lossless quality with spectrum analysis'; + + @override + String get audioAnalysisAnalyzing => 'Analyzing audio...'; + + @override + String get audioAnalysisSampleRate => 'Sample Rate'; + + @override + String get audioAnalysisBitDepth => 'Bit Depth'; + + @override + String get audioAnalysisChannels => 'Channels'; + + @override + String get audioAnalysisDuration => 'Duration'; + + @override + String get audioAnalysisNyquist => 'Nyquist'; + + @override + String get audioAnalysisFileSize => 'Size'; + + @override + String get audioAnalysisDynamicRange => 'Dynamic Range'; + + @override + String get audioAnalysisPeak => 'Peak'; + + @override + String get audioAnalysisRms => 'RMS'; + + @override + String get audioAnalysisSamples => 'Samples'; } /// The translations for Chinese, as used in China (`zh_CN`). diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index ed71d48f..02ef0a43 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -4015,5 +4015,57 @@ "selectionSelectPlaylistsToDelete": "Select playlists to delete", "@selectionSelectPlaylistsToDelete": { "description": "Hint shown when no playlists are selected for deletion" + }, + "audioAnalysisTitle": "Audio Quality Analysis", + "@audioAnalysisTitle": { + "description": "Title for audio analysis section" + }, + "audioAnalysisDescription": "Verify lossless quality with spectrum analysis", + "@audioAnalysisDescription": { + "description": "Description for audio analysis tap-to-analyze prompt" + }, + "audioAnalysisAnalyzing": "Analyzing audio...", + "@audioAnalysisAnalyzing": { + "description": "Loading text while analyzing audio" + }, + "audioAnalysisSampleRate": "Sample Rate", + "@audioAnalysisSampleRate": { + "description": "Sample rate metric label" + }, + "audioAnalysisBitDepth": "Bit Depth", + "@audioAnalysisBitDepth": { + "description": "Bit depth metric label" + }, + "audioAnalysisChannels": "Channels", + "@audioAnalysisChannels": { + "description": "Channels metric label" + }, + "audioAnalysisDuration": "Duration", + "@audioAnalysisDuration": { + "description": "Duration metric label" + }, + "audioAnalysisNyquist": "Nyquist", + "@audioAnalysisNyquist": { + "description": "Nyquist frequency metric label" + }, + "audioAnalysisFileSize": "Size", + "@audioAnalysisFileSize": { + "description": "File size metric label" + }, + "audioAnalysisDynamicRange": "Dynamic Range", + "@audioAnalysisDynamicRange": { + "description": "Dynamic range metric label" + }, + "audioAnalysisPeak": "Peak", + "@audioAnalysisPeak": { + "description": "Peak amplitude metric label" + }, + "audioAnalysisRms": "RMS", + "@audioAnalysisRms": { + "description": "RMS level metric label" + }, + "audioAnalysisSamples": "Samples", + "@audioAnalysisSamples": { + "description": "Total samples metric label" } } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 09f0175c..c27f426b 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -23,6 +23,7 @@ import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart'; import 'package:spotiflac_android/utils/mime_utils.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; +import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; final _log = AppLogger('TrackMetadata'); @@ -770,6 +771,11 @@ class _TrackMetadataScreenState extends ConsumerState { _buildLyricsCard(context, colorScheme), + if (_fileExists) ...[ + const SizedBox(height: 16), + AudioAnalysisCard(filePath: _filePath), + ], + const SizedBox(height: 24), _buildActionButtons(context, ref, colorScheme, _fileExists), diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 0c4447cf..313f2f40 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -92,9 +92,9 @@ class PlatformBridge { } static Stream> downloadProgressStream() { - return _downloadProgressEvents - .receiveBroadcastStream() - .map(_decodeMapResult); + return _downloadProgressEvents.receiveBroadcastStream().map( + _decodeMapResult, + ); } static Future exitApp() async { @@ -1184,9 +1184,9 @@ class PlatformBridge { } static Stream> libraryScanProgressStream() { - return _libraryScanProgressEvents - .receiveBroadcastStream() - .map(_decodeMapResult); + return _libraryScanProgressEvents.receiveBroadcastStream().map( + _decodeMapResult, + ); } /// Cancel ongoing library scan diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart new file mode 100644 index 00000000..09822fef --- /dev/null +++ b/lib/widgets/audio_analysis_widget.dart @@ -0,0 +1,1019 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math' as math; +import 'dart:ui' as ui; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart'; +import 'package:ffmpeg_kit_flutter_new_full/ffprobe_kit.dart'; +import 'package:ffmpeg_kit_flutter_new_full/level.dart'; +import 'package:ffmpeg_kit_flutter_new_full/return_code.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; + +// --------------------------------------------------------------------------- +// Data models +// --------------------------------------------------------------------------- + +class AudioAnalysisData { + final String filePath; + final int fileSize; + final int sampleRate; + final int channels; + final int bitsPerSample; + final double duration; + final int bitrate; + final String bitDepth; + final double dynamicRange; + final double peakAmplitude; + final double rmsLevel; + final int totalSamples; + final SpectrogramData? spectrum; + + const AudioAnalysisData({ + required this.filePath, + required this.fileSize, + required this.sampleRate, + required this.channels, + required this.bitsPerSample, + required this.duration, + required this.bitrate, + required this.bitDepth, + required this.dynamicRange, + required this.peakAmplitude, + required this.rmsLevel, + required this.totalSamples, + this.spectrum, + }); +} + +class SpectrogramData { + final List magnitudes; // [timeSlice][freqBin] + final int sampleRate; + final int freqBins; + final double duration; + final double maxFreq; + final int sliceCount; + + const SpectrogramData({ + required this.magnitudes, + required this.sampleRate, + required this.freqBins, + required this.duration, + required this.maxFreq, + required this.sliceCount, + }); +} + +// --------------------------------------------------------------------------- +// Audio Analysis Card Widget +// --------------------------------------------------------------------------- + +class AudioAnalysisCard extends StatefulWidget { + final String filePath; + + const AudioAnalysisCard({super.key, required this.filePath}); + + @override + State createState() => _AudioAnalysisCardState(); +} + +class _AudioAnalysisCardState extends State { + AudioAnalysisData? _data; + bool _analyzing = false; + String? _error; + ui.Image? _spectrogramImage; + + static const _supportedExtensions = { + '.flac', + '.mp3', + '.m4a', + '.aac', + '.opus', + '.ogg', + '.wav', + '.wma', + }; + + bool get _isSupported { + final lower = widget.filePath.toLowerCase(); + return _supportedExtensions.any((ext) => lower.endsWith(ext)); + } + + @override + void dispose() { + _spectrogramImage?.dispose(); + super.dispose(); + } + + Future _analyze() async { + if (_analyzing) return; + setState(() { + _analyzing = true; + _error = null; + }); + + try { + final data = await _runAnalysis(widget.filePath); + + ui.Image? image; + if (data.spectrum != null && data.spectrum!.sliceCount > 0) { + image = await _renderSpectrogramToImage(data.spectrum!); + } + + if (mounted) { + setState(() { + _data = data; + _spectrogramImage?.dispose(); + _spectrogramImage = image; + _analyzing = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _analyzing = false; + }); + } + } + } + + // ------------------------------------------------------------------------- + // Analysis pipeline: FFprobe metadata + FFmpeg PCM decode + FFT in isolate + // ------------------------------------------------------------------------- + + Future _runAnalysis(String filePath) async { + // Suppress FFmpegKit verbose logging (metadata/lyrics dump) + await FFmpegKitConfig.setLogLevel(Level.avLogError); + + // Handle SAF content:// URIs by copying to temp first + String workingPath = filePath; + String? tempCopy; + if (filePath.startsWith('content://')) { + tempCopy = await PlatformBridge.copyContentUriToTemp(filePath); + if (tempCopy == null) { + throw Exception('Failed to copy SAF file for analysis'); + } + workingPath = tempCopy; + } + + try { + // 1. Get metadata via FFprobe + final info = await _getMediaInfo(workingPath); + + // 2. Decode to raw PCM via FFmpeg + final tempDir = await getTemporaryDirectory(); + final pcmPath = + '${tempDir.path}/analysis_pcm_${DateTime.now().millisecondsSinceEpoch}.raw'; + + try { + await _decodeToPCM(workingPath, pcmPath, info.sampleRate); + + // 3. Read PCM + compute FFT + metrics in isolate + final pcmBytes = await File(pcmPath).readAsBytes(); + final result = await compute( + _analyzeInIsolate, + _AnalysisParams( + pcmBytes: pcmBytes, + sampleRate: info.sampleRate, + bitsPerSample: info.bitsPerSample, + ), + ); + + return AudioAnalysisData( + filePath: filePath, + fileSize: info.fileSize, + sampleRate: info.sampleRate, + channels: info.channels, + bitsPerSample: info.bitsPerSample, + duration: info.duration, + bitrate: info.bitrate, + bitDepth: info.bitsPerSample > 0 + ? '${info.bitsPerSample}-bit' + : 'N/A', + dynamicRange: result.dynamicRange, + peakAmplitude: result.peakAmplitude, + rmsLevel: result.rmsLevel, + totalSamples: result.totalSamples, + spectrum: result.spectrum, + ); + } finally { + try { + await File(pcmPath).delete(); + } catch (_) {} + } + } finally { + if (tempCopy != null) { + try { + await File(tempCopy).delete(); + } catch (_) {} + } + // Restore default log level + await FFmpegKitConfig.setLogLevel(Level.avLogInfo); + } + } + + Future<_MediaInfo> _getMediaInfo(String filePath) async { + final session = await FFprobeKit.getMediaInformation(filePath); + final info = session.getMediaInformation(); + + if (info == null) { + throw Exception('Failed to get media information'); + } + + int fileSize = 0; + try { + fileSize = await File(filePath).length(); + } catch (_) {} + + final streams = info.getStreams(); + final audioStream = streams.firstWhere( + (s) => s.getAllProperties()?['codec_type'] == 'audio', + orElse: () => throw Exception('No audio stream found'), + ); + + final props = audioStream.getAllProperties() ?? {}; + final sampleRate = + int.tryParse(props['sample_rate']?.toString() ?? '') ?? 0; + final channels = int.tryParse(props['channels']?.toString() ?? '') ?? 0; + final duration = + double.tryParse( + info.getDuration() ?? props['duration']?.toString() ?? '', + ) ?? + 0; + final bitrate = + int.tryParse( + info.getBitrate() ?? props['bit_rate']?.toString() ?? '', + ) ?? + 0; + + int bitsPerSample = + int.tryParse(props['bits_per_raw_sample']?.toString() ?? '') ?? 0; + if (bitsPerSample == 0) { + bitsPerSample = + int.tryParse(props['bits_per_sample']?.toString() ?? '') ?? 0; + } + + // For lossy formats, infer bit depth from sample format + if (bitsPerSample == 0) { + final sampleFmt = props['sample_fmt']?.toString() ?? ''; + if (sampleFmt.contains('16') || + sampleFmt == 's16' || + sampleFmt == 's16p') { + bitsPerSample = 16; + } else if (sampleFmt.contains('32') || + sampleFmt == 'flt' || + sampleFmt == 'fltp') { + bitsPerSample = 32; + } else if (sampleFmt.contains('24') || sampleFmt == 's24') { + bitsPerSample = 24; + } + } + + return _MediaInfo( + fileSize: fileSize, + sampleRate: sampleRate, + channels: channels, + bitsPerSample: bitsPerSample, + duration: duration, + bitrate: bitrate, + ); + } + + Future _decodeToPCM( + String inputPath, + String outputPath, + int sampleRate, + ) async { + // Decode to mono 16-bit signed LE PCM, limit to ~10M samples + final maxDuration = sampleRate > 0 ? (10000000 / sampleRate) : 300; + + final session = await FFmpegKit.executeWithArguments([ + '-loglevel', 'error', + '-i', inputPath, + '-t', maxDuration.toStringAsFixed(1), + '-ac', '1', // mono + '-ar', sampleRate.toString(), + '-f', 's16le', // 16-bit signed little-endian PCM + '-acodec', 'pcm_s16le', + '-y', outputPath, + ]); + + final returnCode = await session.getReturnCode(); + if (!ReturnCode.isSuccess(returnCode)) { + final logs = await session.getLogsAsString(); + throw Exception('FFmpeg decode failed: $logs'); + } + } + + Future _renderSpectrogramToImage(SpectrogramData spectrum) async { + const imgWidth = 800; + const imgHeight = 400; + + final pixels = await compute( + _renderSpectrogramPixels, + _SpectrogramRenderParams( + spectrum: spectrum, + width: imgWidth, + height: imgHeight, + ), + ); + + final completer = Completer(); + ui.decodeImageFromPixels( + pixels, + imgWidth, + imgHeight, + ui.PixelFormat.rgba8888, + completer.complete, + ); + return completer.future; + } + + @override + Widget build(BuildContext context) { + if (!_isSupported) return const SizedBox.shrink(); + + final cs = Theme.of(context).colorScheme; + final l10n = context.l10n; + + if (_analyzing) { + return Card( + color: cs.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2.5), + ), + const SizedBox(height: 12), + Text( + l10n.audioAnalysisAnalyzing, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13), + ), + ], + ), + ), + ), + ); + } + + if (_error != null) { + return Card( + color: cs.errorContainer, + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.error_outline, color: cs.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Text( + _error!, + style: TextStyle(color: cs.onErrorContainer, fontSize: 13), + ), + ), + ], + ), + ), + ); + } + + if (_data == null) { + return Card( + color: cs.surfaceContainerLow, + child: InkWell( + onTap: _analyze, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Icon(Icons.analytics_outlined, color: cs.primary, size: 28), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + l10n.audioAnalysisTitle, + style: TextStyle( + color: cs.onSurface, + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + const SizedBox(height: 2), + Text( + l10n.audioAnalysisDescription, + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 12, + ), + ), + ], + ), + ), + Icon(Icons.chevron_right, color: cs.onSurfaceVariant), + ], + ), + ), + ), + ); + } + + final data = _data!; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _AudioInfoCard(data: data), + if (_spectrogramImage != null) ...[ + const SizedBox(height: 12), + _SpectrogramView(image: _spectrogramImage!, spectrum: data.spectrum!), + ], + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Internal types +// --------------------------------------------------------------------------- + +class _MediaInfo { + final int fileSize; + final int sampleRate; + final int channels; + final int bitsPerSample; + final double duration; + final int bitrate; + + const _MediaInfo({ + required this.fileSize, + required this.sampleRate, + required this.channels, + required this.bitsPerSample, + required this.duration, + required this.bitrate, + }); +} + +class _AnalysisParams { + final Uint8List pcmBytes; + final int sampleRate; + final int bitsPerSample; + + const _AnalysisParams({ + required this.pcmBytes, + required this.sampleRate, + required this.bitsPerSample, + }); +} + +class _AnalysisResult { + final double dynamicRange; + final double peakAmplitude; + final double rmsLevel; + final int totalSamples; + final SpectrogramData? spectrum; + + const _AnalysisResult({ + required this.dynamicRange, + required this.peakAmplitude, + required this.rmsLevel, + required this.totalSamples, + this.spectrum, + }); +} + +// --------------------------------------------------------------------------- +// Isolate: PCM → metrics + FFT spectrogram (all CPU, no GPU) +// --------------------------------------------------------------------------- + +_AnalysisResult _analyzeInIsolate(_AnalysisParams params) { + // Decode 16-bit signed LE PCM to normalized float samples + final byteData = ByteData.sublistView(params.pcmBytes); + final sampleCount = params.pcmBytes.length ~/ 2; // 16-bit = 2 bytes + final samples = Float64List(sampleCount); + + for (int i = 0; i < sampleCount; i++) { + final raw = byteData.getInt16(i * 2, Endian.little); + samples[i] = raw / 32768.0; + } + + // Audio metrics + double peak = 0; + double sumSquares = 0; + for (int i = 0; i < samples.length; i++) { + final abs = samples[i].abs(); + if (abs > peak) peak = abs; + sumSquares += samples[i] * samples[i]; + } + + final peakDB = peak > 0 ? 20.0 * math.log(peak) / math.ln10 : -100.0; + final rms = math.sqrt(sumSquares / samples.length); + final rmsDB = rms > 0 ? 20.0 * math.log(rms) / math.ln10 : -100.0; + + // FFT spectrogram + SpectrogramData? spectrum; + if (samples.length >= 8192) { + spectrum = _computeSpectrum(samples, params.sampleRate); + } + + return _AnalysisResult( + dynamicRange: peakDB - rmsDB, + peakAmplitude: peakDB, + rmsLevel: rmsDB, + totalSamples: sampleCount, + spectrum: spectrum, + ); +} + +SpectrogramData _computeSpectrum(Float64List samples, int sampleRate) { + const fftSize = 8192; + const numSlices = 300; + const freqBins = fftSize ~/ 2; + + final duration = samples.length / sampleRate; + var samplesPerSlice = samples.length ~/ numSlices; + var actualSlices = numSlices; + if (samplesPerSlice < fftSize) { + samplesPerSlice = fftSize; + actualSlices = samples.length ~/ fftSize; + } + + final magnitudes = []; + + for (int i = 0; i < actualSlices; i++) { + final start = i * samplesPerSlice; + if (start + fftSize > samples.length) break; + + // Apply Hann window + final windowed = Float64List(fftSize); + for (int j = 0; j < fftSize; j++) { + final w = 0.5 * (1.0 - math.cos(2.0 * math.pi * j / (fftSize - 1))); + windowed[j] = samples[start + j] * w; + } + + // FFT + final spectrum = _fft(windowed); + + // Magnitude in dB + final mags = Float64List(freqBins); + for (int j = 0; j < freqBins; j++) { + final re = spectrum[j * 2]; + final im = spectrum[j * 2 + 1]; + var mag = math.sqrt(re * re + im * im); + if (mag < 1e-10) mag = 1e-10; + mags[j] = 20.0 * math.log(mag) / math.ln10; + } + magnitudes.add(mags); + } + + return SpectrogramData( + magnitudes: magnitudes, + sampleRate: sampleRate, + freqBins: freqBins, + duration: duration, + maxFreq: sampleRate / 2.0, + sliceCount: magnitudes.length, + ); +} + +/// Cooley-Tukey radix-2 FFT. Returns interleaved [re0, im0, re1, im1, ...]. +Float64List _fft(Float64List realInput) { + final n = realInput.length; + // Interleaved complex: [re, im, re, im, ...] + final data = Float64List(n * 2); + for (int i = 0; i < n; i++) { + data[i * 2] = realInput[i]; + } + + // Bit-reversal permutation + int j = 0; + for (int i = 0; i < n; i++) { + if (i < j) { + final tr = data[i * 2]; + final ti = data[i * 2 + 1]; + data[i * 2] = data[j * 2]; + data[i * 2 + 1] = data[j * 2 + 1]; + data[j * 2] = tr; + data[j * 2 + 1] = ti; + } + int m = n >> 1; + while (m >= 1 && j >= m) { + j -= m; + m >>= 1; + } + j += m; + } + + // Iterative FFT + for (int size = 2; size <= n; size <<= 1) { + final halfSize = size >> 1; + final angle = -2.0 * math.pi / size; + final wRe = math.cos(angle); + final wIm = math.sin(angle); + + for (int i = 0; i < n; i += size) { + double curRe = 1.0; + double curIm = 0.0; + + for (int k = 0; k < halfSize; k++) { + final evenIdx = (i + k) * 2; + final oddIdx = (i + k + halfSize) * 2; + + final tRe = curRe * data[oddIdx] - curIm * data[oddIdx + 1]; + final tIm = curRe * data[oddIdx + 1] + curIm * data[oddIdx]; + + data[oddIdx] = data[evenIdx] - tRe; + data[oddIdx + 1] = data[evenIdx + 1] - tIm; + data[evenIdx] += tRe; + data[evenIdx + 1] += tIm; + + final newRe = curRe * wRe - curIm * wIm; + curIm = curRe * wIm + curIm * wRe; + curRe = newRe; + } + } + } + + return data; +} + +// --------------------------------------------------------------------------- +// Audio Info Card (metrics) +// --------------------------------------------------------------------------- + +class _AudioInfoCard extends StatelessWidget { + final AudioAnalysisData data; + + const _AudioInfoCard({required this.data}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + final nyquist = data.sampleRate / 2; + + return Card( + color: cs.surfaceContainerLow, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.analytics_outlined, color: cs.primary, size: 20), + const SizedBox(width: 8), + Text( + context.l10n.audioAnalysisTitle, + style: TextStyle( + color: cs.onSurface, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _MetricChip( + icon: Icons.graphic_eq, + label: context.l10n.audioAnalysisSampleRate, + value: '${(data.sampleRate / 1000).toStringAsFixed(1)} kHz', + cs: cs, + ), + _MetricChip( + icon: Icons.audio_file, + label: context.l10n.audioAnalysisBitDepth, + value: data.bitDepth, + cs: cs, + ), + _MetricChip( + icon: Icons.surround_sound, + label: context.l10n.audioAnalysisChannels, + value: data.channels == 2 + ? 'Stereo' + : data.channels == 1 + ? 'Mono' + : '${data.channels}', + cs: cs, + ), + _MetricChip( + icon: Icons.timer_outlined, + label: context.l10n.audioAnalysisDuration, + value: _formatDuration(data.duration), + cs: cs, + ), + _MetricChip( + icon: Icons.speed, + label: context.l10n.audioAnalysisNyquist, + value: '${(nyquist / 1000).toStringAsFixed(1)} kHz', + cs: cs, + ), + if (data.fileSize > 0) + _MetricChip( + icon: Icons.storage, + label: context.l10n.audioAnalysisFileSize, + value: _formatFileSize(data.fileSize), + cs: cs, + ), + ], + ), + const SizedBox(height: 8), + Divider(color: cs.outlineVariant), + const SizedBox(height: 8), + Wrap( + spacing: 16, + runSpacing: 8, + children: [ + _MetricChip( + icon: Icons.trending_up, + label: context.l10n.audioAnalysisDynamicRange, + value: '${data.dynamicRange.toStringAsFixed(2)} dB', + cs: cs, + ), + _MetricChip( + icon: Icons.show_chart, + label: context.l10n.audioAnalysisPeak, + value: '${data.peakAmplitude.toStringAsFixed(2)} dB', + cs: cs, + ), + _MetricChip( + icon: Icons.equalizer, + label: context.l10n.audioAnalysisRms, + value: '${data.rmsLevel.toStringAsFixed(2)} dB', + cs: cs, + ), + _MetricChip( + icon: Icons.numbers, + label: context.l10n.audioAnalysisSamples, + value: _formatNumber(data.totalSamples), + cs: cs, + ), + ], + ), + ], + ), + ), + ); + } + + String _formatDuration(double seconds) { + final mins = seconds ~/ 60; + final secs = (seconds % 60).floor(); + return '$mins:${secs.toString().padLeft(2, '0')}'; + } + + String _formatFileSize(int bytes) { + if (bytes == 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + final i = (math.log(bytes) / math.log(1024)).floor(); + final size = bytes / math.pow(1024, i); + return '${size.toStringAsFixed(1)} ${units[i]}'; + } + + String _formatNumber(int n) { + if (n >= 1000000) return '${(n / 1000000).toStringAsFixed(1)}M'; + if (n >= 1000) return '${(n / 1000).toStringAsFixed(1)}K'; + return n.toString(); + } +} + +class _MetricChip extends StatelessWidget { + final IconData icon; + final String label; + final String value; + final ColorScheme cs; + + const _MetricChip({ + required this.icon, + required this.label, + required this.value, + required this.cs, + }); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: cs.onSurfaceVariant), + const SizedBox(width: 4), + Text( + '$label: ', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12), + ), + Text( + value, + style: TextStyle( + color: cs.onSurface, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ); + } +} + +// --------------------------------------------------------------------------- +// Spectrogram View +// --------------------------------------------------------------------------- + +class _SpectrogramView extends StatelessWidget { + final ui.Image image; + final SpectrogramData spectrum; + + const _SpectrogramView({required this.image, required this.spectrum}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + + return Card( + color: Colors.black, + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AspectRatio( + aspectRatio: 2.0, + child: CustomPaint( + painter: _ImagePainter(image), + size: Size.infinite, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Text( + '${context.l10n.audioAnalysisSampleRate}: ${spectrum.sampleRate} Hz', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), + ), + const Spacer(), + Text( + '${context.l10n.audioAnalysisNyquist}: ${(spectrum.maxFreq / 1000).toStringAsFixed(1)} kHz', + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ImagePainter extends CustomPainter { + final ui.Image image; + _ImagePainter(this.image); + + @override + void paint(Canvas canvas, Size size) { + paintImage( + canvas: canvas, + rect: Offset.zero & size, + image: image, + fit: BoxFit.contain, + filterQuality: FilterQuality.medium, + ); + } + + @override + bool shouldRepaint(covariant _ImagePainter old) => old.image != image; +} + +// --------------------------------------------------------------------------- +// Spectrogram pixel-buffer rendering (runs in isolate) +// --------------------------------------------------------------------------- + +class _SpectrogramRenderParams { + final SpectrogramData spectrum; + final int width; + final int height; + + const _SpectrogramRenderParams({ + required this.spectrum, + required this.width, + required this.height, + }); +} + +Uint8List _renderSpectrogramPixels(_SpectrogramRenderParams params) { + final w = params.width; + final h = params.height; + final spectrum = params.spectrum; + final pixels = Uint8List(w * h * 4); + + // Fill black with full alpha + for (int i = 3; i < pixels.length; i += 4) { + pixels[i] = 255; + } + + final slices = spectrum.magnitudes; + if (slices.isEmpty) return pixels; + + final freqBins = spectrum.freqBins; + + // Calculate dB range + double minDB = 0; + double maxDB = -200; + for (final slice in slices) { + for (int i = 0; i < slice.length; i++) { + final db = slice[i]; + if (db > maxDB) maxDB = db; + if (db < minDB && db > -200) minDB = db; + } + } + minDB = math.max(minDB, maxDB - 90); + final dbRange = maxDB - minDB; + if (dbRange <= 0) return pixels; + + for (int px = 0; px < w; px++) { + final t = (px / w * slices.length).floor().clamp(0, slices.length - 1); + final slice = slices[t]; + + for (int py = 0; py < h; py++) { + final freqRatio = 1.0 - (py / h); + final f = (freqRatio * freqBins).floor().clamp(0, freqBins - 1); + if (f >= slice.length) continue; + + final db = slice[f]; + final intensity = ((db - minDB) / dbRange).clamp(0.0, 1.0); + final color = _spekColorRGB(intensity); + + final offset = (py * w + px) * 4; + pixels[offset] = color[0]; + pixels[offset + 1] = color[1]; + pixels[offset + 2] = color[2]; + pixels[offset + 3] = 255; + } + } + + return pixels; +} + +List _spekColorRGB(double intensity) { + int r, g, b; + if (intensity < 0.08) { + final t = intensity / 0.08; + r = 0; + g = 0; + b = (t * 80).floor(); + } else if (intensity < 0.18) { + final t = (intensity - 0.08) / 0.10; + r = (t * 50).floor(); + g = (t * 30).floor(); + b = (80 + t * 175).floor(); + } else if (intensity < 0.28) { + final t = (intensity - 0.18) / 0.10; + r = (50 + t * 150).floor(); + g = (30 - t * 30).floor(); + b = (255 - t * 55).floor(); + } else if (intensity < 0.40) { + final t = (intensity - 0.28) / 0.12; + r = (200 + t * 55).floor(); + g = 0; + b = (200 - t * 200).floor(); + } else if (intensity < 0.52) { + final t = (intensity - 0.40) / 0.12; + r = 255; + g = (t * 100).floor(); + b = 0; + } else if (intensity < 0.65) { + final t = (intensity - 0.52) / 0.13; + r = 255; + g = (100 + t * 80).floor(); + b = 0; + } else if (intensity < 0.78) { + final t = (intensity - 0.65) / 0.13; + r = 255; + g = (180 + t * 55).floor(); + b = (t * 30).floor(); + } else if (intensity < 0.90) { + final t = (intensity - 0.78) / 0.12; + r = 255; + g = (235 + t * 20).floor(); + b = (30 + t * 100).floor(); + } else { + final t = (intensity - 0.90) / 0.10; + r = 255; + g = 255; + b = (130 + t * 125).floor(); + } + return [r.clamp(0, 255), g.clamp(0, 255), b.clamp(0, 255)]; +} From a73f2e1a136319a6d41f922fb14694765756621e Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 26 Mar 2026 01:44:11 +0700 Subject: [PATCH 13/33] feat: auto-select recommended download service based on content source --- lib/screens/album_screen.dart | 12 ++++++++++++ lib/screens/artist_screen.dart | 12 ++++++++++++ lib/screens/home_tab.dart | 3 +++ lib/screens/playlist_screen.dart | 29 +++++++++++++++++++++++++++++ 4 files changed, 56 insertions(+) diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 3f020953..126c2f5e 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -241,6 +241,16 @@ class _AlbumScreenState extends ConsumerState { ); } + String? _recommendedDownloadService() { + if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { + return widget.extensionId; + } + if (widget.albumId.startsWith('tidal:')) return 'tidal'; + if (widget.albumId.startsWith('qobuz:')) return 'qobuz'; + if (widget.albumId.startsWith('deezer:')) return 'deezer'; + return null; + } + @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -551,6 +561,7 @@ class _AlbumScreenState extends ConsumerState { trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, + recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) @@ -623,6 +634,7 @@ class _AlbumScreenState extends ConsumerState { context, trackName: '${tracksToQueue.length} tracks', artistName: widget.albumName, + recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 7aefcd64..751bc797 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -152,6 +152,16 @@ class _ArtistScreenState extends ConsumerState { return tileSize + 64 + ((textScale - 1) * 14); } + String? _recommendedDownloadService() { + if (widget.extensionId != null && widget.extensionId!.isNotEmpty) { + return widget.extensionId; + } + if (widget.artistId.startsWith('tidal:')) return 'tidal'; + if (widget.artistId.startsWith('qobuz:')) return 'qobuz'; + if (widget.artistId.startsWith('deezer:')) return 'deezer'; + return null; + } + @override void initState() { super.initState(); @@ -889,6 +899,7 @@ class _ArtistScreenState extends ConsumerState { if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, + recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { _fetchAndQueueAlbums(albums, service, quality); }, @@ -1689,6 +1700,7 @@ class _ArtistScreenState extends ConsumerState { if (settings.askQualityBeforeDownload) { DownloadServicePicker.show( context, + recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { if (!mounted) return; enqueue(service, quality: quality); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 890fbd09..2c0521e0 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -712,6 +712,8 @@ class _HomeTabState extends ConsumerState playlistName: trackState.playlistName!, coverUrl: trackState.coverUrl, tracks: trackState.tracks, + recommendedService: + trackState.searchExtensionId ?? trackState.searchSource, ), ), ); @@ -4470,6 +4472,7 @@ class _ExtensionPlaylistScreenState playlistName: widget.playlistName, coverUrl: widget.coverUrl, tracks: _tracks!, + recommendedService: widget.extensionId, ); } } diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index fb4818ea..f3af578e 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -21,6 +21,7 @@ class PlaylistScreen extends ConsumerStatefulWidget { final String? coverUrl; final List tracks; final String? playlistId; + final String? recommendedService; const PlaylistScreen({ super.key, @@ -28,6 +29,7 @@ class PlaylistScreen extends ConsumerStatefulWidget { this.coverUrl, required this.tracks, this.playlistId, + this.recommendedService, }); @override @@ -47,6 +49,31 @@ class _PlaylistScreenState extends ConsumerState { String get _playlistName => _resolvedPlaylistName ?? widget.playlistName; String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl; + String? _recommendedDownloadService() { + final explicit = widget.recommendedService; + if (explicit != null && explicit.isNotEmpty) { + return explicit; + } + + final playlistId = widget.playlistId; + if (playlistId != null) { + if (playlistId.startsWith('tidal:')) return 'tidal'; + if (playlistId.startsWith('qobuz:')) return 'qobuz'; + if (playlistId.startsWith('deezer:')) return 'deezer'; + } + + final source = _tracks.firstOrNull?.source; + if (source != null && source.isNotEmpty) { + return source; + } + + final trackId = _tracks.firstOrNull?.id ?? ''; + if (trackId.startsWith('tidal:')) return 'tidal'; + if (trackId.startsWith('qobuz:')) return 'qobuz'; + if (trackId.startsWith('deezer:')) return 'deezer'; + return null; + } + @override void initState() { super.initState(); @@ -429,6 +456,7 @@ class _PlaylistScreenState extends ConsumerState { trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, + recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) @@ -663,6 +691,7 @@ class _PlaylistScreenState extends ConsumerState { context, trackName: '${tracksToQueue.length} tracks', artistName: _playlistName, + recommendedService: _recommendedDownloadService(), onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) From 9483614bc730c73c5f5b2b2f1459c509dd3ce0ca Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 26 Mar 2026 02:17:18 +0700 Subject: [PATCH 14/33] feat: cache audio analysis results and fix total samples metric --- lib/widgets/audio_analysis_widget.dart | 214 +++++++++++++++++++------ 1 file changed, 163 insertions(+), 51 deletions(-) diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 09822fef..396adde9 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; import 'dart:math' as math; import 'dart:ui' as ui; @@ -13,9 +14,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; -// --------------------------------------------------------------------------- // Data models -// --------------------------------------------------------------------------- class AudioAnalysisData { final String filePath; @@ -47,10 +46,42 @@ class AudioAnalysisData { required this.totalSamples, this.spectrum, }); + + Map toJson() => { + 'filePath': filePath, + 'fileSize': fileSize, + 'sampleRate': sampleRate, + 'channels': channels, + 'bitsPerSample': bitsPerSample, + 'duration': duration, + 'bitrate': bitrate, + 'bitDepth': bitDepth, + 'dynamicRange': dynamicRange, + 'peakAmplitude': peakAmplitude, + 'rmsLevel': rmsLevel, + 'totalSamples': totalSamples, + }; + + factory AudioAnalysisData.fromJson(Map json) { + return AudioAnalysisData( + filePath: json['filePath'] as String, + fileSize: json['fileSize'] as int, + sampleRate: json['sampleRate'] as int, + channels: json['channels'] as int, + bitsPerSample: json['bitsPerSample'] as int, + duration: (json['duration'] as num).toDouble(), + bitrate: json['bitrate'] as int, + bitDepth: json['bitDepth'] as String, + dynamicRange: (json['dynamicRange'] as num).toDouble(), + peakAmplitude: (json['peakAmplitude'] as num).toDouble(), + rmsLevel: (json['rmsLevel'] as num).toDouble(), + totalSamples: json['totalSamples'] as int, + ); + } } class SpectrogramData { - final List magnitudes; // [timeSlice][freqBin] + final List magnitudes; final int sampleRate; final int freqBins; final double duration; @@ -67,9 +98,7 @@ class SpectrogramData { }); } -// --------------------------------------------------------------------------- // Audio Analysis Card Widget -// --------------------------------------------------------------------------- class AudioAnalysisCard extends StatefulWidget { final String filePath; @@ -83,6 +112,7 @@ class AudioAnalysisCard extends StatefulWidget { class _AudioAnalysisCardState extends State { AudioAnalysisData? _data; bool _analyzing = false; + bool _checkingCache = true; String? _error; ui.Image? _spectrogramImage; @@ -102,12 +132,45 @@ class _AudioAnalysisCardState extends State { return _supportedExtensions.any((ext) => lower.endsWith(ext)); } + @override + void initState() { + super.initState(); + if (_isSupported) { + _tryLoadFromCache(); + } + } + @override void dispose() { _spectrogramImage?.dispose(); super.dispose(); } + Future _tryLoadFromCache() async { + try { + final cached = await _loadFromCache(widget.filePath); + if (cached != null && mounted) { + setState(() { + _data = cached; + _checkingCache = false; + }); + if (cached.spectrum != null && cached.spectrum!.sliceCount > 0) { + final image = await _renderSpectrogramToImage(cached.spectrum!); + if (mounted) { + setState(() { + _spectrogramImage?.dispose(); + _spectrogramImage = image; + }); + } + } + return; + } + } catch (_) {} + if (mounted) { + setState(() => _checkingCache = false); + } + } + Future _analyze() async { if (_analyzing) return; setState(() { @@ -116,7 +179,17 @@ class _AudioAnalysisCardState extends State { }); try { - final data = await _runAnalysis(widget.filePath); + // Try loading from cache first + final cached = await _loadFromCache(widget.filePath); + AudioAnalysisData data; + + if (cached != null) { + data = cached; + } else { + data = await _runAnalysis(widget.filePath); + // Save to cache (fire-and-forget) + _saveToCache(widget.filePath, data); + } ui.Image? image; if (data.spectrum != null && data.spectrum!.sliceCount > 0) { @@ -141,15 +214,64 @@ class _AudioAnalysisCardState extends State { } } - // ------------------------------------------------------------------------- - // Analysis pipeline: FFprobe metadata + FFmpeg PCM decode + FFT in isolate - // ------------------------------------------------------------------------- + // Analysis cache + + static String _cacheKey(String filePath) { + var hash = 0xcbf29ce484222325; + for (final byte in utf8.encode(filePath)) { + hash ^= byte; + hash = (hash * 0x100000001b3) & 0x7FFFFFFFFFFFFFFF; + } + return hash.toRadixString(16); + } + + static Future _cacheDir() async { + final appSupport = await getApplicationSupportDirectory(); + final dir = Directory('${appSupport.path}/audio_analysis_cache'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dir; + } + + static Future _loadFromCache(String filePath) async { + try { + final dir = await _cacheDir(); + final key = _cacheKey(filePath); + final file = File('${dir.path}/$key.json'); + if (!await file.exists()) return null; + + final json = jsonDecode(await file.readAsString()); + final cachedSize = json['fileSize'] as int; + + if (!filePath.startsWith('content://')) { + final currentSize = await File(filePath).length(); + if (currentSize != cachedSize) return null; + } + + return AudioAnalysisData.fromJson(json); + } catch (_) { + return null; + } + } + + static Future _saveToCache( + String filePath, + AudioAnalysisData data, + ) async { + try { + final dir = await _cacheDir(); + final key = _cacheKey(filePath); + final file = File('${dir.path}/$key.json'); + await file.writeAsString(jsonEncode(data.toJson())); + } catch (_) {} + } + + // Analysis pipeline Future _runAnalysis(String filePath) async { - // Suppress FFmpegKit verbose logging (metadata/lyrics dump) await FFmpegKitConfig.setLogLevel(Level.avLogError); - // Handle SAF content:// URIs by copying to temp first String workingPath = filePath; String? tempCopy; if (filePath.startsWith('content://')) { @@ -161,10 +283,8 @@ class _AudioAnalysisCardState extends State { } try { - // 1. Get metadata via FFprobe final info = await _getMediaInfo(workingPath); - // 2. Decode to raw PCM via FFmpeg final tempDir = await getTemporaryDirectory(); final pcmPath = '${tempDir.path}/analysis_pcm_${DateTime.now().millisecondsSinceEpoch}.raw'; @@ -172,7 +292,6 @@ class _AudioAnalysisCardState extends State { try { await _decodeToPCM(workingPath, pcmPath, info.sampleRate); - // 3. Read PCM + compute FFT + metrics in isolate final pcmBytes = await File(pcmPath).readAsBytes(); final result = await compute( _analyzeInIsolate, @@ -183,6 +302,10 @@ class _AudioAnalysisCardState extends State { ), ); + // Total samples from file metadata (not truncated PCM) + final trueTotalSamples = + (info.duration * info.sampleRate * info.channels).round(); + return AudioAnalysisData( filePath: filePath, fileSize: info.fileSize, @@ -197,7 +320,7 @@ class _AudioAnalysisCardState extends State { dynamicRange: result.dynamicRange, peakAmplitude: result.peakAmplitude, rmsLevel: result.rmsLevel, - totalSamples: result.totalSamples, + totalSamples: trueTotalSamples, spectrum: result.spectrum, ); } finally { @@ -211,7 +334,6 @@ class _AudioAnalysisCardState extends State { await File(tempCopy).delete(); } catch (_) {} } - // Restore default log level await FFmpegKitConfig.setLogLevel(Level.avLogInfo); } } @@ -257,7 +379,6 @@ class _AudioAnalysisCardState extends State { int.tryParse(props['bits_per_sample']?.toString() ?? '') ?? 0; } - // For lossy formats, infer bit depth from sample format if (bitsPerSample == 0) { final sampleFmt = props['sample_fmt']?.toString() ?? ''; if (sampleFmt.contains('16') || @@ -288,18 +409,25 @@ class _AudioAnalysisCardState extends State { String outputPath, int sampleRate, ) async { - // Decode to mono 16-bit signed LE PCM, limit to ~10M samples final maxDuration = sampleRate > 0 ? (10000000 / sampleRate) : 300; final session = await FFmpegKit.executeWithArguments([ - '-loglevel', 'error', - '-i', inputPath, - '-t', maxDuration.toStringAsFixed(1), - '-ac', '1', // mono - '-ar', sampleRate.toString(), - '-f', 's16le', // 16-bit signed little-endian PCM - '-acodec', 'pcm_s16le', - '-y', outputPath, + '-loglevel', + 'error', + '-i', + inputPath, + '-t', + maxDuration.toStringAsFixed(1), + '-ac', + '1', + '-ar', + sampleRate.toString(), + '-f', + 's16le', + '-acodec', + 'pcm_s16le', + '-y', + outputPath, ]); final returnCode = await session.getReturnCode(); @@ -340,6 +468,9 @@ class _AudioAnalysisCardState extends State { final cs = Theme.of(context).colorScheme; final l10n = context.l10n; + // Still checking cache, show nothing yet + if (_checkingCache) return const SizedBox.shrink(); + if (_analyzing) { return Card( color: cs.surfaceContainerLow, @@ -444,9 +575,7 @@ class _AudioAnalysisCardState extends State { } } -// --------------------------------------------------------------------------- // Internal types -// --------------------------------------------------------------------------- class _MediaInfo { final int fileSize; @@ -494,14 +623,11 @@ class _AnalysisResult { }); } -// --------------------------------------------------------------------------- -// Isolate: PCM → metrics + FFT spectrogram (all CPU, no GPU) -// --------------------------------------------------------------------------- +// Isolate: PCM analysis + FFT spectrogram _AnalysisResult _analyzeInIsolate(_AnalysisParams params) { - // Decode 16-bit signed LE PCM to normalized float samples final byteData = ByteData.sublistView(params.pcmBytes); - final sampleCount = params.pcmBytes.length ~/ 2; // 16-bit = 2 bytes + final sampleCount = params.pcmBytes.length ~/ 2; final samples = Float64List(sampleCount); for (int i = 0; i < sampleCount; i++) { @@ -509,7 +635,6 @@ _AnalysisResult _analyzeInIsolate(_AnalysisParams params) { samples[i] = raw / 32768.0; } - // Audio metrics double peak = 0; double sumSquares = 0; for (int i = 0; i < samples.length; i++) { @@ -522,7 +647,6 @@ _AnalysisResult _analyzeInIsolate(_AnalysisParams params) { final rms = math.sqrt(sumSquares / samples.length); final rmsDB = rms > 0 ? 20.0 * math.log(rms) / math.ln10 : -100.0; - // FFT spectrogram SpectrogramData? spectrum; if (samples.length >= 8192) { spectrum = _computeSpectrum(samples, params.sampleRate); @@ -556,17 +680,14 @@ SpectrogramData _computeSpectrum(Float64List samples, int sampleRate) { final start = i * samplesPerSlice; if (start + fftSize > samples.length) break; - // Apply Hann window final windowed = Float64List(fftSize); for (int j = 0; j < fftSize; j++) { final w = 0.5 * (1.0 - math.cos(2.0 * math.pi * j / (fftSize - 1))); windowed[j] = samples[start + j] * w; } - // FFT final spectrum = _fft(windowed); - // Magnitude in dB final mags = Float64List(freqBins); for (int j = 0; j < freqBins; j++) { final re = spectrum[j * 2]; @@ -588,16 +709,14 @@ SpectrogramData _computeSpectrum(Float64List samples, int sampleRate) { ); } -/// Cooley-Tukey radix-2 FFT. Returns interleaved [re0, im0, re1, im1, ...]. +/// Cooley-Tukey radix-2 FFT. Returns interleaved [re, im, re, im, ...]. Float64List _fft(Float64List realInput) { final n = realInput.length; - // Interleaved complex: [re, im, re, im, ...] final data = Float64List(n * 2); for (int i = 0; i < n; i++) { data[i * 2] = realInput[i]; } - // Bit-reversal permutation int j = 0; for (int i = 0; i < n; i++) { if (i < j) { @@ -616,7 +735,6 @@ Float64List _fft(Float64List realInput) { j += m; } - // Iterative FFT for (int size = 2; size <= n; size <<= 1) { final halfSize = size >> 1; final angle = -2.0 * math.pi / size; @@ -649,9 +767,7 @@ Float64List _fft(Float64List realInput) { return data; } -// --------------------------------------------------------------------------- -// Audio Info Card (metrics) -// --------------------------------------------------------------------------- +// Audio Info Card class _AudioInfoCard extends StatelessWidget { final AudioAnalysisData data; @@ -829,9 +945,7 @@ class _MetricChip extends StatelessWidget { } } -// --------------------------------------------------------------------------- // Spectrogram View -// --------------------------------------------------------------------------- class _SpectrogramView extends StatelessWidget { final ui.Image image; @@ -897,9 +1011,7 @@ class _ImagePainter extends CustomPainter { bool shouldRepaint(covariant _ImagePainter old) => old.image != image; } -// --------------------------------------------------------------------------- // Spectrogram pixel-buffer rendering (runs in isolate) -// --------------------------------------------------------------------------- class _SpectrogramRenderParams { final SpectrogramData spectrum; @@ -919,7 +1031,7 @@ Uint8List _renderSpectrogramPixels(_SpectrogramRenderParams params) { final spectrum = params.spectrum; final pixels = Uint8List(w * h * 4); - // Fill black with full alpha + // Fill black for (int i = 3; i < pixels.length; i += 4) { pixels[i] = 255; } @@ -929,7 +1041,7 @@ Uint8List _renderSpectrogramPixels(_SpectrogramRenderParams params) { final freqBins = spectrum.freqBins; - // Calculate dB range + // dB range double minDB = 0; double maxDB = -200; for (final slice in slices) { From d4b37edc2fa6d95f570acc99649029283ebf5600 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 26 Mar 2026 13:09:57 +0700 Subject: [PATCH 15/33] feat: add animation utilities and fix regressions in UI refactor - Add animation_utils.dart with skeleton loaders, staggered list animations, animated checkboxes, badge bump, download success overlay, and shared page route helper - Replace CircularProgressIndicator with shimmer skeleton loaders across album, artist, playlist, search, store, and extension screens - Unify page transitions via slidePageRoute (MaterialPageRoute) for Android predictive back gesture support - Extract AnimatedSelectionCheckbox with configurable unselectedColor to preserve original transparent/opaque backgrounds per context - Add swipe-to-dismiss on download queue items with confirmDismiss dialog for active downloads to prevent accidental cancellation - Add Hero animations for cover art transitions between list and detail - Add AnimatedBadge bump on navigation bar badge count changes - Add DownloadSuccessOverlay green flash on download completion - Restore fine-grained ref.watch(.select()) in _CollectionTrackTile to avoid full list rebuilds on download history changes - Fix DownloadSuccessOverlay re-flashing on widget recreation by initialising _wasSuccess from initial widget state - Remove orphan Hero tag in search_screen that had no matching pair - Chip borderRadius updated from 8 to 20 for consistency --- lib/providers/download_queue_provider.dart | 25 + lib/screens/album_screen.dart | 15 +- lib/screens/artist_screen.dart | 46 +- lib/screens/downloaded_album_screen.dart | 50 +- lib/screens/home_tab.dart | 74 +- lib/screens/library_tracks_folder_screen.dart | 178 ++-- lib/screens/local_album_screen.dart | 35 +- lib/screens/main_shell.dart | 41 +- lib/screens/playlist_screen.dart | 16 +- lib/screens/queue_tab.dart | 387 ++++---- lib/screens/search_screen.dart | 72 +- lib/screens/settings/settings_tab.dart | 23 +- lib/screens/store_tab.dart | 8 +- lib/screens/track_metadata_screen.dart | 84 +- lib/theme/app_theme.dart | 14 +- lib/widgets/animation_utils.dart | 857 ++++++++++++++++++ 16 files changed, 1338 insertions(+), 587 deletions(-) create mode 100644 lib/widgets/animation_utils.dart diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 574b8256..c2712eed 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2421,6 +2421,31 @@ class DownloadQueueNotifier extends Notifier { _requestNativeCancel(id); } + void dismissItem(String id) { + final item = _findItemById(id); + if (item == null) return; + + final isActive = + item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing; + + if (isActive) { + _pausePendingItemIds.remove(id); + _locallyCancelledItemIds.add(id); + _requestNativeCancel(id); + } else { + _locallyCancelledItemIds.remove(id); + } + + final items = state.items.where((entry) => entry.id != id).toList(); + final currentDownload = state.currentDownload?.id == id + ? null + : state.currentDownload; + state = state.copyWith(items: items, currentDownload: currentDownload); + _saveQueueToStorage(); + } + void clearCompleted() { final items = state.items .where( diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 126c2f5e..888cc613 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -14,6 +14,7 @@ import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; @@ -267,8 +268,8 @@ class _AlbumScreenState extends ConsumerState { if (_isLoading) const SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + padding: EdgeInsets.all(16), + child: AlbumTrackListSkeleton(itemCount: 10), ), ), if (_error != null) @@ -544,9 +545,12 @@ class _AlbumScreenState extends ConsumerState { final track = tracks[index]; return KeyedSubtree( key: ValueKey(track.id), - child: _AlbumTrackItem( - track: track, - onDownload: () => _downloadTrack(context, track), + child: StaggeredListItem( + index: index, + child: _AlbumTrackItem( + track: track, + onDownload: () => _downloadTrack(context, track), + ), ), ); }, childCount: tracks.length), @@ -587,7 +591,6 @@ class _AlbumScreenState extends ConsumerState { final tracks = _tracks; if (tracks == null || tracks.isEmpty) return; - // Skip already-downloaded tracks final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); final localLibState = diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 751bc797..ef72e14d 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/screens/home_tab.dart' show ExtensionAlbumScreen; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; class _ArtistCache { @@ -491,12 +492,7 @@ class _ArtistScreenState extends ConsumerState { hasDiscography: hasDiscography, ), if (_isLoadingDiscography) - const SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), - ), - ), + const SliverToBoxAdapter(child: ArtistScreenSkeleton()), if (_error != null) SliverToBoxAdapter( child: Padding( @@ -959,7 +955,6 @@ class _ArtistScreenState extends ConsumerState { fetchedCount++; - // Update progress dialog if (mounted) { _FetchingProgressDialog.updateProgress( context, @@ -990,7 +985,6 @@ class _ArtistScreenState extends ConsumerState { return; } - // Check which tracks are already downloaded final historyState = ref.read(downloadHistoryProvider); final tracksToQueue = []; int skippedCount = 0; @@ -1041,10 +1035,7 @@ class _ArtistScreenState extends ConsumerState { content: Text(message), action: SnackBarAction( label: context.l10n.snackbarViewQueue, - onPressed: () { - // Navigate to queue tab (index 1) - // This will be handled by the navigation system - }, + onPressed: () {}, ), ), ); @@ -1851,29 +1842,14 @@ class _ArtistScreenState extends ConsumerState { Positioned( top: 8, right: 8, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 28, - height: 28, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : colorScheme.surface.withValues(alpha: 0.9), - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 28, + unselectedColor: colorScheme.surface.withValues( + alpha: 0.9, ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 18, - ) - : null, ), ), if (showTypeBadge) @@ -2082,7 +2058,6 @@ class _FetchingProgressDialog extends StatefulWidget { required this.onCancel, }); - // Static method to update progress from outside static void updateProgress(BuildContext context, int current, int total) { final state = context .findAncestorStateOfType<_FetchingProgressDialogState>(); @@ -2155,7 +2130,6 @@ class _FetchingProgressDialogState extends State<_FetchingProgressDialog> { ), ), const SizedBox(height: 8), - // Progress bar ClipRRect( borderRadius: BorderRadius.circular(4), child: LinearProgressIndicator( diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 0d40173a..2690fb33 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class DownloadedAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -120,7 +121,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { final tracks = allItems.where((item) { - // Use albumArtist if available and not empty, otherwise artistName final itemArtist = (item.albumArtist != null && item.albumArtist!.isNotEmpty) ? item.albumArtist! @@ -129,7 +129,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { '${item.albumName.toLowerCase()}|${itemArtist.toLowerCase()}'; return itemKey == _albumLookupKey; }).toList()..sort((a, b) { - // Sort by disc number first, then by track number final aDisc = a.discNumber ?? 1; final bDisc = b.discNumber ?? 1; if (aDisc != bDisc) return aDisc.compareTo(bDisc); @@ -310,14 +309,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (!mounted) return; final result = await navigator.push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: item)), ); await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( item.filePath, @@ -693,7 +685,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { final track = tracks[index]; return KeyedSubtree( key: ValueKey(track.id), - child: _buildTrackItem(context, colorScheme, track), + child: StaggeredListItem( + index: index, + child: _buildTrackItem(context, colorScheme, track), + ), ); }, childCount: tracks.length), ); @@ -701,6 +696,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { final discNumbers = _getSortedDiscNumbers(tracks); final List children = []; + var revealIndex = 0; for (final discNumber in discNumbers) { final discTracks = discMap[discNumber]; @@ -712,7 +708,10 @@ class _DownloadedAlbumScreenState extends ConsumerState { children.add( KeyedSubtree( key: ValueKey(track.id), - child: _buildTrackItem(context, colorScheme, track), + child: StaggeredListItem( + index: revealIndex++, + child: _buildTrackItem(context, colorScheme, track), + ), ), ); } @@ -796,28 +795,11 @@ class _DownloadedAlbumScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ if (_isSelectionMode) ...[ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, + AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), const SizedBox(width: 12), ], diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 2c0521e0..504e7edc 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -28,6 +28,7 @@ import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; class HomeTab extends ConsumerStatefulWidget { @@ -1297,8 +1298,8 @@ class _HomeTabState extends ConsumerState exploreLoading) const SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + padding: EdgeInsets.all(16), + child: TrackListSkeleton(itemCount: 5), ), ), @@ -1548,7 +1549,11 @@ class _HomeTabState extends ConsumerState itemCount: section.items.length, itemBuilder: (context, index) { final item = section.items[index]; - return _buildExploreItem(item, colorScheme); + return StaggeredListItem( + index: index, + staggerDelay: const Duration(milliseconds: 50), + child: _buildExploreItem(item, colorScheme), + ); }, ), ), @@ -2270,14 +2275,7 @@ class _HomeTabState extends ConsumerState ); if (!mounted) return; final result = await navigator.push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: item)), ); await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( item.filePath, @@ -2590,6 +2588,15 @@ class _HomeTabState extends ConsumerState required bool showLocalLibraryIndicator, required Map thumbnailSizesByExtensionId, }) { + final hasActualData = + tracks.isNotEmpty || + (searchArtists != null && searchArtists.isNotEmpty) || + (searchAlbums != null && searchAlbums.isNotEmpty) || + (searchPlaylists != null && searchPlaylists.isNotEmpty); + + if (!hasActualData && isLoading) { + return [const SliverToBoxAdapter(child: HomeSearchSkeleton())]; + } if (!hasResults) { return [const SliverToBoxAdapter(child: SizedBox.shrink())]; } @@ -2601,7 +2608,6 @@ class _HomeTabState extends ConsumerState final playlistItems = buckets.playlistItems; final artistItems = buckets.artistItems; - // Apply sorting to each list. final sortedArtists = searchArtists != null && searchArtists.isNotEmpty ? _applySortToList( searchArtists, @@ -2633,7 +2639,6 @@ class _HomeTabState extends ConsumerState ) : searchPlaylists; - // For tracks we need paired sorting (track + original index). List sortedTracks; List sortedTrackIndexes; if (realTracks.isNotEmpty && @@ -2673,7 +2678,6 @@ class _HomeTabState extends ConsumerState ), ]; - // Track whether the sort button has been shown yet (show on first section). bool sortButtonShown = false; if (sortedArtists != null && sortedArtists.isNotEmpty) { @@ -2878,19 +2882,22 @@ class _HomeTabState extends ConsumerState delegate: SliverChildBuilderDelegate((context, index) { final isFirst = index == 0; final isLast = index == itemCount - 1; - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: sectionColor, - borderRadius: BorderRadius.vertical( - top: isFirst ? const Radius.circular(20) : Radius.zero, - bottom: isLast ? const Radius.circular(20) : Radius.zero, + return StaggeredListItem( + index: index, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: sectionColor, + borderRadius: BorderRadius.vertical( + top: isFirst ? const Radius.circular(20) : Radius.zero, + bottom: isLast ? const Radius.circular(20) : Radius.zero, + ), + ), + clipBehavior: Clip.antiAlias, + child: Material( + color: Colors.transparent, + child: itemBuilder(index, !isLast), ), - ), - clipBehavior: Clip.antiAlias, - child: Material( - color: Colors.transparent, - child: itemBuilder(index, !isLast), ), ); }, childCount: itemCount), @@ -3084,7 +3091,6 @@ class _HomeTabState extends ConsumerState } if (searchProvider != null && searchProvider.isNotEmpty) { - // Check built-in providers first if (searchProvider == 'tidal') { return 'Search with Tidal...'; } @@ -3178,7 +3184,6 @@ class _HomeTabState extends ConsumerState if (text.isEmpty || text.length < _minLiveSearchChars) return; if (text.startsWith('http') || text.startsWith('spotify:')) return; - // Reset last search query to force new search _lastSearchQuery = null; _performSearch(text, filterOverride: filter); } @@ -3299,7 +3304,6 @@ class _SearchProviderDropdown extends ConsumerWidget { .firstOrNull; } - // Check if current provider is a built-in provider (tidal/qobuz) const builtInProviders = {'tidal', 'qobuz'}; final isBuiltInProvider = currentProvider != null && builtInProviders.contains(currentProvider); @@ -3379,7 +3383,6 @@ class _SearchProviderDropdown extends ConsumerWidget { ], ), ), - // Built-in Tidal search option PopupMenuItem( value: 'tidal', child: Row( @@ -3407,7 +3410,6 @@ class _SearchProviderDropdown extends ConsumerWidget { ], ), ), - // Built-in Qobuz search option PopupMenuItem( value: 'qobuz', child: Row( @@ -4230,7 +4232,6 @@ class _ExtensionAlbumScreenState extends ConsumerState { .map((t) => _parseTrack(t as Map)) .toList(); - // Extract artist info from album response final artistId = (result['artist_id'] ?? result['artistId'])?.toString(); final artistName = result['artists'] as String?; @@ -4288,7 +4289,10 @@ class _ExtensionAlbumScreenState extends ConsumerState { if (_isLoading) { return Scaffold( appBar: AppBar(title: Text(widget.albumName)), - body: const Center(child: CircularProgressIndicator()), + body: const AlbumTrackListSkeleton( + itemCount: 10, + showCoverHeader: true, + ), ); } @@ -4442,7 +4446,7 @@ class _ExtensionPlaylistScreenState if (_isLoading) { return Scaffold( appBar: AppBar(title: Text(widget.playlistName)), - body: const Center(child: CircularProgressIndicator()), + body: const TrackListSkeleton(itemCount: 8, showCoverHeader: true), ); } @@ -4614,7 +4618,7 @@ class _ExtensionArtistScreenState extends ConsumerState { if (_isLoading) { return Scaffold( appBar: AppBar(title: Text(widget.artistName)), - body: const Center(child: CircularProgressIndicator()), + body: const ArtistScreenSkeleton(), ); } diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 48847f63..17444c7b 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -17,6 +17,7 @@ import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/bottom_sheet_option_tile.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class LibraryTracksFolderScreen extends ConsumerStatefulWidget { final LibraryTracksFolderMode mode; @@ -273,7 +274,6 @@ class _LibraryTracksFolderScreenState break; } - // Stale selection cleanup if (_isSelectionMode) { final validKeys = entries.map((e) => e.key).toSet(); _selectedKeys.removeWhere((key) => !validKeys.contains(key)); @@ -349,20 +349,23 @@ class _LibraryTracksFolderScreenState final isSelected = _selectedKeys.contains(entry.key); return KeyedSubtree( key: ValueKey(entry.key), - child: _CollectionTrackTile( - entry: entry, - mode: widget.mode, - playlistId: widget.playlistId, - localLibraryState: localState, - folderTracks: folderTracks, - isSelectionMode: _isSelectionMode, - isSelected: isSelected, - onTap: _isSelectionMode - ? () => _toggleSelection(entry.key) - : null, - onLongPress: _isSelectionMode - ? null - : () => _enterSelectionMode(entry.key), + child: StaggeredListItem( + index: index, + child: _CollectionTrackTile( + entry: entry, + mode: widget.mode, + playlistId: widget.playlistId, + localLibraryState: localState, + folderTracks: folderTracks, + isSelectionMode: _isSelectionMode, + isSelected: isSelected, + onTap: _isSelectionMode + ? () => _toggleSelection(entry.key) + : null, + onLongPress: _isSelectionMode + ? null + : () => _enterSelectionMode(entry.key), + ), ), ); }, childCount: entries.length), @@ -373,7 +376,6 @@ class _LibraryTracksFolderScreenState ], ), - // Selection bottom bar AnimatedPositioned( duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, @@ -1082,14 +1084,19 @@ class _CollectionTrackTile extends ConsumerWidget { final track = entry.track; final colorScheme = Theme.of(context).colorScheme; final effectiveCoverUrl = _resolveCoverUrl(track); - final isInHistory = ref.watch( + + // Fine-grained provider watches – only this tile rebuilds when its own + // history / local-library entry changes. + final historyItem = ref.watch( downloadHistoryProvider.select((state) { - if (state.isDownloaded(track.id)) return true; + final byId = state.getBySpotifyId(track.id); + if (byId != null) return byId; final isrc = track.isrc?.trim(); - if (isrc != null && isrc.isNotEmpty && state.getByIsrc(isrc) != null) { - return true; + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = state.getByIsrc(isrc); + if (byIsrc != null) return byIsrc; } - return state.findByTrackAndArtist(track.name, track.artistName) != null; + return state.findByTrackAndArtist(track.name, track.artistName); }), ); final showLocalLibraryIndicator = ref.watch( @@ -1097,17 +1104,26 @@ class _CollectionTrackTile extends ConsumerWidget { (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, ), ); - final isInLocalLibrary = showLocalLibraryIndicator + final localItem = showLocalLibraryIndicator ? ref.watch( - localLibraryProvider.select( - (state) => state.existsInLibrary( - isrc: track.isrc, - trackName: track.name, - artistName: track.artistName, - ), - ), + localLibraryProvider.select((state) { + final isrc = track.isrc?.trim(); + if (isrc != null && isrc.isNotEmpty) { + final byIsrc = state.getByIsrc(isrc); + if (byIsrc != null) return byIsrc; + } + return state.findByTrackAndArtist(track.name, track.artistName); + }), ) - : false; + : null; + + final isInHistory = historyItem != null; + final isInLocalLibrary = localItem != null; + final heroTag = historyItem != null + ? 'cover_${historyItem.id}' + : localItem != null + ? 'cover_lib_${localItem.id}' + : null; return Padding( padding: const EdgeInsets.symmetric(horizontal: 8), @@ -1125,43 +1141,51 @@ class _CollectionTrackTile extends ConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ if (isSelectionMode) ...[ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, + AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), const SizedBox(width: 12), ], - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: effectiveCoverUrl != null && effectiveCoverUrl.isNotEmpty - ? _buildTrackCover(context, effectiveCoverUrl, 52) - : Container( - width: 52, - height: 52, - color: colorScheme.surfaceContainerHighest, - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, + HeroMode( + enabled: heroTag != null, + child: heroTag != null + ? Hero( + tag: heroTag, + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + effectiveCoverUrl != null && + effectiveCoverUrl.isNotEmpty + ? _buildTrackCover(context, effectiveCoverUrl, 52) + : Container( + width: 52, + height: 52, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), ), + ) + : ClipRRect( + borderRadius: BorderRadius.circular(8), + child: + effectiveCoverUrl != null && + effectiveCoverUrl.isNotEmpty + ? _buildTrackCover(context, effectiveCoverUrl, 52) + : Container( + width: 52, + height: 52, + color: colorScheme.surfaceContainerHighest, + child: Icon( + Icons.music_note, + color: colorScheme.onSurfaceVariant, + ), + ), ), ), ], @@ -1391,7 +1415,6 @@ class _CollectionTrackTile extends ConsumerWidget { color: colorScheme.outlineVariant.withValues(alpha: 0.5), ), - // Add to playlist (hidden in wishlist unless already downloaded) if (showAddToPlaylist) BottomSheetOptionTile( icon: Icons.playlist_add, @@ -1402,7 +1425,6 @@ class _CollectionTrackTile extends ConsumerWidget { }, ), - // Remove from folder / playlist BottomSheetOptionTile( icon: Icons.remove_circle_outline, iconColor: colorScheme.error, @@ -1501,16 +1523,9 @@ class _CollectionTrackTile extends ConsumerWidget { ); if (historyItem != null) { - await Navigator.of(context).push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: historyItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), - ); + await Navigator.of( + context, + ).push(slidePageRoute(page: TrackMetadataScreen(item: historyItem))); return; } @@ -1525,16 +1540,9 @@ class _CollectionTrackTile extends ConsumerWidget { localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); if (localItem != null) { - await Navigator.of(context).push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(localItem: localItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), - ); + await Navigator.of( + context, + ).push(slidePageRoute(page: TrackMetadataScreen(localItem: localItem))); return; } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index 5c3c8ea3..d41e6c80 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/services/local_track_redownload_service.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class LocalAlbumScreen extends ConsumerStatefulWidget { final String albumName; @@ -531,7 +532,6 @@ class _LocalAlbumScreenState extends ConsumerState { if (tracks.isEmpty) return null; final first = tracks.first; - // For lossy formats, use bitrate if (first.bitrate != null && first.bitrate! > 0) { final fmt = first.format?.toUpperCase() ?? ''; final firstBitrate = first.bitrate; @@ -543,7 +543,6 @@ class _LocalAlbumScreenState extends ConsumerState { return '$fmt ${firstBitrate}kbps'.trim(); } - // For lossless formats, use bit depth / sample rate if (first.bitDepth == null || first.bitDepth == 0 || first.sampleRate == null) { @@ -630,7 +629,10 @@ class _LocalAlbumScreenState extends ConsumerState { final track = discTracks[index]; return KeyedSubtree( key: ValueKey(track.id), - child: _buildTrackItem(context, colorScheme, track), + child: StaggeredListItem( + index: index, + child: _buildTrackItem(context, colorScheme, track), + ), ); }, childCount: discTracks.length), ), @@ -669,28 +671,11 @@ class _LocalAlbumScreenState extends ConsumerState { mainAxisSize: MainAxisSize.min, children: [ if (_isSelectionMode) ...[ - Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, + AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), const SizedBox(width: 12), ], diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 61feab23..4f3fcbe1 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -20,6 +20,7 @@ import 'package:spotiflac_android/services/shell_navigation_service.dart'; import 'package:spotiflac_android/services/share_intent_service.dart'; import 'package:spotiflac_android/services/update_checker.dart'; import 'package:spotiflac_android/widgets/update_dialog.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('MainShell'); @@ -461,32 +462,44 @@ class _MainShellState extends ConsumerState label: l10n.navHome, ), NavigationDestination( - icon: Badge( - isLabelVisible: queueState > 0, - label: Text('$queueState'), - child: const Icon(Icons.library_music_outlined), - ), - selectedIcon: SlidingIcon( + icon: AnimatedBadge( + count: queueState, child: Badge( isLabelVisible: queueState > 0, label: Text('$queueState'), - child: const Icon(Icons.library_music), + child: const Icon(Icons.library_music_outlined), + ), + ), + selectedIcon: SlidingIcon( + child: AnimatedBadge( + count: queueState, + child: Badge( + isLabelVisible: queueState > 0, + label: Text('$queueState'), + child: const Icon(Icons.library_music), + ), ), ), label: l10n.navLibrary, ), if (showStore) NavigationDestination( - icon: Badge( - isLabelVisible: storeUpdatesCount > 0, - label: Text('$storeUpdatesCount'), - child: const Icon(Icons.store_outlined), - ), - selectedIcon: SwingIcon( + icon: AnimatedBadge( + count: storeUpdatesCount, child: Badge( isLabelVisible: storeUpdatesCount > 0, label: Text('$storeUpdatesCount'), - child: const Icon(Icons.store), + child: const Icon(Icons.store_outlined), + ), + ), + selectedIcon: SwingIcon( + child: AnimatedBadge( + count: storeUpdatesCount, + child: Badge( + isLabelVisible: storeUpdatesCount > 0, + label: Text('$storeUpdatesCount'), + child: const Icon(Icons.store), + ), ), ), label: l10n.navStore, diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index f3af578e..1ac74e77 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -15,6 +15,7 @@ import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class PlaylistScreen extends ConsumerStatefulWidget { final String playlistName; @@ -387,8 +388,8 @@ class _PlaylistScreenState extends ConsumerState { if (_isLoading) { return const SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.all(32), - child: Center(child: CircularProgressIndicator()), + padding: EdgeInsets.all(16), + child: TrackListSkeleton(itemCount: 8), ), ); } @@ -438,9 +439,12 @@ class _PlaylistScreenState extends ConsumerState { final track = _tracks[index]; return KeyedSubtree( key: ValueKey(track.id), - child: _PlaylistTrackItem( - track: track, - onDownload: () => _downloadTrack(context, track), + child: StaggeredListItem( + index: index, + child: _PlaylistTrackItem( + track: track, + onDownload: () => _downloadTrack(context, track), + ), ), ); }, childCount: _tracks.length), @@ -644,7 +648,6 @@ class _PlaylistScreenState extends ConsumerState { void _downloadTracks(BuildContext context, List tracks) { if (tracks.isEmpty) return; - // Skip already-downloaded tracks final historyState = ref.read(downloadHistoryProvider); final settings = ref.read(settingsProvider); final localLibState = @@ -754,7 +757,6 @@ class _PlaylistTrackItem extends ConsumerWidget { }), ); - // Check local library for duplicate detection final showLocalLibraryIndicator = ref.watch( settingsProvider.select( (s) => s.localLibraryEnabled && s.localLibraryShowDuplicates, diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 21b58379..326c78e4 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -34,6 +34,7 @@ import 'package:spotiflac_android/utils/clickable_metadata.dart'; import 'package:spotiflac_android/utils/path_match_keys.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; enum LibraryItemSource { downloaded, local } @@ -785,7 +786,6 @@ class _QueueTabState extends ConsumerState { String? _filterCacheQuality; String? _filterCacheFormat; String _filterCacheSortMode = 'latest'; - // Advanced filters String? _filterSource; // null = all, 'downloaded', 'local' String? _filterQuality; // null = all, 'hires', 'cd', 'lossy' String? _filterFormat; // null = all, 'flac', 'mp3', 'm4a', 'opus', 'ogg' @@ -1925,7 +1925,6 @@ class _QueueTabState extends ConsumerState { .toList(growable: false); } - // Apply sorting return _applySorting(filtered); } @@ -2286,14 +2285,7 @@ class _QueueTabState extends ConsumerState { final beforeModTime = await _readFileModTimeMillis(historyItem.filePath); if (!mounted) return; final result = await navigator.push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: historyItem), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: historyItem)), ); _searchFocusNode.unfocus(); if (result == true) { @@ -2319,14 +2311,7 @@ class _QueueTabState extends ConsumerState { final beforeModTime = await _readFileModTimeMillis(item.filePath); if (!mounted) return; final result = await navigator.push( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(item: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(item: item)), ); _searchFocusNode.unfocus(); if (result == true) { @@ -2347,14 +2332,7 @@ class _QueueTabState extends ConsumerState { _searchFocusNode.unfocus(); Navigator.push( context, - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - TrackMetadataScreen(localItem: item), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), - ), + slidePageRoute(page: TrackMetadataScreen(localItem: item)), ).then((_) => _searchFocusNode.unfocus()); } @@ -2401,35 +2379,25 @@ class _QueueTabState extends ConsumerState { void _navigateToDownloadedAlbum(_GroupedAlbum album) { _navigateWithUnfocus( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - DownloadedAlbumScreen( - albumName: album.albumName, - artistName: album.artistName, - coverUrl: album.coverUrl, - ), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), + slidePageRoute( + page: DownloadedAlbumScreen( + albumName: album.albumName, + artistName: album.artistName, + coverUrl: album.coverUrl, + ), ), ); } void _navigateToLocalAlbum(_GroupedLocalAlbum album) { _navigateWithUnfocus( - PageRouteBuilder( - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - pageBuilder: (context, animation, secondaryAnimation) => - LocalAlbumScreen( - albumName: album.albumName, - artistName: album.artistName, - coverPath: album.coverPath, - tracks: album.tracks, - ), - transitionsBuilder: (context, animation, secondaryAnimation, child) => - FadeTransition(opacity: animation, child: child), + slidePageRoute( + page: LocalAlbumScreen( + albumName: album.albumName, + artistName: album.artistName, + coverPath: album.coverPath, + tracks: album.tracks, + ), ), ); } @@ -2664,7 +2632,6 @@ class _QueueTabState extends ConsumerState { return; } - // Single track drop final track = item.toTrack(); final added = await notifier.addTrackToPlaylist(playlistId, track); @@ -2731,7 +2698,6 @@ class _QueueTabState extends ConsumerState { final allHistoryItems = ref.watch( downloadHistoryProvider.select((s) => s.items), ); - // Watch local library items final localLibraryEnabled = ref.watch( settingsProvider.select((s) => s.localLibraryEnabled), ); @@ -2953,7 +2919,6 @@ class _QueueTabState extends ConsumerState { padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), child: Builder( builder: (context) { - // Compute filtered counts for tab chips int filteredAllCount; int filteredAlbumCount; int filteredSingleCount; @@ -3470,26 +3435,14 @@ class _QueueTabState extends ConsumerState { top: 4, right: 4, child: IgnorePointer( - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : colorScheme.surface.withValues(alpha: 0.85), - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 20, + unselectedColor: colorScheme.surface.withValues( + alpha: 0.85, ), - child: isSelected - ? Icon( - Icons.check, - size: 16, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 16, height: 16), ), ), ), @@ -3567,26 +3520,11 @@ class _QueueTabState extends ConsumerState { behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.only(left: 8), - child: Container( - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - size: 18, - color: colorScheme.onPrimary, - ) - : const SizedBox(width: 18, height: 18), + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), ), ), @@ -3653,7 +3591,6 @@ class _QueueTabState extends ConsumerState { ), ), const Spacer(), - // Filter button with long-press to reset if (!_isSelectionMode) _buildFilterButton(context, unifiedItems), if (!_isSelectionMode && filteredUnifiedItems.isNotEmpty) @@ -3693,7 +3630,6 @@ class _QueueTabState extends ConsumerState { ), ), - // Albums empty state with filter button if (filteredGroupedAlbums.isEmpty && filteredGroupedLocalAlbums.isEmpty && filterMode == 'albums' && @@ -3749,7 +3685,6 @@ class _QueueTabState extends ConsumerState { ), ), - // Combined albums grid (downloaded + local in single grid) if (filterMode == 'albums' && (filteredGroupedAlbums.isNotEmpty || filteredGroupedLocalAlbums.isNotEmpty)) @@ -3764,7 +3699,6 @@ class _QueueTabState extends ConsumerState { ), delegate: SliverChildBuilderDelegate( (context, index) { - // First render downloaded albums, then local albums if (index < filteredGroupedAlbums.length) { final album = filteredGroupedAlbums[index]; return KeyedSubtree( @@ -3791,7 +3725,6 @@ class _QueueTabState extends ConsumerState { ), ), - // Unified list/grid for 'all' filter: collection items + tracks combined if (filterMode == 'all') ...[ if (historyViewMode == 'grid') SliverPadding( @@ -3908,7 +3841,6 @@ class _QueueTabState extends ConsumerState { ), ], - // Singles filter - show unified items (downloaded + local singles) if (filterMode == 'singles') SliverToBoxAdapter( child: Padding( @@ -4996,7 +4928,6 @@ class _QueueTabState extends ConsumerState { return; } - // Confirm final isLossless = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final confirmed = await showDialog( context: context, @@ -5437,7 +5368,6 @@ class _QueueTabState extends ConsumerState { const SizedBox(height: 12), - // Action buttons row: Share/Re-enrich, Convert, Delete Row( children: [ if (localOnlySelection && flacEligibleCount > 0) ...[ @@ -5524,101 +5454,148 @@ class _QueueTabState extends ConsumerState { ColorScheme colorScheme, ) { final isCompleted = item.status == DownloadStatus.completed; + final isActive = + item.status == DownloadStatus.queued || + item.status == DownloadStatus.downloading || + item.status == DownloadStatus.finalizing; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: InkWell( - onTap: isCompleted ? () => _navigateToMetadataScreen(item) : null, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - isCompleted - ? Hero( - tag: 'cover_${item.id}', - child: _buildCoverArt(item, colorScheme), - ) - : _buildCoverArt(item, colorScheme), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - item.track.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall?.copyWith( - fontWeight: FontWeight.w600, + return Dismissible( + key: ValueKey('dismiss_${item.id}'), + direction: DismissDirection.endToStart, + confirmDismiss: isActive + ? (_) async { + return await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Cancel download?'), + content: Text( + 'This will cancel the active download for "${item.track.name}".', ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Keep'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Cancel'), + ), + ], ), - const SizedBox(height: 2), - ClickableArtistName( - artistName: item.track.artistName, - artistId: item.track.artistId, - coverUrl: item.track.coverUrl, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - if (item.status == DownloadStatus.downloading) ...[ - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: ClipRRect( - borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: item.progress > 0 ? item.progress : null, - backgroundColor: - colorScheme.surfaceContainerHighest, - color: colorScheme.primary, - minHeight: 6, - ), - ), - ), - const SizedBox(width: 8), - Text( - // When progress is 0 (unknown size, e.g. YouTube tunnel mode), - // show bytes downloaded instead of percentage - item.progress > 0 - ? (item.speedMBps > 0 - ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : '${(item.progress * 100).toStringAsFixed(0)}%') - : (item.bytesReceived > 0 - ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : (item.speedMBps > 0 - ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : 'Starting...')), - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.bold, + ) ?? + false; + } + : null, + onDismissed: (_) { + ref.read(downloadQueueProvider.notifier).dismissItem(item.id); + }, + background: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: Icon(Icons.delete_outline, color: colorScheme.onErrorContainer), + ), + child: DownloadSuccessOverlay( + showSuccess: isCompleted, + child: Card( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: InkWell( + onTap: isCompleted ? () => _navigateToMetadataScreen(item) : null, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + isCompleted + ? Hero( + tag: 'cover_${item.id}', + child: _buildCoverArt(item, colorScheme), + ) + : _buildCoverArt(item, colorScheme), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.track.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleSmall + ?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + ClickableArtistName( + artistName: item.track.artistName, + artistId: item.track.artistId, + coverUrl: item.track.coverUrl, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + if (item.status == DownloadStatus.downloading) ...[ + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: item.progress > 0 + ? item.progress + : null, + backgroundColor: + colorScheme.surfaceContainerHighest, + color: colorScheme.primary, + minHeight: 6, + ), ), + ), + const SizedBox(width: 8), + Text( + // When progress is 0 (unknown size, e.g. YouTube tunnel mode), + // show bytes downloaded instead of percentage + item.progress > 0 + ? (item.speedMBps > 0 + ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : '${(item.progress * 100).toStringAsFixed(0)}%') + : (item.bytesReceived > 0 + ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : (item.speedMBps > 0 + ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : 'Starting...')), + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + ], ), ], - ), - ], - if (item.status == DownloadStatus.failed) ...[ - const SizedBox(height: 4), - Text( - item.errorMessage, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.labelSmall?.copyWith( - color: colorScheme.error, - ), - ), - ], - ], - ), + if (item.status == DownloadStatus.failed) ...[ + const SizedBox(height: 4), + Text( + item.errorMessage, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall + ?.copyWith(color: colorScheme.error), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + _buildActionButtons(context, item, colorScheme), + ], ), - const SizedBox(width: 8), - _buildActionButtons(context, item, colorScheme), - ], + ), ), ), ), @@ -5997,34 +5974,19 @@ class _QueueTabState extends ConsumerState { Semantics( checked: isSelected, label: isSelected ? 'Deselect track' : 'Select track', - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? colorScheme.primary - : Colors.transparent, - shape: BoxShape.circle, - border: Border.all( - color: isSelected - ? colorScheme.primary - : colorScheme.outline, - width: 2, - ), - ), - child: isSelected - ? Icon( - Icons.check, - color: colorScheme.onPrimary, - size: 16, - ) - : null, + child: AnimatedSelectionCheckbox( + visible: true, + selected: isSelected, + colorScheme: colorScheme, + size: 24, ), ), const SizedBox(width: 12), ], - // Cover image - supports network URL and local file path - _buildUnifiedCoverImage(item, colorScheme, 56), + Hero( + tag: 'cover_lib_${item.id}', + child: _buildUnifiedCoverImage(item, colorScheme, 56), + ), const SizedBox(width: 12), Expanded( @@ -6052,7 +6014,6 @@ class _QueueTabState extends ConsumerState { const SizedBox(height: 2), Row( children: [ - // Source badge Container( padding: const EdgeInsets.symmetric( horizontal: 6, @@ -6200,7 +6161,6 @@ class _QueueTabState extends ConsumerState { aspectRatio: 1, child: _buildUnifiedCoverImage(item, colorScheme), ), - // Source badge (top-right) Positioned( right: 4, top: 4, @@ -6224,7 +6184,6 @@ class _QueueTabState extends ConsumerState { ), ), ), - // Quality badge (top-left) if (item.quality != null && item.quality!.isNotEmpty) Positioned( left: 4, diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 42118769..0268e149 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -8,6 +8,7 @@ import 'package:spotiflac_android/providers/track_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/utils/clickable_metadata.dart'; class SearchScreen extends ConsumerStatefulWidget { @@ -51,9 +52,9 @@ class _SearchScreenState extends ConsumerState { ref .read(downloadQueueProvider.notifier) .addToQueue(track, settings.defaultService); - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name)))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarAddedToQueue(track.name))), + ); } @override @@ -95,13 +96,20 @@ class _SearchScreenState extends ConsumerState { child: Text(error, style: TextStyle(color: colorScheme.error)), ), Expanded( - child: tracks.isEmpty - ? _buildEmptyState(colorScheme) - : ListView.builder( - itemCount: tracks.length, - itemBuilder: (context, index) => - _buildTrackTile(tracks[index], colorScheme), - ), + child: AnimatedStateSwitcher( + child: isLoading && tracks.isEmpty + ? const TrackListSkeleton(key: ValueKey('loading')) + : tracks.isEmpty + ? _buildEmptyState(colorScheme) + : ListView.builder( + key: const ValueKey('results'), + itemCount: tracks.length, + itemBuilder: (context, index) => StaggeredListItem( + index: index, + child: _buildTrackTile(tracks[index], colorScheme), + ), + ), + ), ), ], ), @@ -127,32 +135,30 @@ class _SearchScreenState extends ConsumerState { } Widget _buildTrackTile(Track track, ColorScheme colorScheme) { - return ListTile( - leading: track.coverUrl != null - ? ClipRRect( - borderRadius: BorderRadius.circular(8), - child: CachedNetworkImage( - imageUrl: track.coverUrl!, - width: 48, - height: 48, - fit: BoxFit.cover, - memCacheWidth: 144, - memCacheHeight: 144, - cacheManager: CoverCacheManager.instance, - ), - ) - : Container( + final coverWidget = track.coverUrl != null + ? ClipRRect( + borderRadius: BorderRadius.circular(8), + child: CachedNetworkImage( + imageUrl: track.coverUrl!, width: 48, height: 48, - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(8), - ), - child: Icon( - Icons.music_note, - color: colorScheme.onSurfaceVariant, - ), + fit: BoxFit.cover, + memCacheWidth: 144, + memCacheHeight: 144, + cacheManager: CoverCacheManager.instance, ), + ) + : Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Icon(Icons.music_note, color: colorScheme.onSurfaceVariant), + ); + return ListTile( + leading: coverWidget, title: Text(track.name, maxLines: 1, overflow: TextOverflow.ellipsis), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 7e66fc62..7e4d03f2 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -13,6 +13,7 @@ import 'package:spotiflac_android/screens/settings/donate_page.dart'; import 'package:spotiflac_android/screens/settings/log_screen.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; class SettingsTab extends ConsumerWidget { const SettingsTab({super.key}); @@ -150,26 +151,6 @@ class SettingsTab extends ConsumerWidget { void _navigateTo(BuildContext context, Widget page) { FocusManager.instance.primaryFocus?.unfocus(); - - Navigator.of(context).push( - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => page, - transitionsBuilder: (context, animation, secondaryAnimation, child) { - const begin = Offset(1.0, 0.0); - const end = Offset.zero; - const curve = Curves.easeInOut; - var tween = Tween( - begin: begin, - end: end, - ).chain(CurveTween(curve: curve)); - return SlideTransition( - position: animation.drive(tween), - child: child, - ); - }, - transitionDuration: const Duration(milliseconds: 300), - reverseTransitionDuration: const Duration(milliseconds: 250), - ), - ); + Navigator.of(context).push(slidePageRoute(page: page)); } } diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 41a9143c..f11cfa1a 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -4,6 +4,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/store_provider.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/screens/store/extension_details_screen.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart'; @@ -259,8 +260,11 @@ class _StoreTabState extends ConsumerState { ), if (isLoading && extensions.isEmpty) - const SliverFillRemaining( - child: Center(child: CircularProgressIndicator()), + const SliverToBoxAdapter( + child: Padding( + padding: EdgeInsets.all(16), + child: TrackListSkeleton(itemCount: 6), + ), ) else if (error != null && extensions.isEmpty) SliverFillRemaining(child: _buildErrorState(error, colorScheme)) diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index c27f426b..d84b9fc9 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -308,12 +308,10 @@ class _TrackMetadataScreenState extends ConsumerState { storedQuality: _quality, ); - // Fill in album name from file tags if stored value is empty final needsAlbum = resolvedAlbum != null && resolvedAlbum.isNotEmpty && (albumName.isEmpty); - // Fill in duration from file if stored value is missing/zero final needsDuration = resolvedDuration != null && resolvedDuration > 0 && @@ -520,6 +518,8 @@ class _TrackMetadataScreenState extends ConsumerState { String get _filePath => _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; + String get _coverHeroTag => + _isLocalItem ? 'cover_lib_$_itemId' : 'cover_$_itemId'; String? get _coverUrl => _isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl); String? get _localCoverPath => @@ -528,7 +528,6 @@ class _TrackMetadataScreenState extends ConsumerState { String get _service => _isLocalItem ? 'local' : _downloadItem!.service; DateTime get _addedAt { if (_isLocalItem) { - // Use file modification time if available, otherwise fall back to scannedAt final modTime = _localLibraryItem!.fileModTime; if (modTime != null && modTime > 0) { return DateTime.fromMillisecondsSinceEpoch(modTime); @@ -796,38 +795,42 @@ class _TrackMetadataScreenState extends ConsumerState { double expandedHeight, bool showContent, ) { - return Stack( - fit: StackFit.expand, - children: [ - if (_hasPath(_embeddedCoverPreviewPath)) - Image.file( + final coverChild = _hasPath(_embeddedCoverPreviewPath) + ? Image.file( File(_embeddedCoverPreviewPath!), fit: BoxFit.cover, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) - else if (_coverUrl != null) - CachedNetworkImage( + : _coverUrl != null + ? CachedNetworkImage( imageUrl: _coverUrl!, fit: BoxFit.cover, cacheManager: CoverCacheManager.instance, placeholder: (_, _) => Container(color: colorScheme.surface), errorWidget: (_, _, _) => Container(color: colorScheme.surface), ) - else if (_localCoverPath != null && _localCoverPath!.isNotEmpty) - Image.file( + : _localCoverPath != null && _localCoverPath!.isNotEmpty + ? Image.file( File(_localCoverPath!), fit: BoxFit.cover, errorBuilder: (_, _, _) => Container(color: colorScheme.surface), ) - else - Container( + : Container( color: colorScheme.surfaceContainerHighest, child: Icon( Icons.music_note, size: 80, color: colorScheme.onSurfaceVariant, ), - ), + ); + + return Stack( + fit: StackFit.expand, + children: [ + Hero( + tag: _coverHeroTag, + child: Material(color: Colors.transparent, child: coverChild), + ), Positioned( left: 0, right: 0, @@ -1620,7 +1623,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ), - // Show "Embed Lyrics" button if lyrics are from online (not already embedded) if (!_lyricsEmbedded && _fileExists) ...[ const SizedBox(height: 16), Center( @@ -1668,7 +1670,6 @@ class _TrackMetadataScreenState extends ConsumerState { try { final durationMs = (duration ?? 0) * 1000; - // First, check if lyrics are embedded in the file if (_fileExists) { final embeddedResult = await PlatformBridge.getLyricsLRCWithSource( @@ -1702,7 +1703,6 @@ class _TrackMetadataScreenState extends ConsumerState { } } - // No embedded lyrics, fetch from online final result = await PlatformBridge.getLyricsLRCWithSource( _spotifyId ?? '', trackName, @@ -1992,7 +1992,6 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - // Write temp file to SAF tree final treeUri = _downloadItem?.downloadTreeUri; final relativeDir = _downloadItem?.safRelativeDir ?? ''; if (treeUri != null && treeUri.isNotEmpty) { @@ -2039,7 +2038,6 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - // Regular file path final dir = _getFileDirectory(); final outputPath = '$dir${Platform.pathSeparator}$baseName.jpg'; @@ -2132,7 +2130,6 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - // Write temp file to SAF tree final treeUri = _downloadItem?.downloadTreeUri; final relativeDir = _downloadItem?.safRelativeDir ?? ''; if (treeUri != null && treeUri.isNotEmpty) { @@ -2188,7 +2185,6 @@ class _TrackMetadataScreenState extends ConsumerState { return; } - // Regular file path final dir = _getFileDirectory(); final outputPath = '$dir${Platform.pathSeparator}$baseName.lrc'; @@ -2263,7 +2259,6 @@ class _TrackMetadataScreenState extends ConsumerState { final result = await PlatformBridge.reEnrichFile(request); final method = result['method'] as String?; - // Update local UI state with enriched metadata from online search final enriched = result['enriched_metadata'] as Map?; if (enriched != null && mounted) { setState(() { @@ -2350,7 +2345,6 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - // For SAF files, copy processed temp file back if (ffmpegResult != null && tempPath != null && safUri != null) { final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); if (!ok && mounted) { @@ -2363,7 +2357,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), ); - // Cleanup temp files if (_hasPath(downloadedCoverPath)) { try { await File(downloadedCoverPath!).delete(); @@ -2381,7 +2374,6 @@ class _TrackMetadataScreenState extends ConsumerState { } } - // Cleanup temp files if (tempPath != null && tempPath.isNotEmpty) { try { await File(tempPath).delete(); @@ -2403,7 +2395,6 @@ class _TrackMetadataScreenState extends ConsumerState { ); } - // Cleanup temp cover from Go backend if (_hasPath(downloadedCoverPath)) { try { await File(downloadedCoverPath!).delete(); @@ -2468,7 +2459,6 @@ class _TrackMetadataScreenState extends ConsumerState { for (final line in lines) { var cleaned = line.trim(); - // Skip metadata tags if (_lrcMetadataPattern.hasMatch(cleaned) && !_lrcBackgroundLinePattern.hasMatch(cleaned)) { continue; @@ -2480,7 +2470,6 @@ class _TrackMetadataScreenState extends ConsumerState { cleaned = bgMatch.group(1)?.trim() ?? ''; } - // Remove line timestamp, inline word-by-word timestamps, and speaker prefix. cleaned = cleaned.replaceAll(_lrcTimestampPattern, '').trim(); cleaned = cleaned.replaceAll(_lrcInlineTimestampPattern, ''); cleaned = cleaned.replaceFirst(_lrcSpeakerPrefixPattern, ''); @@ -2691,11 +2680,9 @@ class _TrackMetadataScreenState extends ConsumerState { /// Whether the current file is a CUE sheet (or CUE-referenced) bool get _isCueFile { - // Check if the raw path has a CUE virtual path suffix if (isCueVirtualPath(rawFilePath)) return true; final lower = cleanFilePath.toLowerCase(); if (lower.endsWith('.cue')) return true; - // Check if local library item has cue+ format if (_isLocalItem && _localLibraryItem != null) { final format = _localLibraryItem!.format ?? ''; if (format.startsWith('cue+')) return true; @@ -2821,7 +2808,6 @@ class _TrackMetadataScreenState extends ConsumerState { final currentFormat = _currentFileFormat; final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A'; - // Build available target formats based on source final formats = []; if (currentFormat == 'FLAC') { formats.addAll(['ALAC', 'MP3', 'Opus']); @@ -2912,7 +2898,6 @@ class _TrackMetadataScreenState extends ConsumerState { }).toList(), ), - // Only show bitrate for lossy targets if (!isLosslessTarget) ...[ const SizedBox(height: 16), Text( @@ -2939,7 +2924,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ], - // Show lossless indicator if (isLosslessTarget && isLosslessSource) ...[ const SizedBox(height: 16), Row( @@ -2997,14 +2981,12 @@ class _TrackMetadataScreenState extends ConsumerState { } void _showCueSplitSheet(BuildContext context) async { - // Strip the #trackNN suffix from virtual CUE paths to get the real .cue path var cuePath = cleanFilePath; final trackSuffix = RegExp(r'#track\d+$'); if (trackSuffix.hasMatch(cuePath)) { cuePath = cuePath.replaceFirst(trackSuffix, ''); } - // Show loading indicator ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(context.l10n.snackbarLoadingCueSheet)), ); @@ -3099,7 +3081,6 @@ class _TrackMetadataScreenState extends ConsumerState { ), ), const SizedBox(height: 16), - // Track list preview (scrollable, max 200px) ConstrainedBox( constraints: const BoxConstraints(maxHeight: 200), child: ListView.builder( @@ -3321,7 +3302,6 @@ class _TrackMetadataScreenState extends ConsumerState { workingAudioPath = tempPath; } - // Determine output directory final String outputDir; final treeUri = !_isLocalItem ? (_downloadItem?.downloadTreeUri ?? '') @@ -3348,7 +3328,6 @@ class _TrackMetadataScreenState extends ConsumerState { if (!mounted) return; _showLongSnackBarMessage(_l10nCueSplitSplitting(1, tracks.length)); - // Extract cover from audio file for embedding String? coverPath; try { final tempDir = await getTemporaryDirectory(); @@ -3391,11 +3370,9 @@ class _TrackMetadataScreenState extends ConsumerState { for (final path in finalOutputPaths) { if (path.toLowerCase().endsWith('.flac')) { try { - // Read existing metadata first final metadata = await PlatformBridge.readFileMetadata(path); if (metadata['error'] == null) { final fields = {'cover_path': coverPath}; - // Preserve existing fields for (final entry in metadata.entries) { if (entry.key == 'error' || entry.value == null) continue; final v = entry.value.toString().trim(); @@ -3421,7 +3398,6 @@ class _TrackMetadataScreenState extends ConsumerState { finalOutputPaths = exportedUris; } - // Cleanup cover temp if (coverPath != null) { try { await File(coverPath).delete(); @@ -3443,7 +3419,6 @@ class _TrackMetadataScreenState extends ConsumerState { _showSnackBarMessage(_l10nCueSplitFailed); } } finally { - // Cleanup SAF temp audio copy if (safTempAudioPath != null) { try { await File(safTempAudioPath).delete(); @@ -3562,7 +3537,6 @@ class _TrackMetadataScreenState extends ConsumerState { String? safTempPath; if (isSaf) { - // Copy SAF file to temp for processing safTempPath = await PlatformBridge.copyContentUriToTemp(cleanFilePath); if (safTempPath == null) { if (mounted) { @@ -3585,7 +3559,6 @@ class _TrackMetadataScreenState extends ConsumerState { deleteOriginal: !isSaf, // Don't delete temp copy for SAF, we handle it ); - // Cleanup cover temp if (coverPath != null) { try { await File(coverPath).delete(); @@ -3593,7 +3566,6 @@ class _TrackMetadataScreenState extends ConsumerState { } if (newPath == null) { - // Cleanup SAF temp if needed if (safTempPath != null) { try { await File(safTempPath).delete(); @@ -3695,7 +3667,6 @@ class _TrackMetadataScreenState extends ConsumerState { _log.w('Converted SAF file created but failed deleting original URI'); } - // Update history with new SAF info if (!_isLocalItem) { await HistoryDatabase.instance.updateFilePath( _downloadItem!.id, @@ -3707,7 +3678,6 @@ class _TrackMetadataScreenState extends ConsumerState { await ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); } - // Cleanup temp files try { await File(newPath).delete(); } catch (_) {} @@ -3717,7 +3687,6 @@ class _TrackMetadataScreenState extends ConsumerState { } catch (_) {} } } else { - // Regular file: update history with new path if (!_isLocalItem) { await HistoryDatabase.instance.updateFilePath( _downloadItem!.id, @@ -3736,7 +3705,6 @@ class _TrackMetadataScreenState extends ConsumerState { content: Text(context.l10n.trackConvertSuccess(targetFormat)), ), ); - // Pop and let the caller refresh Navigator.pop(context, true); } } catch (e) { @@ -3754,7 +3722,6 @@ class _TrackMetadataScreenState extends ConsumerState { WidgetRef ref, ColorScheme colorScheme, ) async { - // Read current metadata from file, fall back to item data on failure Map? fileMetadata; try { final result = await PlatformBridge.readFileMetadata(cleanFilePath); @@ -3765,7 +3732,6 @@ class _TrackMetadataScreenState extends ConsumerState { debugPrint('readFileMetadata failed, using item data: $e'); } - // Build initial values map — prefer file metadata, fall back to item data String val(String key, String? fallback) { final v = fileMetadata?[key]?.toString(); return (v != null && v.isNotEmpty) ? v : (fallback ?? ''); @@ -3811,7 +3777,6 @@ class _TrackMetadataScreenState extends ConsumerState { ScaffoldMessenger.of(this.context).showSnackBar( SnackBar(content: Text(this.context.l10n.snackbarMetadataSaved)), ); - // Re-read metadata from file to refresh the display try { final refreshed = await PlatformBridge.readFileMetadata(cleanFilePath); setState(() => _editedMetadata = refreshed); @@ -4056,10 +4021,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { String? _currentCoverTempDir; bool _loadingCurrentCover = false; - // Auto-fill field selection — which fields the user wants to fetch final Set _autoFillFields = {}; - // All auto-fillable fields and their mapping static const _fieldDefs = { 'title': 'title', 'artist': 'artist', @@ -4685,7 +4648,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { throw StateError('No metadata match resolved for auto-fill'); } - // Extract basic metadata from search result final enriched = { 'title': (selectedBest['name'] ?? '').toString(), 'artist': (selectedBest['artists'] ?? selectedBest['artist'] ?? '') @@ -4763,7 +4725,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (!mounted) return; - // Fetch genre/label/copyright from Deezer extended metadata if (needsExtended && deezerId != null) { try { final extended = await PlatformBridge.getDeezerExtendedMetadata( @@ -4781,10 +4742,9 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { if (!mounted) return; - // Apply selected fields to controllers var filledCount = 0; for (final key in _autoFillFields) { - if (key == 'cover') continue; // Handle cover separately below + if (key == 'cover') continue; final value = enriched[key]; if (value != null && value.isNotEmpty && @@ -4798,7 +4758,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { } } - // Handle cover art download if (_autoFillFields.contains('cover')) { final coverUrl = (selectedBest['cover_url'] ?? selectedBest['images'] ?? '') @@ -5077,7 +5036,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { return; } - // For SAF files, copy the processed temp file back if (tempPath != null && safUri != null) { final ok = await PlatformBridge.writeTempToSaf(ffmpegResult, safUri); if (!ok && mounted) { @@ -5190,7 +5148,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), _field('Genre', _genreCtrl), _field('ISRC', _isrcCtrl), - // Advanced fields toggle Padding( padding: const EdgeInsets.only(top: 8, bottom: 4), child: InkWell( @@ -5288,7 +5245,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), const SizedBox(height: 8), - // Quick select buttons Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Row( @@ -5308,7 +5264,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), const SizedBox(height: 8), - // Field chips Padding( padding: const EdgeInsets.symmetric(horizontal: 12), child: Wrap( @@ -5345,7 +5300,6 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> { ), ), const SizedBox(height: 10), - // Fetch button Padding( padding: const EdgeInsets.only(left: 12, right: 12, bottom: 12), child: SizedBox( diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index c13b54ee..309a4c9a 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -89,9 +89,7 @@ class AppTheme { static CardThemeData _cardTheme(ColorScheme scheme) => CardThemeData( elevation: 0, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), color: scheme.surfaceContainerLow, surfaceTintColor: scheme.surfaceTint, ); @@ -148,9 +146,7 @@ class AppTheme { static InputDecorationTheme _inputDecorationTheme(ColorScheme scheme) => InputDecorationTheme( filled: true, - fillColor: scheme.surfaceContainerHighest.withValues( - alpha: 0.3, - ), + fillColor: scheme.surfaceContainerHighest.withValues(alpha: 0.3), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide.none, @@ -175,9 +171,7 @@ class AppTheme { static ListTileThemeData _listTileTheme(ColorScheme scheme) => ListTileThemeData( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ); @@ -237,7 +231,7 @@ class AppTheme { ); static ChipThemeData _chipTheme(ColorScheme scheme) => ChipThemeData( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), backgroundColor: scheme.surfaceContainerLow, selectedColor: scheme.secondaryContainer, ); diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart new file mode 100644 index 00000000..d6c292d2 --- /dev/null +++ b/lib/widgets/animation_utils.dart @@ -0,0 +1,857 @@ +import 'package:flutter/material.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Staggered List Item – fade + slide-up entrance with index-based delay +// ───────────────────────────────────────────────────────────────────────────── + +/// Wraps a child in a staggered fade-in + slide-up animation. +/// +/// [index] controls the stagger delay (each item delayed by [staggerDelay]). +/// Set [animate] to false to skip the animation (e.g. when scrolling back). +class StaggeredListItem extends StatelessWidget { + static const int _defaultMaxAnimatedItems = 10; + + final int index; + final Widget child; + final Duration duration; + final Duration staggerDelay; + final bool animate; + final int maxAnimatedItems; + + const StaggeredListItem({ + super.key, + required this.index, + required this.child, + this.duration = const Duration(milliseconds: 250), + this.staggerDelay = const Duration(milliseconds: 40), + this.animate = true, + this.maxAnimatedItems = _defaultMaxAnimatedItems, + }); + + @override + Widget build(BuildContext context) { + if (!animate || index >= maxAnimatedItems) return child; + // Cap the delay so very long lists don't have absurd wait times. + final cappedIndex = index.clamp(0, maxAnimatedItems - 1); + final delay = staggerDelay * cappedIndex; + final totalDuration = duration + delay; + + return TweenAnimationBuilder( + key: ValueKey('stagger_$index'), + tween: Tween(begin: 0.0, end: 1.0), + duration: totalDuration, + curve: Curves.easeOutCubic, + builder: (context, value, child) { + // Compute the effective progress after the stagger delay. + final delayFraction = totalDuration.inMilliseconds > 0 + ? delay.inMilliseconds / totalDuration.inMilliseconds + : 0.0; + final progress = value <= delayFraction + ? 0.0 + : ((value - delayFraction) / (1.0 - delayFraction)).clamp(0.0, 1.0); + return Opacity( + opacity: progress, + child: Transform.translate( + offset: Offset(0, 12 * (1 - progress)), + child: child, + ), + ); + }, + child: child, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Animated State Switcher – crossfade between loading / content / empty / error +// ───────────────────────────────────────────────────────────────────────────── + +/// A convenience wrapper around [AnimatedSwitcher] that crossfades between +/// different widget states (loading, content, empty, error). +/// +/// Assign a unique [ValueKey] to each child so the switcher detects changes. +class AnimatedStateSwitcher extends StatelessWidget { + final Widget child; + final Duration duration; + + const AnimatedStateSwitcher({ + super.key, + required this.child, + this.duration = const Duration(milliseconds: 250), + }); + + @override + Widget build(BuildContext context) { + return AnimatedSwitcher( + duration: duration, + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) { + return FadeTransition(opacity: animation, child: child); + }, + child: child, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Shared Page Route – consistent slide-from-right transition +// ───────────────────────────────────────────────────────────────────────────── + +/// Creates a platform-aware material route. +/// +/// This intentionally defers route transitions to Flutter's material route and +/// theme so Android predictive back and platform-default animations remain +/// intact. +Route slidePageRoute({required Widget page}) { + return MaterialPageRoute(builder: (context) => page); +} + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Shimmer / Skeleton Loading Widget +// ───────────────────────────────────────────────────────────────────────────── + +/// A shimmer effect widget that can wrap skeleton placeholders. +class ShimmerLoading extends StatefulWidget { + final Widget child; + + const ShimmerLoading({super.key, required this.child}); + + @override + State createState() => _ShimmerLoadingState(); +} + +class _ShimmerLoadingState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + + final baseColor = isDark + ? colorScheme.surfaceContainerHighest + : colorScheme.surfaceContainerHigh; + final highlightColor = isDark + ? colorScheme.surfaceContainerHigh + : colorScheme.surface; + + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return ShaderMask( + shaderCallback: (bounds) { + return LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [baseColor, highlightColor, baseColor], + stops: [ + (_controller.value - 0.3).clamp(0.0, 1.0), + _controller.value, + (_controller.value + 0.3).clamp(0.0, 1.0), + ], + tileMode: TileMode.clamp, + ).createShader(bounds); + }, + blendMode: BlendMode.srcATop, + child: child, + ); + }, + child: widget.child, + ); + } +} + +/// A skeleton placeholder box used inside [ShimmerLoading]. +class SkeletonBox extends StatelessWidget { + final double width; + final double height; + final double borderRadius; + + const SkeletonBox({ + super.key, + required this.width, + required this.height, + this.borderRadius = 8, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(borderRadius), + ), + ); + } +} + +/// Track list skeleton – mimics a list of track items while loading. +class TrackListSkeleton extends StatelessWidget { + final int itemCount; + final bool showCoverHeader; + + const TrackListSkeleton({ + super.key, + this.itemCount = 8, + this.showCoverHeader = false, + }); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + return ShimmerLoading( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + children: [ + if (showCoverHeader) ...[ + SkeletonBox( + width: screenWidth, + height: screenWidth * 0.75, + borderRadius: 0, + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: SkeletonBox(width: 180, height: 20, borderRadius: 4), + ), + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 20), + child: SkeletonBox(width: 110, height: 14, borderRadius: 4), + ), + ], + ...List.generate(itemCount, (index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + const SkeletonBox(width: 48, height: 48), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: 140 + (index % 3) * 30, + height: 14, + borderRadius: 4, + ), + const SizedBox(height: 6), + SkeletonBox( + width: 90 + (index % 2) * 20, + height: 12, + borderRadius: 4, + ), + ], + ), + ), + const SkeletonBox(width: 24, height: 24, borderRadius: 12), + ], + ), + ); + }), + ], + ), + ), + ); + } +} + +/// Grid skeleton – mimics a grid of album/playlist cards while loading. + +/// Album track list skeleton – mimics the album screen track list layout +/// (track number + title + artist + trailing icon, no cover art thumbnail). +class AlbumTrackListSkeleton extends StatelessWidget { + final int itemCount; + final bool showCoverHeader; + + const AlbumTrackListSkeleton({ + super.key, + this.itemCount = 10, + this.showCoverHeader = false, + }); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + return ShimmerLoading( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + children: [ + if (showCoverHeader) ...[ + SkeletonBox( + width: screenWidth, + height: screenWidth * 0.75, + borderRadius: 0, + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: SkeletonBox(width: 180, height: 20, borderRadius: 4), + ), + Padding( + padding: const EdgeInsets.only(top: 8, bottom: 20), + child: SkeletonBox(width: 110, height: 14, borderRadius: 4), + ), + ], + ...List.generate(itemCount, (index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 6, + ), + child: Row( + children: [ + SizedBox( + width: 32, + child: Center( + child: SkeletonBox( + width: 14, + height: 14, + borderRadius: 4, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: 120 + (index % 4) * 35, + height: 14, + borderRadius: 4, + ), + const SizedBox(height: 6), + SkeletonBox( + width: 70 + (index % 3) * 20, + height: 12, + borderRadius: 4, + ), + ], + ), + ), + const SkeletonBox(width: 20, height: 20, borderRadius: 10), + ], + ), + ); + }), + ], + ), + ), + ); + } +} + +class GridSkeleton extends StatelessWidget { + final int itemCount; + final int crossAxisCount; + + const GridSkeleton({super.key, this.itemCount = 6, this.crossAxisCount = 2}); + + @override + Widget build(BuildContext context) { + return ShimmerLoading( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.78, + ), + itemCount: itemCount, + itemBuilder: (context, index) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const AspectRatio( + aspectRatio: 1, + child: SkeletonBox(width: double.infinity, height: 0), + ), + const SizedBox(height: 8), + SkeletonBox( + width: 80 + (index % 3) * 20, + height: 12, + borderRadius: 4, + ), + const SizedBox(height: 4), + SkeletonBox( + width: 50 + (index % 2) * 15, + height: 10, + borderRadius: 4, + ), + ], + ); + }, + ), + ), + ); + } +} + +/// Artist screen skeleton – mimics the artist page content below the header: +/// an optional "Popular" section (rank + cover 48x48 + title + trailing) then +/// a horizontal-scroll album section. +class ArtistScreenSkeleton extends StatelessWidget { + final int popularCount; + final int albumCount; + + const ArtistScreenSkeleton({ + super.key, + this.popularCount = 5, + this.albumCount = 5, + }); + + @override + Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + return ShimmerLoading( + child: SingleChildScrollView( + physics: const NeverScrollableScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: screenWidth, + height: screenWidth * 0.75, + borderRadius: 0, + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), + child: SkeletonBox(width: 180, height: 24, borderRadius: 4), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), + child: SkeletonBox(width: 120, height: 14, borderRadius: 4), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: SkeletonBox(width: 90, height: 20, borderRadius: 4), + ), + ...List.generate(popularCount, (index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + SizedBox( + width: 24, + child: Center( + child: SkeletonBox( + width: 12, + height: 14, + borderRadius: 4, + ), + ), + ), + const SizedBox(width: 12), + const SkeletonBox(width: 48, height: 48, borderRadius: 4), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: 110 + (index % 4) * 30, + height: 14, + borderRadius: 4, + ), + const SizedBox(height: 6), + SkeletonBox( + width: 70 + (index % 3) * 15, + height: 11, + borderRadius: 4, + ), + ], + ), + ), + const SkeletonBox(width: 20, height: 20, borderRadius: 10), + ], + ), + ); + }), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: SkeletonBox(width: 80, height: 20, borderRadius: 4), + ), + SizedBox( + height: 190, + child: ListView.builder( + scrollDirection: Axis.horizontal, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.symmetric(horizontal: 12), + itemCount: albumCount, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SkeletonBox(width: 140, height: 140), + const SizedBox(height: 8), + SkeletonBox( + width: 80 + (index % 3) * 20, + height: 12, + borderRadius: 4, + ), + const SizedBox(height: 4), + SkeletonBox( + width: 50 + (index % 2) * 15, + height: 10, + borderRadius: 4, + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +/// Home search skeleton – mimics filter chips + sectioned results +/// (Artists section with rounded card items, Albums section, etc.) +class HomeSearchSkeleton extends StatelessWidget { + const HomeSearchSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return ShimmerLoading( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + SkeletonBox(width: 48, height: 32, borderRadius: 16), + const SizedBox(width: 8), + SkeletonBox(width: 64, height: 32, borderRadius: 16), + const SizedBox(width: 8), + SkeletonBox(width: 72, height: 32, borderRadius: 16), + const SizedBox(width: 8), + SkeletonBox(width: 60, height: 32, borderRadius: 16), + const SizedBox(width: 8), + SkeletonBox(width: 70, height: 32, borderRadius: 16), + ], + ), + ), + const SizedBox(height: 8), + _sectionSkeleton(context, 70, 2), + const SizedBox(height: 16), + _sectionSkeleton(context, 65, 4), + ], + ), + ); + } + + static Widget _sectionSkeleton( + BuildContext context, + double headerWidth, + int itemCount, + ) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + SkeletonBox(width: headerWidth, height: 18, borderRadius: 4), + const Spacer(), + const SkeletonBox(width: 50, height: 16, borderRadius: 4), + ], + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: Theme.of( + context, + ).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(20), + ), + child: Column( + children: List.generate(itemCount, (index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + child: Row( + children: [ + const SkeletonBox(width: 48, height: 48, borderRadius: 24), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: 100 + (index % 3) * 40, + height: 14, + borderRadius: 4, + ), + const SizedBox(height: 6), + SkeletonBox( + width: 60 + (index % 2) * 25, + height: 12, + borderRadius: 4, + ), + ], + ), + ), + const SkeletonBox(width: 20, height: 20, borderRadius: 10), + ], + ), + ); + }), + ), + ), + ], + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Animated Selection Checkbox – scales in when entering selection mode +// ───────────────────────────────────────────────────────────────────────────── + +/// An animated selection indicator that scales in/out and crossfades the +/// checked/unchecked state. +class AnimatedSelectionCheckbox extends StatelessWidget { + final bool visible; + final bool selected; + final ColorScheme colorScheme; + final double size; + + /// Background color when not selected. Defaults to `Colors.transparent`. + final Color? unselectedColor; + + const AnimatedSelectionCheckbox({ + super.key, + required this.visible, + required this.selected, + required this.colorScheme, + this.size = 20, + this.unselectedColor, + }); + + @override + Widget build(BuildContext context) { + return AnimatedScale( + scale: visible ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutBack, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: size, + height: size, + decoration: BoxDecoration( + color: selected + ? colorScheme.primary + : unselectedColor ?? Colors.transparent, + shape: BoxShape.circle, + border: Border.all( + color: selected ? colorScheme.primary : colorScheme.outline, + width: 2, + ), + ), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + child: selected + ? Icon( + Icons.check, + key: const ValueKey('checked'), + size: size - 6, + color: colorScheme.onPrimary, + ) + : SizedBox( + key: const ValueKey('unchecked'), + width: size - 6, + height: size - 6, + ), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Download Success Animation – green flash + checkmark +// ───────────────────────────────────────────────────────────────────────────── + +/// A widget that briefly flashes a success color behind its child and shows +/// an animated checkmark when [showSuccess] transitions to true. +class DownloadSuccessOverlay extends StatefulWidget { + final bool showSuccess; + final Widget child; + + const DownloadSuccessOverlay({ + super.key, + required this.showSuccess, + required this.child, + }); + + @override + State createState() => _DownloadSuccessOverlayState(); +} + +class _DownloadSuccessOverlayState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _flashAnimation; + late bool _wasSuccess; + + @override + void initState() { + super.initState(); + // Initialise from the current widget value so items that are already + // completed when first built do not play the flash animation. + _wasSuccess = widget.showSuccess; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _flashAnimation = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 0.0, end: 0.15), weight: 30), + TweenSequenceItem(tween: Tween(begin: 0.15, end: 0.0), weight: 70), + ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + } + + @override + void didUpdateWidget(DownloadSuccessOverlay oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.showSuccess && !_wasSuccess) { + _controller.forward(from: 0); + } + _wasSuccess = widget.showSuccess; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Container( + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: _flashAnimation.value), + borderRadius: BorderRadius.circular(12), + ), + child: child, + ); + }, + child: widget.child, + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 7. Badge Bump Animation – scales the badge when count changes +// ───────────────────────────────────────────────────────────────────────────── + +/// Wraps a [Badge] child and plays a brief scale-bump whenever [count] changes. +class AnimatedBadge extends StatefulWidget { + final int count; + final Widget child; + + const AnimatedBadge({super.key, required this.count, required this.child}); + + @override + State createState() => _AnimatedBadgeState(); +} + +class _AnimatedBadgeState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + int _previousCount = 0; + + @override + void initState() { + super.initState(); + _previousCount = widget.count; + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 300), + ); + _scaleAnimation = TweenSequence([ + TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.3), weight: 40), + TweenSequenceItem(tween: Tween(begin: 1.3, end: 1.0), weight: 60), + ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); + } + + @override + void didUpdateWidget(AnimatedBadge oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.count != _previousCount && widget.count > _previousCount) { + _controller.forward(from: 0); + } + _previousCount = widget.count; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScaleTransition(scale: _scaleAnimation, child: widget.child); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// 8. Animated Removal Item – fade + slide out when removed from a list +// ───────────────────────────────────────────────────────────────────────────── + +/// Build a removal animation for [AnimatedList] items. +/// Use as the `builder` callback in [AnimatedListState.removeItem]. +Widget buildRemovalAnimation(Widget child, Animation animation) { + return SizeTransition( + sizeFactor: CurvedAnimation(parent: animation, curve: Curves.easeInOut), + child: FadeTransition( + opacity: CurvedAnimation(parent: animation, curve: Curves.easeIn), + child: child, + ), + ); +} From 5e1cc3ecb5963a5d09da71a8423a78cf8263afa6 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 26 Mar 2026 16:17:57 +0700 Subject: [PATCH 16/33] refactor: extract YouTube download to ytmusic extension and fix UI issues Remove built-in YouTube/Cobalt download pipeline from Go backend and Dart frontend. YouTube downloading now requires the ytmusic-spotiflac extension (with download_provider capability). Go backend: - Delete youtube.go (745 lines) and youtube_quality_test.go - Remove DownloadFromYouTube, IsYouTubeURLExport, ExtractYouTubeVideoIDExport from exports.go - Remove YouTube routing from DownloadTrack and DownloadByStrategy Dart frontend: - Remove YouTube from built-in services, bitrate settings, quality UI - Remove youtubeOpusBitrate/youtubeMp3Bitrate from settings model - Add migration 7: default service youtube -> tidal - Remove YouTube l10n keys from all 14 arb files and regenerate - Update _determineOutputExt to handle opus_/mp3_ quality strings - Add SAF opus/mp3 metadata embedding in unified branch - Fix TweenSequence assertion crash (t outside 0.0-1.0) - Fix store URL TextField styling consistency Extension changes (gitignored, in extension/YT-Music-SpotiFLAC/): - Add download_provider type, qualityOptions, network permissions - Implement checkAvailability and download via SpotubeDL/Cobalt --- go_backend/exports.go | 80 +- go_backend/youtube.go | 745 ------------------ go_backend/youtube_quality_test.go | 54 -- lib/l10n/app_localizations.dart | 18 - lib/l10n/app_localizations_de.dart | 10 - lib/l10n/app_localizations_en.dart | 10 - lib/l10n/app_localizations_es.dart | 20 - lib/l10n/app_localizations_fr.dart | 10 - lib/l10n/app_localizations_hi.dart | 10 - lib/l10n/app_localizations_id.dart | 10 - lib/l10n/app_localizations_ja.dart | 10 - lib/l10n/app_localizations_ko.dart | 10 - lib/l10n/app_localizations_nl.dart | 10 - lib/l10n/app_localizations_pt.dart | 20 - lib/l10n/app_localizations_ru.dart | 10 - lib/l10n/app_localizations_tr.dart | 10 - lib/l10n/app_localizations_zh.dart | 30 - lib/l10n/arb/app_de.arb | 12 - lib/l10n/arb/app_en.arb | 12 - lib/l10n/arb/app_es_ES.arb | 12 - lib/l10n/arb/app_fr.arb | 12 - lib/l10n/arb/app_hi.arb | 12 - lib/l10n/arb/app_id.arb | 12 - lib/l10n/arb/app_ja.arb | 12 - lib/l10n/arb/app_ko.arb | 12 - lib/l10n/arb/app_nl.arb | 12 - lib/l10n/arb/app_pt_PT.arb | 12 - lib/l10n/arb/app_ru.arb | 12 - lib/l10n/arb/app_tr.arb | 12 - lib/l10n/arb/app_zh_CN.arb | 12 - lib/l10n/arb/app_zh_TW.arb | 12 - lib/models/settings.dart | 10 - lib/models/settings.g.dart | 4 - lib/providers/download_queue_provider.dart | 206 ++--- lib/providers/settings_provider.dart | 64 +- lib/screens/main_shell.dart | 2 +- .../settings/download_settings_page.dart | 101 +-- .../settings/provider_priority_page.dart | 6 - lib/screens/store_tab.dart | 41 +- lib/widgets/animation_utils.dart | 4 +- lib/widgets/download_service_picker.dart | 77 +- 41 files changed, 100 insertions(+), 1650 deletions(-) delete mode 100644 go_backend/youtube.go delete mode 100644 go_backend/youtube_quality_test.go diff --git a/go_backend/exports.go b/go_backend/exports.go index 3c9ec59d..23f80ad6 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -406,24 +406,6 @@ func DownloadTrack(requestJSON string) (string, error) { } } err = deezerErr - case "youtube": - youtubeResult, youtubeErr := downloadFromYouTube(req) - if youtubeErr == nil { - result = DownloadResult{ - FilePath: youtubeResult.FilePath, - BitDepth: 0, - SampleRate: 0, - Title: youtubeResult.Title, - Artist: youtubeResult.Artist, - Album: youtubeResult.Album, - ReleaseDate: youtubeResult.ReleaseDate, - TrackNumber: youtubeResult.TrackNumber, - DiscNumber: youtubeResult.DiscNumber, - ISRC: youtubeResult.ISRC, - LyricsLRC: youtubeResult.LyricsLRC, - } - } - err = youtubeErr default: return errorResponse("Unknown service: " + req.Service) } @@ -475,7 +457,7 @@ func DownloadByStrategy(requestJSON string) (string, error) { serviceNormalized := strings.ToLower(serviceRaw) normalizedReq := req - if serviceNormalized == "youtube" || isBuiltInProvider(serviceNormalized) { + if isBuiltInProvider(serviceNormalized) { normalizedReq.Service = serviceNormalized } @@ -485,10 +467,6 @@ func DownloadByStrategy(requestJSON string) (string, error) { } normalizedJSON := string(normalizedBytes) - if serviceNormalized == "youtube" { - return DownloadFromYouTube(normalizedJSON) - } - if req.UseExtensions { // Respect strict mode when auto fallback is disabled: // for built-in providers, route directly to selected service only. @@ -1668,62 +1646,6 @@ func errorResponse(msg string) (string, error) { return string(jsonBytes), nil } -func DownloadFromYouTube(requestJSON string) (string, error) { - var req DownloadRequest - if err := json.Unmarshal([]byte(requestJSON), &req); err != nil { - return errorResponse("Invalid request: " + err.Error()) - } - applySongLinkRegionFromRequest(&req) - defer closeOwnedOutputFD(req.OutputFD) - - req.TrackName = strings.TrimSpace(req.TrackName) - req.ArtistName = strings.TrimSpace(req.ArtistName) - req.AlbumName = strings.TrimSpace(req.AlbumName) - req.AlbumArtist = strings.TrimSpace(req.AlbumArtist) - req.OutputDir = strings.TrimSpace(req.OutputDir) - req.OutputPath = strings.TrimSpace(req.OutputPath) - req.OutputExt = strings.TrimSpace(req.OutputExt) - - if req.OutputPath == "" && req.OutputFD <= 0 && req.OutputDir != "" { - AddAllowedDownloadDir(req.OutputDir) - } - - youtubeResult, err := downloadFromYouTube(req) - if err != nil { - return errorResponse(err.Error()) - } - - resp := DownloadResponse{ - Success: true, - Message: "Downloaded from YouTube", - FilePath: youtubeResult.FilePath, - Service: "youtube", - Title: youtubeResult.Title, - Artist: youtubeResult.Artist, - Album: youtubeResult.Album, - ReleaseDate: youtubeResult.ReleaseDate, - TrackNumber: youtubeResult.TrackNumber, - DiscNumber: youtubeResult.DiscNumber, - ISRC: youtubeResult.ISRC, - LyricsLRC: youtubeResult.LyricsLRC, - CoverURL: req.CoverURL, - Genre: req.Genre, - Label: req.Label, - Copyright: req.Copyright, - } - - jsonBytes, _ := json.Marshal(resp) - return string(jsonBytes), nil -} - -func IsYouTubeURLExport(urlStr string) bool { - return IsYouTubeURL(urlStr) -} - -func ExtractYouTubeVideoIDExport(urlStr string) (string, error) { - return ExtractYouTubeVideoID(urlStr) -} - func DownloadCoverToFile(coverURL string, outputPath string, maxQuality bool) error { if coverURL == "" { return fmt.Errorf("no cover URL provided") diff --git a/go_backend/youtube.go b/go_backend/youtube.go deleted file mode 100644 index abffd2ff..00000000 --- a/go_backend/youtube.go +++ /dev/null @@ -1,745 +0,0 @@ -package gobackend - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "sync" -) - -type YouTubeDownloader struct { - client *http.Client - apiURL string - mu sync.Mutex -} - -const spotubeBaseURL = "https://spotubedl.com" - -var ( - globalYouTubeDownloader *YouTubeDownloader - youtubeDownloaderOnce sync.Once -) - -type YouTubeQuality string - -const ( - YouTubeQualityOpus320 YouTubeQuality = "opus_320" - YouTubeQualityOpus256 YouTubeQuality = "opus_256" - YouTubeQualityOpus128 YouTubeQuality = "opus_128" - YouTubeQualityMP3128 YouTubeQuality = "mp3_128" - YouTubeQualityMP3256 YouTubeQuality = "mp3_256" - YouTubeQualityMP3320 YouTubeQuality = "mp3_320" -) - -var ( - youtubeOpusSupportedBitrates = []int{128, 256, 320} - youtubeMp3SupportedBitrates = []int{128, 256, 320} -) - -type CobaltRequest struct { - URL string `json:"url"` - AudioBitrate string `json:"audioBitrate,omitempty"` - AudioFormat string `json:"audioFormat,omitempty"` - DownloadMode string `json:"downloadMode,omitempty"` - FilenameStyle string `json:"filenameStyle,omitempty"` - DisableMetadata bool `json:"disableMetadata,omitempty"` -} - -type CobaltResponse struct { - Status string `json:"status"` - URL string `json:"url,omitempty"` - Filename string `json:"filename,omitempty"` - Error *struct { - Code string `json:"code"` - Context *struct { - Service string `json:"service,omitempty"` - Limit int `json:"limit,omitempty"` - } `json:"context,omitempty"` - } `json:"error,omitempty"` -} - -type YouTubeDownloadResult struct { - FilePath string - Title string - Artist string - Album string - ReleaseDate string - TrackNumber int - DiscNumber int - ISRC string - Format string // "opus" or "mp3" - Bitrate int - LyricsLRC string - CoverData []byte -} - -func NewYouTubeDownloader() *YouTubeDownloader { - youtubeDownloaderOnce.Do(func() { - globalYouTubeDownloader = &YouTubeDownloader{ - client: NewHTTPClientWithTimeout(DownloadTimeout), - apiURL: "https://api.qwkuns.me", - } - }) - return globalYouTubeDownloader -} - -func extractBitrateFromQuality(raw string, defaultBitrate int) int { - parts := strings.FieldsFunc(raw, func(r rune) bool { - return (r < '0' || r > '9') - }) - for i := len(parts) - 1; i >= 0; i-- { - part := parts[i] - if part == "" { - continue - } - if parsed, err := strconv.Atoi(part); err == nil { - return parsed - } - } - return defaultBitrate -} - -func nearestSupportedBitrate(value int, supported []int) int { - nearest := supported[0] - nearestDistance := absInt(value - nearest) - - for _, option := range supported[1:] { - distance := absInt(value - option) - // On tie prefer higher quality. - if distance < nearestDistance || (distance == nearestDistance && option > nearest) { - nearest = option - nearestDistance = distance - } - } - - return nearest -} - -func absInt(value int) int { - if value < 0 { - return -value - } - return value -} - -func parseYouTubeQualityInput(raw string) (format string, bitrate int, normalized YouTubeQuality) { - normalizedRaw := strings.ToLower(strings.TrimSpace(raw)) - - if strings.HasPrefix(normalizedRaw, "opus") { - parsed := extractBitrateFromQuality(normalizedRaw, 256) - finalBitrate := nearestSupportedBitrate(parsed, youtubeOpusSupportedBitrates) - return "opus", finalBitrate, YouTubeQuality(fmt.Sprintf("opus_%d", finalBitrate)) - } - - if strings.HasPrefix(normalizedRaw, "mp3") { - parsed := extractBitrateFromQuality(normalizedRaw, 320) - finalBitrate := nearestSupportedBitrate(parsed, youtubeMp3SupportedBitrates) - return "mp3", finalBitrate, YouTubeQuality(fmt.Sprintf("mp3_%d", finalBitrate)) - } - - // Backward compatibility for legacy symbolic values. - switch normalizedRaw { - case "opus_256", "opus256", "opus": - return "opus", 256, YouTubeQualityOpus256 - case "opus_320", "opus320": - return "opus", 320, YouTubeQualityOpus320 - case "opus_128", "opus128": - return "opus", 128, YouTubeQualityOpus128 - case "mp3_320", "mp3320", "mp3", "": - return "mp3", 320, YouTubeQualityMP3320 - case "mp3_256", "mp3256": - return "mp3", 256, YouTubeQualityMP3256 - case "mp3_128", "mp3128": - return "mp3", 128, YouTubeQualityMP3128 - default: - return "mp3", 320, YouTubeQualityMP3320 - } -} - -func (y *YouTubeDownloader) SearchYouTube(trackName, artistName string) (string, error) { - query := fmt.Sprintf("%s %s", artistName, trackName) - searchQuery := url.QueryEscape(query) - - GoLog("[YouTube] Search query: %s\n", query) - - youtubeMusicURL := fmt.Sprintf("https://music.youtube.com/search?q=%s", searchQuery) - - return youtubeMusicURL, nil -} - -func (y *YouTubeDownloader) GetDownloadURL(youtubeURL string, quality YouTubeQuality) (*CobaltResponse, error) { - y.mu.Lock() - defer y.mu.Unlock() - - audioFormat, bitrate, _ := parseYouTubeQualityInput(string(quality)) - audioBitrate := strconv.Itoa(bitrate) - - // Try SpotubeDL first (primary) - var spotubeErr error - videoID, extractErr := ExtractYouTubeVideoID(youtubeURL) - if extractErr == nil { - GoLog("[YouTube] Requesting from SpotubeDL: videoID=%s (format: %s, bitrate: %s)\n", - videoID, audioFormat, audioBitrate) - - resp, err := y.requestSpotubeDL(videoID, audioFormat, audioBitrate) - if err == nil { - return resp, nil - } - spotubeErr = err - GoLog("[YouTube] SpotubeDL failed: %v, trying Cobalt fallback...\n", err) - } else { - GoLog("[YouTube] Could not extract video ID: %v, skipping SpotubeDL\n", extractErr) - } - - // Fallback: direct Cobalt API (api.qwkuns.me) - cobaltURL := toYouTubeMusicURL(youtubeURL) - GoLog("[YouTube] Requesting from Cobalt API: %s (format: %s, bitrate: %s)\n", - cobaltURL, audioFormat, audioBitrate) - - resp, err := y.requestCobaltDirect(cobaltURL, audioFormat, audioBitrate) - if err != nil { - if spotubeErr != nil { - return nil, fmt.Errorf("all download methods failed: spotubedl: %v, cobalt: %v", spotubeErr, err) - } - return nil, fmt.Errorf("all download methods failed: spotubedl: extractErr=%v, cobalt: %v", extractErr, err) - } - - return resp, nil -} - -func (y *YouTubeDownloader) requestCobaltDirect(videoURL, audioFormat, audioBitrate string) (*CobaltResponse, error) { - reqBody := CobaltRequest{ - URL: videoURL, - AudioFormat: audioFormat, - AudioBitrate: audioBitrate, - DownloadMode: "audio", - FilenameStyle: "basic", - DisableMetadata: true, - } - - jsonData, err := json.Marshal(reqBody) - if err != nil { - return nil, fmt.Errorf("failed to marshal request: %w", err) - } - - req, err := http.NewRequest("POST", y.apiURL, strings.NewReader(string(jsonData))) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json") - - resp, err := DoRequestWithUserAgent(y.client, req) - if err != nil { - return nil, fmt.Errorf("cobalt API request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - GoLog("[YouTube] Cobalt API response status: %d\n", resp.StatusCode) - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("cobalt API returned status %d: %s", resp.StatusCode, string(body)) - } - - var cobaltResp CobaltResponse - if err := json.Unmarshal(body, &cobaltResp); err != nil { - return nil, fmt.Errorf("failed to parse response: %w", err) - } - - if cobaltResp.Status == "error" && cobaltResp.Error != nil { - return nil, fmt.Errorf("cobalt error: %s", cobaltResp.Error.Code) - } - - if cobaltResp.Status != "tunnel" && cobaltResp.Status != "redirect" { - return nil, fmt.Errorf("unexpected cobalt status: %s", cobaltResp.Status) - } - - if cobaltResp.URL == "" { - return nil, fmt.Errorf("no download URL in response") - } - - GoLog("[YouTube] Got download URL from Cobalt (status: %s)\n", cobaltResp.Status) - return &cobaltResp, nil -} - -// requestSpotubeDL uses SpotubeDL as a Cobalt proxy (they handle auth to yt-dl.click instances). -// Engines v3/v2 are MP3-oriented outputs, so we only use them for MP3 requests. -func (y *YouTubeDownloader) requestSpotubeDL(videoID, audioFormat, audioBitrate string) (*CobaltResponse, error) { - engines := []string{"v1"} - if strings.EqualFold(audioFormat, "mp3") { - engines = append(engines, "v3", "v2") - } - var lastErr error - - for _, engine := range engines { - resp, err := y.requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine) - if err == nil { - return resp, nil - } - lastErr = err - GoLog("[YouTube] SpotubeDL (%s) failed: %v\n", engine, err) - } - - if lastErr == nil { - lastErr = fmt.Errorf("no SpotubeDL engine available") - } - return nil, lastErr -} - -func (y *YouTubeDownloader) requestSpotubeDLEngine(videoID, audioFormat, audioBitrate, engine string) (*CobaltResponse, error) { - apiURL := fmt.Sprintf("%s/api/download/%s?engine=%s&format=%s&quality=%s", - spotubeBaseURL, videoID, url.QueryEscape(engine), url.QueryEscape(audioFormat), url.QueryEscape(audioBitrate)) - - GoLog("[YouTube] Requesting from SpotubeDL (%s): %s\n", engine, apiURL) - - req, err := http.NewRequest("GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) - } - - req.Header.Set("Accept", "application/json") - - resp, err := DoRequestWithUserAgent(y.client, req) - if err != nil { - return nil, fmt.Errorf("spotubedl request failed: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) - } - - GoLog("[YouTube] SpotubeDL (%s) response status: %d\n", engine, resp.StatusCode) - - if resp.StatusCode != 200 { - return nil, fmt.Errorf("spotubedl(%s) returned status %d: %s", engine, resp.StatusCode, string(body)) - } - - var result struct { - URL string `json:"url"` - Status string `json:"status"` - Error string `json:"error"` - Message string `json:"message"` - Filename string `json:"filename"` - } - if err := json.Unmarshal(body, &result); err != nil { - return nil, fmt.Errorf("failed to parse spotubedl response: %w", err) - } - - downloadURL := strings.TrimSpace(result.URL) - if downloadURL == "" { - if result.Error != "" { - return nil, fmt.Errorf("spotubedl(%s) error: %s", engine, result.Error) - } - if result.Message != "" { - return nil, fmt.Errorf("spotubedl(%s) message: %s", engine, result.Message) - } - return nil, fmt.Errorf("no download URL from spotubedl(%s)", engine) - } - - if strings.HasPrefix(downloadURL, "/") { - downloadURL = spotubeBaseURL + downloadURL - } - - if !strings.HasPrefix(downloadURL, "http://") && !strings.HasPrefix(downloadURL, "https://") { - return nil, fmt.Errorf("invalid download URL from spotubedl(%s): %s", engine, downloadURL) - } - - filename := strings.TrimSpace(result.Filename) - if filename == "" { - if parsedURL, parseErr := url.Parse(downloadURL); parseErr == nil { - if queryFilename := strings.TrimSpace(parsedURL.Query().Get("filename")); queryFilename != "" { - if decodedFilename, decodeErr := url.QueryUnescape(queryFilename); decodeErr == nil { - filename = decodedFilename - } else { - filename = queryFilename - } - } - } - } - - GoLog("[YouTube] Got download URL from SpotubeDL (%s)\n", engine) - return &CobaltResponse{ - Status: "tunnel", - URL: downloadURL, - Filename: filename, - }, nil -} - -func (y *YouTubeDownloader) DownloadFile(downloadURL, outputPath string, outputFD int, itemID string) error { - ctx := context.Background() - - if itemID != "" { - StartItemProgress(itemID) - defer CompleteItemProgress(itemID) - ctx = initDownloadCancel(itemID) - defer clearDownloadCancel(itemID) - } - - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - - req, err := http.NewRequestWithContext(ctx, "GET", downloadURL, nil) - if err != nil { - return fmt.Errorf("failed to create request: %w", err) - } - - resp, err := DoRequestWithUserAgent(y.client, req) - if err != nil { - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return fmt.Errorf("download request failed: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - return fmt.Errorf("download failed: HTTP %d", resp.StatusCode) - } - - expectedSize := resp.ContentLength - if expectedSize > 0 && itemID != "" { - SetItemBytesTotal(itemID, expectedSize) - } - - out, err := openOutputForWrite(outputPath, outputFD) - if err != nil { - return fmt.Errorf("failed to create output file: %w", err) - } - - bufWriter := bufio.NewWriterSize(out, 256*1024) - - var written int64 - if itemID != "" { - progressWriter := NewItemProgressWriter(bufWriter, itemID) - written, err = io.Copy(progressWriter, resp.Body) - } else { - written, err = io.Copy(bufWriter, resp.Body) - } - - flushErr := bufWriter.Flush() - closeErr := out.Close() - - if err != nil { - cleanupOutputOnError(outputPath, outputFD) - if isDownloadCancelled(itemID) { - return ErrDownloadCancelled - } - return fmt.Errorf("download interrupted: %w", err) - } - if flushErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to flush buffer: %w", flushErr) - } - if closeErr != nil { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("failed to close file: %w", closeErr) - } - - if expectedSize > 0 && written != expectedSize { - cleanupOutputOnError(outputPath, outputFD) - return fmt.Errorf("incomplete download: expected %d bytes, got %d bytes", expectedSize, written) - } - - GoLog("[YouTube] Download completed: %d bytes written\n", written) - - return nil -} - -func BuildYouTubeSearchURL(trackName, artistName string) string { - query := fmt.Sprintf("%s %s official audio", artistName, trackName) - return fmt.Sprintf("https://music.youtube.com/search?q=%s", url.QueryEscape(query)) -} - -func BuildYouTubeWatchURL(videoID string) string { - return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID) -} - -func isYouTubeVideoID(s string) bool { - if len(s) != 11 { - return false - } - for _, c := range s { - if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_') { - return false - } - } - return true -} - -func IsYouTubeURL(urlStr string) bool { - lower := strings.ToLower(urlStr) - return strings.Contains(lower, "youtube.com") || - strings.Contains(lower, "youtu.be") || - strings.Contains(lower, "music.youtube.com") -} - -// toYouTubeMusicURL converts any YouTube URL to music.youtube.com format. -// YouTube Music URLs bypass the login requirement that affects regular YouTube videos on Cobalt. -func toYouTubeMusicURL(rawURL string) string { - videoID, err := ExtractYouTubeVideoID(rawURL) - if err != nil { - return rawURL - } - return fmt.Sprintf("https://music.youtube.com/watch?v=%s", videoID) -} - -func ExtractYouTubeVideoID(urlStr string) (string, error) { - if strings.Contains(urlStr, "youtu.be/") { - parts := strings.Split(urlStr, "youtu.be/") - if len(parts) >= 2 { - videoID := strings.Split(parts[1], "?")[0] - videoID = strings.Split(videoID, "&")[0] - return strings.TrimSpace(videoID), nil - } - } - - parsed, err := url.Parse(urlStr) - if err != nil { - return "", fmt.Errorf("invalid URL: %w", err) - } - - if v := parsed.Query().Get("v"); v != "" { - return v, nil - } - - if strings.Contains(parsed.Path, "/embed/") { - parts := strings.Split(parsed.Path, "/embed/") - if len(parts) >= 2 { - return strings.Split(parts[1], "/")[0], nil - } - } - - if strings.Contains(parsed.Path, "/v/") { - parts := strings.Split(parsed.Path, "/v/") - if len(parts) >= 2 { - return strings.Split(parts[1], "/")[0], nil - } - } - - return "", fmt.Errorf("could not extract video ID from URL") -} - -// searchYouTubeMusicViaExtension uses the YT Music extension's customSearch -// to find a track by artist + title. It filters for tracks only (not videos, -// albums, or playlists) and returns the YouTube Music watch URL for the first -// matching track, or "" if nothing was found. -func searchYouTubeMusicViaExtension(artistName, trackName string) string { - extManager := GetExtensionManager() - searchProviders := extManager.GetSearchProviders() - - var ytProvider *ExtensionProviderWrapper - for _, p := range searchProviders { - if p.extension.ID == "ytmusic-spotiflac" { - ytProvider = p - break - } - } - if ytProvider == nil { - GoLog("[YouTube] YT Music extension not found or not enabled, skipping fallback\n") - return "" - } - - query := strings.TrimSpace(artistName + " " + trackName) - if query == "" { - return "" - } - - GoLog("[YouTube] Searching YT Music extension for: %s\n", query) - results, err := ytProvider.CustomSearch(query, map[string]interface{}{ - "filter": "tracks", - }) - if err != nil { - GoLog("[YouTube] YT Music extension search failed: %v\n", err) - return "" - } - - for _, track := range results { - if track.ItemType != "" && track.ItemType != "track" { - continue - } - videoID := strings.TrimSpace(track.ID) - if videoID == "" { - continue - } - if isYouTubeVideoID(videoID) { - return BuildYouTubeWatchURL(videoID) - } - } - - GoLog("[YouTube] YT Music extension returned no matching tracks for: %s\n", query) - return "" -} - -func downloadFromYouTube(req DownloadRequest) (YouTubeDownloadResult, error) { - downloader := NewYouTubeDownloader() - - format, bitrate, quality := parseYouTubeQualityInput(req.Quality) - - // URL lookup priority: YouTube video ID > YT Music extension > SongLink (Spotify/Deezer/ISRC) - var youtubeURL string - var lookupErr error - - // SpotifyID might actually be a YouTube video ID (from YT Music extension) - if req.SpotifyID != "" && isYouTubeVideoID(req.SpotifyID) { - youtubeURL = BuildYouTubeWatchURL(req.SpotifyID) - GoLog("[YouTube] SpotifyID appears to be YouTube video ID, using directly: %s\n", youtubeURL) - } - - // Try YT Music extension search first (if installed) - more accurate, tracks only - if youtubeURL == "" && (req.TrackName != "" || req.ArtistName != "") { - youtubeURL = searchYouTubeMusicViaExtension(req.ArtistName, req.TrackName) - if youtubeURL != "" { - GoLog("[YouTube] Found YouTube URL via YT Music extension: %s\n", youtubeURL) - } - } - - if youtubeURL == "" && req.SpotifyID != "" && !isYouTubeVideoID(req.SpotifyID) { - GoLog("[YouTube] Looking up YouTube URL via SongLink for Spotify ID: %s\n", req.SpotifyID) - songlink := NewSongLinkClient() - youtubeURL, lookupErr = songlink.GetYouTubeURLFromSpotify(req.SpotifyID) - if lookupErr != nil { - GoLog("[YouTube] SongLink Spotify lookup failed: %v\n", lookupErr) - } else { - GoLog("[YouTube] Found YouTube URL via SongLink (Spotify): %s\n", youtubeURL) - } - } - - if youtubeURL == "" && req.DeezerID != "" { - GoLog("[YouTube] Looking up YouTube URL via SongLink for Deezer ID: %s\n", req.DeezerID) - songlink := NewSongLinkClient() - youtubeURL, lookupErr = songlink.GetYouTubeURLFromDeezer(req.DeezerID) - if lookupErr != nil { - GoLog("[YouTube] SongLink Deezer lookup failed: %v\n", lookupErr) - } else { - GoLog("[YouTube] Found YouTube URL via SongLink (Deezer): %s\n", youtubeURL) - } - } - - if youtubeURL == "" && req.ISRC != "" { - GoLog("[YouTube] Looking up YouTube URL via SongLink for ISRC: %s\n", req.ISRC) - songlink := NewSongLinkClient() - availability, isrcErr := songlink.CheckTrackAvailability("", req.ISRC) - if isrcErr == nil && availability.YouTube && availability.YouTubeURL != "" { - youtubeURL = availability.YouTubeURL - GoLog("[YouTube] Found YouTube URL via SongLink (ISRC): %s\n", youtubeURL) - } else if isrcErr != nil { - GoLog("[YouTube] SongLink ISRC lookup failed: %v\n", isrcErr) - } - } - - // Cobalt requires direct video URLs, not search URLs - if youtubeURL == "" { - return YouTubeDownloadResult{}, fmt.Errorf("could not find YouTube URL for track: %s - %s (no Spotify/Deezer ID available or track not on YouTube)", req.ArtistName, req.TrackName) - } - - GoLog("[YouTube] Requesting download from Cobalt for: %s\n", youtubeURL) - - cobaltResp, err := downloader.GetDownloadURL(youtubeURL, quality) - if err != nil { - return YouTubeDownloadResult{}, fmt.Errorf("failed to get download URL: %w", err) - } - - ext := ".mp3" - if format == "opus" { - ext = ".opus" - } - - // Some SpotubeDL engines may return a different output container than requested. - // Respect the provider-reported filename to avoid saving MP3 bytes with .opus extension. - if cobaltResp != nil && cobaltResp.Filename != "" { - lowerName := strings.ToLower(strings.TrimSpace(cobaltResp.Filename)) - switch { - case strings.HasSuffix(lowerName, ".mp3"): - ext = ".mp3" - format = "mp3" - case strings.HasSuffix(lowerName, ".opus"), strings.HasSuffix(lowerName, ".ogg"): - ext = ".opus" - format = "opus" - } - } - - filename := buildFilenameFromTemplate(req.FilenameFormat, map[string]any{ - "title": req.TrackName, - "artist": req.ArtistName, - "album": req.AlbumName, - "track": req.TrackNumber, - "year": extractYear(req.ReleaseDate), - "date": req.ReleaseDate, - "disc": req.DiscNumber, - }) - filename = sanitizeFilename(filename) + ext - - var outputPath string - isSafOutput := isFDOutput(req.OutputFD) || strings.TrimSpace(req.OutputPath) != "" - if isSafOutput { - outputPath = strings.TrimSpace(req.OutputPath) - if outputPath == "" && isFDOutput(req.OutputFD) { - outputPath = fmt.Sprintf("/proc/self/fd/%d", req.OutputFD) - } - } else { - outputPath = req.OutputDir + "/" + filename - } - - GoLog("[YouTube] Downloading to: %s\n", outputPath) - - var parallelResult *ParallelDownloadResult - if req.EmbedLyrics || req.CoverURL != "" { - GoLog("[YouTube] Starting parallel fetch for cover and lyrics...\n") - parallelResult = FetchCoverAndLyricsParallel( - req.CoverURL, - req.EmbedMaxQualityCover, - req.SpotifyID, - req.TrackName, - req.ArtistName, - req.EmbedLyrics, - int64(req.DurationMS), - ) - } - - if err := downloader.DownloadFile(cobaltResp.URL, outputPath, req.OutputFD, req.ItemID); err != nil { - return YouTubeDownloadResult{}, fmt.Errorf("download failed: %w", err) - } - - lyricsLRC := "" - var coverData []byte - if parallelResult != nil { - if parallelResult.LyricsLRC != "" { - lyricsLRC = parallelResult.LyricsLRC - GoLog("[YouTube] Got lyrics from lrclib (%d lines)\n", len(parallelResult.LyricsData.Lines)) - } - if parallelResult.CoverData != nil { - coverData = parallelResult.CoverData - GoLog("[YouTube] Got cover art (%d bytes)\n", len(coverData)) - } - } - - return YouTubeDownloadResult{ - FilePath: outputPath, - Title: req.TrackName, - Artist: req.ArtistName, - Album: req.AlbumName, - ReleaseDate: req.ReleaseDate, - TrackNumber: req.TrackNumber, - DiscNumber: req.DiscNumber, - ISRC: req.ISRC, - Format: format, - Bitrate: bitrate, - LyricsLRC: lyricsLRC, - CoverData: coverData, - }, nil -} diff --git a/go_backend/youtube_quality_test.go b/go_backend/youtube_quality_test.go deleted file mode 100644 index e0f2ebbf..00000000 --- a/go_backend/youtube_quality_test.go +++ /dev/null @@ -1,54 +0,0 @@ -package gobackend - -import "testing" - -func TestParseYouTubeQualityInput_OpusNormalizesToSupportedBitrates(t *testing.T) { - format, bitrate, normalized := parseYouTubeQualityInput("opus_160") - if format != "opus" { - t.Fatalf("expected opus format, got %s", format) - } - if bitrate != 128 { - t.Fatalf("expected 128 bitrate, got %d", bitrate) - } - if normalized != YouTubeQualityOpus128 { - t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus128, normalized) - } -} - -func TestParseYouTubeQualityInput_Mp3NormalizesToSupportedBitrates(t *testing.T) { - format, bitrate, normalized := parseYouTubeQualityInput("mp3_192") - if format != "mp3" { - t.Fatalf("expected mp3 format, got %s", format) - } - if bitrate != 256 { - t.Fatalf("expected 256 bitrate, got %d", bitrate) - } - if normalized != YouTubeQualityMP3256 { - t.Fatalf("expected %s normalized, got %s", YouTubeQualityMP3256, normalized) - } -} - -func TestParseYouTubeQualityInput_PicksNearestSupportedBitrate(t *testing.T) { - _, opusBitrate, _ := parseYouTubeQualityInput("opus_999") - if opusBitrate != 320 { - t.Fatalf("expected opus normalization to 320, got %d", opusBitrate) - } - - _, mp3Bitrate, _ := parseYouTubeQualityInput("mp3_1") - if mp3Bitrate != 128 { - t.Fatalf("expected mp3 normalization to 128, got %d", mp3Bitrate) - } -} - -func TestParseYouTubeQualityInput_Opus320(t *testing.T) { - format, bitrate, normalized := parseYouTubeQualityInput("opus_320") - if format != "opus" { - t.Fatalf("expected opus format, got %s", format) - } - if bitrate != 320 { - t.Fatalf("expected 320 bitrate, got %d", bitrate) - } - if normalized != YouTubeQualityOpus320 { - t.Fatalf("expected %s normalized, got %s", YouTubeQualityOpus320, normalized) - } -} diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index eb50e497..588eac10 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2722,24 +2722,6 @@ abstract class AppLocalizations { /// **'Actual quality depends on track availability from the service'** String get qualityNote; - /// Note for YouTube service explaining lossy-only quality - /// - /// In en, this message translates to: - /// **'YouTube provides lossy audio only. Not part of lossless fallback.'** - String get youtubeQualityNote; - - /// Title for YouTube Opus bitrate setting - /// - /// In en, this message translates to: - /// **'YouTube Opus Bitrate'** - String get youtubeOpusBitrateTitle; - - /// Title for YouTube MP3 bitrate setting - /// - /// In en, this message translates to: - /// **'YouTube MP3 Bitrate'** - String get youtubeMp3BitrateTitle; - /// Setting - show quality picker /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 5ca628be..f6e211bc 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1479,16 +1479,6 @@ class AppLocalizationsDe extends AppLocalizations { String get qualityNote => 'Die eigentliche Qualität hängt von der Verfügbarkeit des Dienstes ab'; - @override - String get youtubeQualityNote => - 'YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Qualität vor Download fragen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index d15c8b31..2a51e017 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1455,16 +1455,6 @@ class AppLocalizationsEn extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 5c35784d..0442fc0e 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1455,16 +1455,6 @@ class AppLocalizationsEs extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -4466,16 +4456,6 @@ class AppLocalizationsEsEs extends AppLocalizationsEs { String get qualityNote => 'La calidad real depende de la disponibilidad de la pista del servicio'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Preguntar antes de descargar'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 591427f1..9f27a85a 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1457,16 +1457,6 @@ class AppLocalizationsFr extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index e69d5e81..9999389b 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1455,16 +1455,6 @@ class AppLocalizationsHi extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index 3fc89c44..ac0980df 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1463,16 +1463,6 @@ class AppLocalizationsId extends AppLocalizations { String get qualityNote => 'Kualitas sebenarnya tergantung ketersediaan lagu dari layanan'; - @override - String get youtubeQualityNote => - 'YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.'; - - @override - String get youtubeOpusBitrateTitle => 'Bitrate YouTube Opus'; - - @override - String get youtubeMp3BitrateTitle => 'Kecepatan Bit MP3 YouTube'; - @override String get downloadAskBeforeDownload => 'Tanya Sebelum Unduh'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index d4af16ec..2d6606ce 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1444,16 +1444,6 @@ class AppLocalizationsJa extends AppLocalizations { @override String get qualityNote => '実際の品質はサービスからのトラックの可用性に依存します'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus のビットレート'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 のビットレート'; - @override String get downloadAskBeforeDownload => 'ダウンロード前に確認する'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index e9af2187..4a6244d6 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1435,16 +1435,6 @@ class AppLocalizationsKo extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 521ab98b..b0e709d9 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1455,16 +1455,6 @@ class AppLocalizationsNl extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index b81bdbdc..e22c9ed1 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1455,16 +1455,6 @@ class AppLocalizationsPt extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -4463,16 +4453,6 @@ class AppLocalizationsPtPt extends AppLocalizationsPt { String get qualityNote => 'A qualidade real depende da faixa que estiver disponível no serviço'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Perguntar qualidade antes de baixar'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 13af38da..1875d49f 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1480,16 +1480,6 @@ class AppLocalizationsRu extends AppLocalizations { String get qualityNote => 'Фактическое качество зависит от доступности треков в сервисе'; - @override - String get youtubeQualityNote => - 'YouTube обеспечивает только звук с потерями(Lossy).'; - - @override - String get youtubeOpusBitrateTitle => 'Битрейт YouTube Opus'; - - @override - String get youtubeMp3BitrateTitle => 'Битрейт YouTube MP3'; - @override String get downloadAskBeforeDownload => 'Спрашивать перед скачиванием'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 7050af84..9ca79ab1 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -1461,16 +1461,6 @@ class AppLocalizationsTr extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 04a1fd43..cb4ee004 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1455,16 +1455,6 @@ class AppLocalizationsZh extends AppLocalizations { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -4429,16 +4419,6 @@ class AppLocalizationsZhCn extends AppLocalizationsZh { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; @@ -6835,16 +6815,6 @@ class AppLocalizationsZhTw extends AppLocalizationsZh { String get qualityNote => 'Actual quality depends on track availability from the service'; - @override - String get youtubeQualityNote => - 'YouTube provides lossy audio only. Not part of lossless fallback.'; - - @override - String get youtubeOpusBitrateTitle => 'YouTube Opus Bitrate'; - - @override - String get youtubeMp3BitrateTitle => 'YouTube MP3 Bitrate'; - @override String get downloadAskBeforeDownload => 'Ask Before Download'; diff --git a/lib/l10n/arb/app_de.arb b/lib/l10n/arb/app_de.arb index 3dce0557..151c6a8d 100644 --- a/lib/l10n/arb/app_de.arb +++ b/lib/l10n/arb/app_de.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube bietet nur verlustbehaftete Audioqualität. Deswegen ist es kein Teil des verlustfreien Fallbacks.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Qualität vor Download fragen", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 02ef0a43..465cefc6 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1909,18 +1909,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_es_ES.arb b/lib/l10n/arb/app_es_ES.arb index dab6880d..e622a44c 100644 --- a/lib/l10n/arb/app_es_ES.arb +++ b/lib/l10n/arb/app_es_ES.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Preguntar antes de descargar", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_fr.arb b/lib/l10n/arb/app_fr.arb index 2fdf0477..5d2f1cae 100644 --- a/lib/l10n/arb/app_fr.arb +++ b/lib/l10n/arb/app_fr.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_hi.arb b/lib/l10n/arb/app_hi.arb index 0eeebf16..f2568821 100644 --- a/lib/l10n/arb/app_hi.arb +++ b/lib/l10n/arb/app_hi.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index eba93ff1..1cd89fba 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube hanya menyediakan audio terkompresi (lossy). Bukan bagian dari fallback lossless.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "Bitrate YouTube Opus", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "Kecepatan Bit MP3 YouTube", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Tanya Sebelum Unduh", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_ja.arb b/lib/l10n/arb/app_ja.arb index 44674a04..ad71c3f3 100644 --- a/lib/l10n/arb/app_ja.arb +++ b/lib/l10n/arb/app_ja.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus のビットレート", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 のビットレート", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "ダウンロード前に確認する", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_ko.arb b/lib/l10n/arb/app_ko.arb index b872ef5c..1bec37ba 100644 --- a/lib/l10n/arb/app_ko.arb +++ b/lib/l10n/arb/app_ko.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_nl.arb b/lib/l10n/arb/app_nl.arb index 438519c3..ad97b6a3 100644 --- a/lib/l10n/arb/app_nl.arb +++ b/lib/l10n/arb/app_nl.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_pt_PT.arb b/lib/l10n/arb/app_pt_PT.arb index 9c7e843d..3cc94df2 100644 --- a/lib/l10n/arb/app_pt_PT.arb +++ b/lib/l10n/arb/app_pt_PT.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Perguntar qualidade antes de baixar", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_ru.arb b/lib/l10n/arb/app_ru.arb index 4a1ffc71..e9373f9c 100644 --- a/lib/l10n/arb/app_ru.arb +++ b/lib/l10n/arb/app_ru.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube обеспечивает только звук с потерями(Lossy).", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "Битрейт YouTube Opus", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "Битрейт YouTube MP3", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Спрашивать перед скачиванием", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_tr.arb b/lib/l10n/arb/app_tr.arb index 53ce4fb7..1afcb84d 100644 --- a/lib/l10n/arb/app_tr.arb +++ b/lib/l10n/arb/app_tr.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_zh_CN.arb b/lib/l10n/arb/app_zh_CN.arb index db6943ab..ff232550 100644 --- a/lib/l10n/arb/app_zh_CN.arb +++ b/lib/l10n/arb/app_zh_CN.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/l10n/arb/app_zh_TW.arb b/lib/l10n/arb/app_zh_TW.arb index cf4f7b4a..598bb415 100644 --- a/lib/l10n/arb/app_zh_TW.arb +++ b/lib/l10n/arb/app_zh_TW.arb @@ -1773,18 +1773,6 @@ "@qualityNote": { "description": "Note about quality availability" }, - "youtubeQualityNote": "YouTube provides lossy audio only. Not part of lossless fallback.", - "@youtubeQualityNote": { - "description": "Note for YouTube service explaining lossy-only quality" - }, - "youtubeOpusBitrateTitle": "YouTube Opus Bitrate", - "@youtubeOpusBitrateTitle": { - "description": "Title for YouTube Opus bitrate setting" - }, - "youtubeMp3BitrateTitle": "YouTube MP3 Bitrate", - "@youtubeMp3BitrateTitle": { - "description": "Title for YouTube MP3 bitrate setting" - }, "downloadAskBeforeDownload": "Ask Before Download", "@downloadAskBeforeDownload": { "description": "Setting - show quality picker" diff --git a/lib/models/settings.dart b/lib/models/settings.dart index c2f2eed6..63f87abe 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -42,10 +42,6 @@ class AppSettings { final String lyricsMode; final String tidalHighFormat; // Format for Tidal HIGH quality: 'mp3_320', 'opus_256', or 'opus_128' - final int - youtubeOpusBitrate; // YouTube Opus bitrate (supported: 128/256/320 kbps) - final int - youtubeMp3Bitrate; // YouTube MP3 bitrate (supported: 128/256/320 kbps) final bool useAllFilesAccess; // Android 13+ only: enable MANAGE_EXTERNAL_STORAGE final bool @@ -121,8 +117,6 @@ class AppSettings { this.locale = 'system', this.lyricsMode = 'embed', this.tidalHighFormat = 'mp3_320', - this.youtubeOpusBitrate = 256, - this.youtubeMp3Bitrate = 320, this.useAllFilesAccess = false, this.autoExportFailedDownloads = false, this.downloadNetworkMode = 'any', @@ -189,8 +183,6 @@ class AppSettings { String? locale, String? lyricsMode, String? tidalHighFormat, - int? youtubeOpusBitrate, - int? youtubeMp3Bitrate, bool? useAllFilesAccess, bool? autoExportFailedDownloads, String? downloadNetworkMode, @@ -257,8 +249,6 @@ class AppSettings { locale: locale ?? this.locale, lyricsMode: lyricsMode ?? this.lyricsMode, tidalHighFormat: tidalHighFormat ?? this.tidalHighFormat, - youtubeOpusBitrate: youtubeOpusBitrate ?? this.youtubeOpusBitrate, - youtubeMp3Bitrate: youtubeMp3Bitrate ?? this.youtubeMp3Bitrate, useAllFilesAccess: useAllFilesAccess ?? this.useAllFilesAccess, autoExportFailedDownloads: autoExportFailedDownloads ?? this.autoExportFailedDownloads, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 914e224f..d78bc81e 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -47,8 +47,6 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( locale: json['locale'] as String? ?? 'system', lyricsMode: json['lyricsMode'] as String? ?? 'embed', tidalHighFormat: json['tidalHighFormat'] as String? ?? 'mp3_320', - youtubeOpusBitrate: (json['youtubeOpusBitrate'] as num?)?.toInt() ?? 256, - youtubeMp3Bitrate: (json['youtubeMp3Bitrate'] as num?)?.toInt() ?? 320, useAllFilesAccess: json['useAllFilesAccess'] as bool? ?? false, autoExportFailedDownloads: json['autoExportFailedDownloads'] as bool? ?? false, @@ -125,8 +123,6 @@ Map _$AppSettingsToJson( 'locale': instance.locale, 'lyricsMode': instance.lyricsMode, 'tidalHighFormat': instance.tidalHighFormat, - 'youtubeOpusBitrate': instance.youtubeOpusBitrate, - 'youtubeMp3Bitrate': instance.youtubeMp3Bitrate, 'useAllFilesAccess': instance.useAllFilesAccess, 'autoExportFailedDownloads': instance.autoExportFailedDownloads, 'downloadNetworkMode': instance.downloadNetworkMode, diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index c2712eed..57f0e1f3 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2135,15 +2135,12 @@ class DownloadQueueNotifier extends Notifier { } String _determineOutputExt(String quality, String service) { - if (service.toLowerCase() == 'youtube') { - if (quality.toLowerCase().contains('mp3')) { - return '.mp3'; - } - return '.opus'; - } if (service.toLowerCase() == 'tidal' && quality == 'HIGH') { return '.m4a'; } + final q = quality.toLowerCase(); + if (q.startsWith('opus')) return '.opus'; + if (q.startsWith('mp3')) return '.mp3'; return '.flac'; } @@ -3795,28 +3792,6 @@ class DownloadQueueNotifier extends Notifier { ); var quality = item.qualityOverride ?? state.audioQuality; - if (item.service.toLowerCase() == 'youtube') { - final normalized = quality.toLowerCase(); - final isYoutubeQuality = - normalized.startsWith('mp3_') || normalized.startsWith('opus_'); - if (!isYoutubeQuality) { - final mp3Bitrate = (() { - const supported = [128, 256, 320]; - var nearest = supported.first; - var nearestDistance = (settings.youtubeMp3Bitrate - nearest).abs(); - for (final option in supported.skip(1)) { - final distance = (settings.youtubeMp3Bitrate - option).abs(); - if (distance < nearestDistance || - (distance == nearestDistance && option > nearest)) { - nearest = option; - nearestDistance = distance; - } - } - return nearest; - })(); - quality = 'mp3_$mp3Bitrate'; - } - } final isSafMode = _isSafMode(settings); final relativeOutputDir = isSafMode ? await _buildRelativeOutputDir( @@ -4172,14 +4147,10 @@ class DownloadQueueNotifier extends Notifier { final relativeDir = useSaf ? outputDir : ''; final fileName = useSaf ? (safFileName ?? '') : ''; final outputExt = useSaf ? safOutputExt : ''; - final isYouTube = item.service == 'youtube'; - final shouldUseExtensions = !isYouTube && useExtensions; - final shouldUseFallback = !isYouTube && state.autoFallback; + final shouldUseExtensions = useExtensions; + final shouldUseFallback = state.autoFallback; - if (isYouTube) { - _log.d('Using YouTube/Cobalt provider for download'); - _log.d('Quality: $quality (lossy only)'); - } else if (shouldUseExtensions) { + if (shouldUseExtensions) { _log.d('Using extension providers for download'); _log.d( 'Quality: $quality${item.qualityOverride != null ? ' (override)' : ''}', @@ -4854,11 +4825,23 @@ class DownloadQueueNotifier extends Notifier { } else if (metadataEmbeddingEnabled && isContentUriPath && effectiveSafMode && - isFlacFile && + !isM4aFile && !wasExisting) { final currentFilePath = filePath; + final isOpusFile = filePath.endsWith('.opus'); + final isMp3File = filePath.endsWith('.mp3'); + final ext = isOpusFile + ? '.opus' + : isMp3File + ? '.mp3' + : '.flac'; + final formatName = isOpusFile + ? 'Opus' + : isMp3File + ? 'MP3' + : 'FLAC'; _log.d( - 'SAF FLAC detected, embedding metadata and cover via temp file...', + 'SAF $formatName detected, embedding metadata and cover via temp file...', ); final tempPath = await _copySafToTemp(currentFilePath); if (tempPath != null) { @@ -4878,21 +4861,39 @@ class DownloadQueueNotifier extends Notifier { final backendLabel = result['label'] as String?; final backendCopyright = result['copyright'] as String?; - await _embedMetadataAndCover( - tempPath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - writeExternalLrc: false, - ); + if (isMp3File) { + await _embedMetadataToMp3( + tempPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + } else if (isOpusFile) { + await _embedMetadataToOpus( + tempPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + ); + } else { + await _embedMetadataAndCover( + tempPath, + finalTrack, + genre: backendGenre ?? genre, + label: backendLabel ?? label, + copyright: backendCopyright, + writeExternalLrc: false, + ); + } - final newFileName = '${safBaseName ?? 'track'}.flac'; + final newFileName = '${safBaseName ?? 'track'}$ext'; final newUri = await _writeTempToSaf( treeUri: settings.downloadTreeUri, relativeDir: effectiveOutputDir, fileName: newFileName, - mimeType: _mimeTypeForExt('.flac'), + mimeType: _mimeTypeForExt(ext), srcPath: tempPath, ); @@ -4902,12 +4903,14 @@ class DownloadQueueNotifier extends Notifier { } filePath = newUri; finalSafFileName = newFileName; - _log.d('SAF FLAC metadata embedding completed'); + _log.d('SAF $formatName metadata embedding completed'); } else { - _log.w('Failed to write metadata-updated FLAC back to SAF'); + _log.w( + 'Failed to write metadata-updated $formatName back to SAF', + ); } } catch (e) { - _log.w('SAF FLAC metadata embedding failed: $e'); + _log.w('SAF $formatName metadata embedding failed: $e'); } finally { try { await File(tempPath).delete(); @@ -4952,109 +4955,6 @@ class DownloadQueueNotifier extends Notifier { } } - // YouTube downloads: embed metadata to raw Opus/MP3 files from Cobalt - if (metadataEmbeddingEnabled && - !wasExisting && - item.service == 'youtube' && - filePath != null) { - final isOpusFile = filePath.endsWith('.opus'); - final isMp3File = filePath.endsWith('.mp3'); - - if (isOpusFile || isMp3File) { - _log.i( - 'YouTube download: embedding metadata to ${isOpusFile ? 'Opus' : 'MP3'} file', - ); - updateItemStatus( - item.id, - DownloadStatus.downloading, - progress: 0.95, - ); - - final finalTrack = _buildTrackForMetadataEmbedding( - trackToDownload, - result, - resolvedAlbumArtist, - ); - final backendGenre = result['genre'] as String?; - final backendLabel = result['label'] as String?; - final backendCopyright = result['copyright'] as String?; - - final isContentUriPath = isContentUri(filePath); - if (isContentUriPath && effectiveSafMode) { - final tempPath = await _copySafToTemp(filePath); - if (tempPath != null) { - try { - if (isMp3File) { - await _embedMetadataToMp3( - tempPath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); - } else { - await _embedMetadataToOpus( - tempPath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); - } - final ext = isMp3File ? '.mp3' : '.opus'; - final newFileName = '${safBaseName ?? 'track'}$ext'; - final newUri = await _writeTempToSaf( - treeUri: settings.downloadTreeUri, - relativeDir: effectiveOutputDir, - fileName: newFileName, - mimeType: _mimeTypeForExt(ext), - srcPath: tempPath, - ); - if (newUri != null) { - if (newUri != filePath) { - await _deleteSafFile(filePath); - } - filePath = newUri; - finalSafFileName = newFileName; - _log.d('YouTube SAF metadata embedding completed'); - } else { - _log.w('Failed to write metadata-updated file back to SAF'); - } - } catch (e) { - _log.w('YouTube SAF metadata embedding failed: $e'); - } finally { - try { - await File(tempPath).delete(); - } catch (_) {} - } - } - } else { - try { - if (isMp3File) { - await _embedMetadataToMp3( - filePath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); - } else { - await _embedMetadataToOpus( - filePath, - finalTrack, - genre: backendGenre ?? genre, - label: backendLabel ?? label, - copyright: backendCopyright, - ); - } - _log.d('YouTube metadata embedding completed'); - } catch (e) { - _log.w('YouTube metadata embedding failed: $e'); - } - } - } - } - final itemAfterDownload = _findItemById(item.id); if (itemAfterDownload == null || _isLocallyCancelled(item.id, item: itemAfterDownload)) { diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index f2309d5d..77feff76 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -11,13 +11,11 @@ import 'package:spotiflac_android/utils/logger.dart'; const _settingsKey = 'app_settings'; const _migrationVersionKey = 'settings_migration_version'; -const _currentMigrationVersion = 6; +const _currentMigrationVersion = 7; const _spotifyClientSecretKey = 'spotify_client_secret'; final _log = AppLogger('SettingsProvider'); class SettingsNotifier extends Notifier { - static const List _youtubeOpusSupportedBitrates = [128, 256, 320]; - static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; static final RegExp _isoRegionPattern = RegExp(r'^[A-Z]{2}$'); final Future _prefs = SharedPreferences.getInstance(); @@ -40,7 +38,6 @@ class SettingsNotifier extends Notifier { await _runMigrations(prefs); await _normalizeIosDownloadDirectoryIfNeeded(); - await _normalizeYouTubeBitratesIfNeeded(); await _normalizeSongLinkRegionIfNeeded(); } @@ -122,6 +119,10 @@ class SettingsNotifier extends Notifier { ); } state = state.copyWith(lastSeenVersion: AppInfo.version); + // Migration 7: YouTube is no longer a built-in service — reset to Tidal + if (state.defaultService == 'youtube') { + state = state.copyWith(defaultService: 'tidal'); + } await prefs.setInt(_migrationVersionKey, _currentMigrationVersion); await _saveSettings(); } @@ -153,49 +154,6 @@ class SettingsNotifier extends Notifier { } } - int _nearestSupportedBitrate(int value, List supported) { - var nearest = supported.first; - var nearestDistance = (value - nearest).abs(); - - for (final option in supported.skip(1)) { - final distance = (value - option).abs(); - // On tie, prefer higher quality bitrate. - if (distance < nearestDistance || - (distance == nearestDistance && option > nearest)) { - nearest = option; - nearestDistance = distance; - } - } - - return nearest; - } - - int _normalizeYouTubeOpusBitrate(int bitrate) { - return _nearestSupportedBitrate(bitrate, _youtubeOpusSupportedBitrates); - } - - int _normalizeYouTubeMp3Bitrate(int bitrate) { - return _nearestSupportedBitrate(bitrate, _youtubeMp3SupportedBitrates); - } - - Future _normalizeYouTubeBitratesIfNeeded() async { - final normalizedOpus = _normalizeYouTubeOpusBitrate( - state.youtubeOpusBitrate, - ); - final normalizedMp3 = _normalizeYouTubeMp3Bitrate(state.youtubeMp3Bitrate); - - if (normalizedOpus == state.youtubeOpusBitrate && - normalizedMp3 == state.youtubeMp3Bitrate) { - return; - } - - state = state.copyWith( - youtubeOpusBitrate: normalizedOpus, - youtubeMp3Bitrate: normalizedMp3, - ); - await _saveSettings(); - } - Future _normalizeIosDownloadDirectoryIfNeeded() async { if (!Platform.isIOS) return; @@ -469,18 +427,6 @@ class SettingsNotifier extends Notifier { _saveSettings(); } - void setYoutubeOpusBitrate(int bitrate) { - final normalized = _normalizeYouTubeOpusBitrate(bitrate); - state = state.copyWith(youtubeOpusBitrate: normalized); - _saveSettings(); - } - - void setYoutubeMp3Bitrate(int bitrate) { - final normalized = _normalizeYouTubeMp3Bitrate(bitrate); - state = state.copyWith(youtubeMp3Bitrate: normalized); - _saveSettings(); - } - void setUseAllFilesAccess(bool enabled) { state = state.copyWith(useAllFilesAccess: enabled); _saveSettings(); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 4f3fcbe1..cb292291 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -742,7 +742,7 @@ class _SwingIconState extends State TweenSequenceItem(tween: Tween(begin: 0.15, end: -0.1), weight: 20), TweenSequenceItem(tween: Tween(begin: -0.1, end: 0.05), weight: 20), TweenSequenceItem(tween: Tween(begin: 0.05, end: 0.0), weight: 20), - ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut)); + ]).animate(_controller); _controller.forward(); } diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 85470e99..b7c34cae 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -465,34 +465,6 @@ class _DownloadSettingsPageState extends ConsumerState { ), ), ], - SettingsItem( - title: context.l10n.youtubeOpusBitrateTitle, - subtitle: - '${settings.youtubeOpusBitrate}kbps (128/256/320)', - onTap: () => _showYoutubeBitratePicker( - context: context, - title: context.l10n.youtubeOpusBitrateTitle, - currentValue: settings.youtubeOpusBitrate, - options: const [128, 256, 320], - onSave: (value) => ref - .read(settingsProvider.notifier) - .setYoutubeOpusBitrate(value), - ), - ), - SettingsItem( - title: context.l10n.youtubeMp3BitrateTitle, - subtitle: '${settings.youtubeMp3Bitrate}kbps (128/256/320)', - onTap: () => _showYoutubeBitratePicker( - context: context, - title: context.l10n.youtubeMp3BitrateTitle, - currentValue: settings.youtubeMp3Bitrate, - options: const [128, 256, 320], - onSave: (value) => ref - .read(settingsProvider.notifier) - .setYoutubeMp3Bitrate(value), - ), - showDivider: false, - ), ], ), ), @@ -1689,68 +1661,6 @@ class _DownloadSettingsPageState extends ConsumerState { ); } - void _showYoutubeBitratePicker({ - required BuildContext context, - required String title, - required int currentValue, - required List options, - required void Function(int value) onSave, - }) { - final colorScheme = Theme.of(context).colorScheme; - - showModalBottomSheet( - context: context, - useRootNavigator: true, - backgroundColor: colorScheme.surfaceContainerHigh, - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(28)), - ), - builder: (sheetContext) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 8), - Container( - width: 40, - height: 4, - decoration: BoxDecoration( - color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.fromLTRB(24, 12, 24, 8), - child: Row( - children: [ - Expanded( - child: Text( - title, - style: Theme.of(sheetContext).textTheme.titleMedium - ?.copyWith(fontWeight: FontWeight.bold), - ), - ), - ], - ), - ), - for (final bitrate in options) - ListTile( - title: Text('$bitrate kbps'), - trailing: bitrate == currentValue - ? Icon(Icons.check, color: colorScheme.primary) - : null, - onTap: () { - onSave(bitrate); - Navigator.pop(sheetContext); - }, - ), - const SizedBox(height: 8), - ], - ), - ), - ); - } - void _showMusixmatchLanguagePicker( BuildContext context, WidgetRef ref, @@ -2100,7 +2010,7 @@ class _ServiceSelector extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final extState = ref.watch(extensionProvider); - final builtInServiceIds = ['tidal', 'qobuz', 'deezer', 'youtube']; + final builtInServiceIds = ['tidal', 'qobuz', 'deezer']; final extensionProviders = extState.extensions .where((e) => e.enabled && e.hasDownloadProvider) @@ -2136,15 +2046,6 @@ class _ServiceSelector extends ConsumerWidget { onTap: () => onChanged('qobuz'), ), ), - const SizedBox(width: 8), - Expanded( - child: _ServiceChip( - icon: Icons.smart_display, - label: 'YouTube', - isSelected: effectiveService == 'youtube', - onTap: () => onChanged('youtube'), - ), - ), ], ), if (extensionProviders.isNotEmpty) ...[ diff --git a/lib/screens/settings/provider_priority_page.dart b/lib/screens/settings/provider_priority_page.dart index 02c15b1a..3d73a06d 100644 --- a/lib/screens/settings/provider_priority_page.dart +++ b/lib/screens/settings/provider_priority_page.dart @@ -340,12 +340,6 @@ class _ProviderItem extends StatelessWidget { icon: Icons.graphic_eq, isBuiltIn: true, ); - case 'youtube': - return _ProviderInfo( - name: 'YouTube', - icon: Icons.play_circle_outline, - isBuiltIn: true, - ); default: return _ProviderInfo( name: provider, diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index f11cfa1a..2d149f87 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -343,16 +343,23 @@ class _StoreTabState extends ConsumerState { labelText: context.l10n.storeRepoUrlLabel, prefixIcon: const Icon(Icons.link), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide(color: colorScheme.outlineVariant), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: colorScheme.outline), + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide(color: colorScheme.outlineVariant), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(28), borderSide: BorderSide(color: colorScheme.primary, width: 2), ), + filled: true, + fillColor: colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, + ), ), keyboardType: TextInputType.url, autocorrect: false, @@ -441,7 +448,31 @@ class _StoreTabState extends ConsumerState { labelText: context.l10n.storeNewRepoUrlLabel, prefixIcon: const Icon(Icons.link), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(28), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + filled: true, + fillColor: Theme.of( + context, + ).colorScheme.surfaceContainerHighest, + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 16, ), ), keyboardType: TextInputType.url, diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index d6c292d2..7d8a33f6 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -748,7 +748,7 @@ class _DownloadSuccessOverlayState extends State _flashAnimation = TweenSequence([ TweenSequenceItem(tween: Tween(begin: 0.0, end: 0.15), weight: 30), TweenSequenceItem(tween: Tween(begin: 0.15, end: 0.0), weight: 70), - ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); + ]).animate(_controller); } @override @@ -816,7 +816,7 @@ class _AnimatedBadgeState extends State _scaleAnimation = TweenSequence([ TweenSequenceItem(tween: Tween(begin: 1.0, end: 1.3), weight: 40), TweenSequenceItem(tween: Tween(begin: 1.3, end: 1.0), weight: 60), - ]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutBack)); + ]).animate(_controller); } @override diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 7c0a2599..51f4aa37 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -77,24 +77,6 @@ const _builtInServices = [ ), ], ), - BuiltInService( - id: 'youtube', - label: 'YouTube', - qualityOptions: [ - QualityOption( - id: 'opus_320', - label: 'Opus 320kbps', - description: 'Best quality lossy (~10MB per track)', - ), - QualityOption( - id: 'mp3_320', - label: 'MP3 320kbps', - description: 'Best compatibility (~10MB per track)', - ), - ], - isDisabled: false, - disabledReason: null, - ), ]; class DownloadServicePicker extends ConsumerStatefulWidget { @@ -148,9 +130,6 @@ class DownloadServicePicker extends ConsumerStatefulWidget { } class _DownloadServicePickerState extends ConsumerState { - static const List _youtubeOpusSupportedBitrates = [128, 256, 320]; - static const List _youtubeMp3SupportedBitrates = [128, 256, 320]; - late String _selectedService; @override @@ -167,30 +146,6 @@ class _DownloadServicePickerState extends ConsumerState { /// Get quality options for the selected service List _getQualityOptions() { - final settings = ref.read(settingsProvider); - if (_selectedService == 'youtube') { - final opusBitrate = _nearestSupportedBitrate( - settings.youtubeOpusBitrate, - _youtubeOpusSupportedBitrates, - ); - final mp3Bitrate = _nearestSupportedBitrate( - settings.youtubeMp3Bitrate, - _youtubeMp3SupportedBitrates, - ); - return [ - QualityOption( - id: 'opus_$opusBitrate', - label: 'Opus ${opusBitrate}kbps', - description: 'Configured from YouTube settings', - ), - QualityOption( - id: 'mp3_$mp3Bitrate', - label: 'MP3 ${mp3Bitrate}kbps', - description: 'Configured from YouTube settings', - ), - ]; - } - final builtIn = _builtInServices .where((s) => s.id == _selectedService) .firstOrNull; @@ -215,22 +170,6 @@ class _DownloadServicePickerState extends ConsumerState { ]; } - int _nearestSupportedBitrate(int value, List supported) { - var nearest = supported.first; - var nearestDistance = (value - nearest).abs(); - - for (final option in supported.skip(1)) { - final distance = (value - option).abs(); - if (distance < nearestDistance || - (distance == nearestDistance && option > nearest)) { - nearest = option; - nearestDistance = distance; - } - } - - return nearest; - } - @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; @@ -324,9 +263,7 @@ class _DownloadServicePickerState extends ConsumerState { ), ), - if (_builtInServices.any( - (s) => s.id == _selectedService && s.id != 'youtube', - )) + if (_builtInServices.any((s) => s.id == _selectedService)) Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), child: Text( @@ -338,18 +275,6 @@ class _DownloadServicePickerState extends ConsumerState { ), ), - if (_selectedService == 'youtube') - Padding( - padding: const EdgeInsets.fromLTRB(24, 0, 24, 12), - child: Text( - context.l10n.youtubeQualityNote, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontStyle: FontStyle.italic, - ), - ), - ), - for (final quality in qualityOptions) _QualityOption( title: quality.label, From bf0f4bdf3ef048f2dbd3c4cff08c0495d61d3b97 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 26 Mar 2026 16:26:14 +0700 Subject: [PATCH 17/33] fix: store URL input flash on startup and FLAC metadata fallback for mismatched files Load saved registry URL before first state update to prevent brief flash of the setup screen when the store tab initializes. Add Ogg/Opus fallback in readFileMetadata when FLAC parsing fails, handling files saved with .flac extension that contain opus data. --- go_backend/exports.go | 72 ++++++++++++++------ lib/providers/store_provider.dart | 108 +++++++++++++++++++++--------- 2 files changed, 126 insertions(+), 54 deletions(-) diff --git a/go_backend/exports.go b/go_backend/exports.go index 23f80ad6..830f11bb 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -698,29 +698,57 @@ func ReadFileMetadata(filePath string) (string, error) { if isFlac { metadata, err := ReadMetadata(filePath) if err != nil { - return "", fmt.Errorf("failed to read metadata: %w", err) - } - result["title"] = metadata.Title - result["artist"] = metadata.Artist - result["album"] = metadata.Album - result["album_artist"] = metadata.AlbumArtist - result["date"] = metadata.Date - result["track_number"] = metadata.TrackNumber - result["disc_number"] = metadata.DiscNumber - result["isrc"] = metadata.ISRC - result["lyrics"] = metadata.Lyrics - result["genre"] = metadata.Genre - result["label"] = metadata.Label - result["copyright"] = metadata.Copyright - result["composer"] = metadata.Composer - result["comment"] = metadata.Comment + // File may have wrong extension (e.g. opus saved as .flac). + // Try Ogg/Opus parser as fallback before giving up. + GoLog("[ReadFileMetadata] FLAC parse failed for %s, trying Ogg fallback: %v\n", filePath, err) + oggMeta, oggErr := ReadOggVorbisComments(filePath) + if oggErr == nil && oggMeta != nil { + result["title"] = oggMeta.Title + result["artist"] = oggMeta.Artist + result["album"] = oggMeta.Album + result["album_artist"] = oggMeta.AlbumArtist + result["date"] = oggMeta.Date + if oggMeta.Date == "" { + result["date"] = oggMeta.Year + } + result["track_number"] = oggMeta.TrackNumber + result["disc_number"] = oggMeta.DiscNumber + result["isrc"] = oggMeta.ISRC + result["lyrics"] = oggMeta.Lyrics + result["genre"] = oggMeta.Genre + result["composer"] = oggMeta.Composer + result["comment"] = oggMeta.Comment + quality, qualityErr := GetOggQuality(filePath) + if qualityErr == nil { + result["sample_rate"] = quality.SampleRate + result["duration"] = quality.Duration + } + } else { + return "", fmt.Errorf("failed to read metadata: %w", err) + } + } else { + result["title"] = metadata.Title + result["artist"] = metadata.Artist + result["album"] = metadata.Album + result["album_artist"] = metadata.AlbumArtist + result["date"] = metadata.Date + result["track_number"] = metadata.TrackNumber + result["disc_number"] = metadata.DiscNumber + result["isrc"] = metadata.ISRC + result["lyrics"] = metadata.Lyrics + result["genre"] = metadata.Genre + result["label"] = metadata.Label + result["copyright"] = metadata.Copyright + result["composer"] = metadata.Composer + result["comment"] = metadata.Comment - quality, qualityErr := GetAudioQuality(filePath) - if qualityErr == nil { - result["bit_depth"] = quality.BitDepth - result["sample_rate"] = quality.SampleRate - if quality.SampleRate > 0 && quality.TotalSamples > 0 { - result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) + quality, qualityErr := GetAudioQuality(filePath) + if qualityErr == nil { + result["bit_depth"] = quality.BitDepth + result["sample_rate"] = quality.SampleRate + if quality.SampleRate > 0 && quality.TotalSamples > 0 { + result["duration"] = int(quality.TotalSamples / int64(quality.SampleRate)) + } } } } else if isM4A { diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index bac55c5c..fc56c011 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -12,13 +12,13 @@ const _registryUrlPrefKey = 'store_registry_url'; int compareVersions(String v1, String v2) { final parts1 = v1.replaceAll(_leadingVersionPrefix, '').split('.'); final parts2 = v2.replaceAll(_leadingVersionPrefix, '').split('.'); - + final maxLen = parts1.length > parts2.length ? parts1.length : parts2.length; - + for (var i = 0; i < maxLen; i++) { final n1 = i < parts1.length ? (int.tryParse(parts1[i]) ?? 0) : 0; final n2 = i < parts2.length ? (int.tryParse(parts2[i]) ?? 0) : 0; - + if (n1 < n2) return -1; if (n1 > n2) return 1; } @@ -26,14 +26,19 @@ int compareVersions(String v1, String v2) { } class StoreCategory { - static const String metadata = 'metadata'; static const String download = 'download'; static const String utility = 'utility'; static const String lyrics = 'lyrics'; static const String integration = 'integration'; - static const List all = [metadata, download, utility, lyrics, integration]; + static const List all = [ + metadata, + download, + utility, + lyrics, + integration, + ]; static String getDisplayName(String category) { switch (category) { @@ -94,7 +99,8 @@ class StoreExtension { return StoreExtension( id: json['id'] as String? ?? '', name: json['name'] as String? ?? '', - displayName: json['display_name'] as String? ?? json['name'] as String? ?? '', + displayName: + json['display_name'] as String? ?? json['name'] as String? ?? '', version: json['version'] as String? ?? '0.0.0', author: json['author'] as String? ?? 'Unknown', description: json['description'] as String? ?? '', @@ -117,7 +123,6 @@ class StoreExtension { } } - class StoreState { final List extensions; final String? selectedCategory; @@ -160,11 +165,15 @@ class StoreState { }) { return StoreState( extensions: extensions ?? this.extensions, - selectedCategory: clearCategory ? null : (selectedCategory ?? this.selectedCategory), + selectedCategory: clearCategory + ? null + : (selectedCategory ?? this.selectedCategory), searchQuery: searchQuery ?? this.searchQuery, isLoading: isLoading ?? this.isLoading, isDownloading: isDownloading ?? this.isDownloading, - downloadingId: clearDownloadingId ? null : (downloadingId ?? this.downloadingId), + downloadingId: clearDownloadingId + ? null + : (downloadingId ?? this.downloadingId), error: clearError ? null : (error ?? this.error), isInitialized: isInitialized ?? this.isInitialized, registryUrl: registryUrl ?? this.registryUrl, @@ -180,13 +189,16 @@ class StoreState { if (searchQuery.isNotEmpty) { final query = searchQuery.toLowerCase(); - result = result.where((e) => - e.name.toLowerCase().contains(query) || - e.displayName.toLowerCase().contains(query) || - e.description.toLowerCase().contains(query) || - e.author.toLowerCase().contains(query) || - e.tags.any((t) => t.toLowerCase().contains(query)) - ).toList(); + result = result + .where( + (e) => + e.name.toLowerCase().contains(query) || + e.displayName.toLowerCase().contains(query) || + e.description.toLowerCase().contains(query) || + e.author.toLowerCase().contains(query) || + e.tags.any((t) => t.toLowerCase().contains(query)), + ) + .toList(); } return result; @@ -206,23 +218,28 @@ class StoreNotifier extends Notifier { Future initialize(String cacheDir) async { if (state.isInitialized) return; - state = state.copyWith(isLoading: true, clearError: true); + // Load saved registry URL early to avoid UI flash (empty → setup screen) + final prefs = await SharedPreferences.getInstance(); + final savedUrl = prefs.getString(_registryUrlPrefKey) ?? ''; + + state = state.copyWith( + isLoading: true, + clearError: true, + registryUrl: savedUrl, + ); try { await PlatformBridge.initExtensionStore(cacheDir); - // Load saved registry URL from SharedPreferences - final prefs = await SharedPreferences.getInstance(); - final savedUrl = prefs.getString(_registryUrlPrefKey) ?? ''; - if (savedUrl.isNotEmpty) { await PlatformBridge.setStoreRegistryUrl(savedUrl); - state = state.copyWith(registryUrl: savedUrl); await refresh(); } state = state.copyWith(isInitialized: true, isLoading: false); - _log.i('Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})'); + _log.i( + 'Extension store initialized (registryUrl: ${savedUrl.isEmpty ? "not set" : savedUrl})', + ); } catch (e) { _log.e('Failed to initialize store: $e'); state = state.copyWith(isLoading: false, error: e.toString()); @@ -292,7 +309,9 @@ class StoreNotifier extends Notifier { state = state.copyWith(isLoading: true, clearError: true); try { - final extensions = await PlatformBridge.getStoreExtensions(forceRefresh: forceRefresh); + final extensions = await PlatformBridge.getStoreExtensions( + forceRefresh: forceRefresh, + ); state = state.copyWith( extensions: extensions.map((e) => StoreExtension.fromJson(e)).toList(), isLoading: false, @@ -320,12 +339,23 @@ class StoreNotifier extends Notifier { state = state.copyWith(searchQuery: '', clearCategory: true); } - Future installExtension(String extensionId, String tempDir, String extensionsDir) async { - state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); + Future installExtension( + String extensionId, + String tempDir, + String extensionsDir, + ) async { + state = state.copyWith( + isDownloading: true, + downloadingId: extensionId, + clearError: true, + ); try { _log.i('Downloading extension: $extensionId'); - final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir); + final downloadPath = await PlatformBridge.downloadStoreExtension( + extensionId, + tempDir, + ); _log.i('Installing extension from: $downloadPath'); final extNotifier = ref.read(extensionProvider.notifier); @@ -340,18 +370,28 @@ class StoreNotifier extends Notifier { return success; } catch (e) { _log.e('Failed to install extension: $e'); - state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString()); + state = state.copyWith( + isDownloading: false, + clearDownloadingId: true, + error: e.toString(), + ); return false; } } - Future updateExtension(String extensionId, String tempDir) async { - state = state.copyWith(isDownloading: true, downloadingId: extensionId, clearError: true); + state = state.copyWith( + isDownloading: true, + downloadingId: extensionId, + clearError: true, + ); try { _log.i('Downloading update for: $extensionId'); - final downloadPath = await PlatformBridge.downloadStoreExtension(extensionId, tempDir); + final downloadPath = await PlatformBridge.downloadStoreExtension( + extensionId, + tempDir, + ); _log.i('Upgrading extension from: $downloadPath'); final extNotifier = ref.read(extensionProvider.notifier); @@ -366,7 +406,11 @@ class StoreNotifier extends Notifier { return success; } catch (e) { _log.e('Failed to update extension: $e'); - state = state.copyWith(isDownloading: false, clearDownloadingId: true, error: e.toString()); + state = state.copyWith( + isDownloading: false, + clearDownloadingId: true, + error: e.toString(), + ); return false; } } From 79a69f8f70ef68804cae8c46e63735c103a2f2ab Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 26 Mar 2026 16:42:40 +0700 Subject: [PATCH 18/33] chore: clean up codebase --- .../com/zarz/spotiflac/DownloadService.kt | 5 +- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 138 ++++-------------- go_backend/cover.go | 6 - go_backend/cue_parser.go | 10 -- go_backend/deezer_download.go | 1 - go_backend/exports.go | 2 - go_backend/extension_store.go | 3 - go_backend/httputil_utls.go | 2 - go_backend/library_scan.go | 16 +- go_backend/title_match_utils.go | 7 +- lib/providers/download_queue_provider.dart | 33 ----- lib/providers/explore_provider.dart | 10 +- lib/providers/extension_provider.dart | 20 +-- .../library_collections_provider.dart | 2 - lib/providers/local_library_provider.dart | 14 -- lib/providers/recent_access_provider.dart | 18 +-- lib/providers/store_provider.dart | 3 +- lib/providers/theme_provider.dart | 2 - lib/providers/track_provider.dart | 54 +++---- lib/screens/downloaded_album_screen.dart | 1 - lib/screens/local_album_screen.dart | 9 +- lib/screens/main_shell.dart | 1 - lib/screens/queue_tab.dart | 25 +--- .../settings/extension_detail_page.dart | 2 +- lib/screens/settings/extensions_page.dart | 4 +- .../settings/library_settings_page.dart | 8 +- lib/screens/settings/log_screen.dart | 2 +- .../settings/options_settings_page.dart | 2 +- lib/screens/setup_screen.dart | 8 +- lib/screens/track_metadata_screen.dart | 22 ++- lib/services/csv_import_service.dart | 4 +- lib/services/ffmpeg_service.dart | 7 - lib/services/platform_bridge.dart | 18 --- lib/services/share_intent_service.dart | 3 - lib/utils/clickable_metadata.dart | 8 - lib/widgets/audio_analysis_widget.dart | 24 --- lib/widgets/donate_icons.dart | 1 - 37 files changed, 80 insertions(+), 415 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt index fe9b8e07..f05542bc 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/DownloadService.kt @@ -137,14 +137,13 @@ class DownloadService : Service() { private fun startForegroundService() { isRunning = true - - // Acquire wake lock to prevent CPU sleep + val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager wakeLock = powerManager.newWakeLock( PowerManager.PARTIAL_WAKE_LOCK, WAKELOCK_TAG ).apply { - acquire(60 * 60 * 1000L) // 1 hour max + acquire(60 * 60 * 1000L) } val notification = buildNotification(0, 0) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index e282cc52..dbdd74ff 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -130,39 +130,35 @@ class MainActivity: FlutterFragmentActivity() { ) companion object { - // Minimum API level we consider "safe" for Impeller (Android 10+) private const val SAFE_API_FOR_IMPELLER = 29 - - // Known problematic GPU patterns (lowercase) + private val PROBLEMATIC_GPU_PATTERNS = listOf( - "adreno (tm) 3", // Adreno 300 series (305, 320, 330, etc.) - old Qualcomm - "adreno (tm) 4", // Adreno 400 series - some have issues - "mali-4", // Mali-400 series - old ARM GPUs - "mali-t6", // Mali-T600 series - "mali-t7", // Mali-T700 series (some) - "powervr sgx", // PowerVR SGX series - old Imagination GPUs - "powervr ge8320", // PowerVR GE8320 - known issues - "gc1000", // Vivante GC1000 - "gc2000", // Vivante GC2000 + "adreno (tm) 3", + "adreno (tm) 4", + "mali-4", + "mali-t6", + "mali-t7", + "powervr sgx", + "powervr ge8320", + "gc1000", + "gc2000", ) - - // Known problematic chipsets/hardware (lowercase) + private val PROBLEMATIC_CHIPSETS = listOf( - "mt6762", // MediaTek Helio P22 with PowerVR GE8320 - "mt6765", // MediaTek Helio P35 with PowerVR GE8320 - "mt8768", // MediaTek tablet chip - "mp0873", // MediaTek variant - "msm8974", // Snapdragon 800/801 with Adreno 330 - "msm8226", // Snapdragon 400 with Adreno 305 - "msm8926", // Snapdragon 400 with Adreno 305 - "apq8084", // Snapdragon 805 (some issues) + "mt6762", + "mt6765", + "mt8768", + "mp0873", + "msm8974", + "msm8226", + "msm8926", + "apq8084", ) - - // Known problematic device models (lowercase) + private val PROBLEMATIC_MODELS = listOf( - "sm-t220", // Samsung Tab A7 Lite - "sm-t225", // Samsung Tab A7 Lite LTE - "hammerhead", // Nexus 5 (Adreno 330) + "sm-t220", + "sm-t225", + "hammerhead", ) /** * Check if device should use Skia instead of Impeller. @@ -174,7 +170,6 @@ class MainActivity: FlutterFragmentActivity() { val model = Build.MODEL.lowercase(Locale.ROOT) val device = Build.DEVICE.lowercase(Locale.ROOT) - // 1. Check for explicitly problematic device models for (problematicModel in PROBLEMATIC_MODELS) { if (model.contains(problematicModel) || device.contains(problematicModel)) { android.util.Log.i("SpotiFLAC", "Matched problematic model: $problematicModel") @@ -182,7 +177,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // 2. Check for problematic chipsets for (chipset in PROBLEMATIC_CHIPSETS) { if (hardware.contains(chipset) || board.contains(chipset)) { android.util.Log.i("SpotiFLAC", "Matched problematic chipset: $chipset") @@ -190,12 +184,9 @@ class MainActivity: FlutterFragmentActivity() { } } - // 3. For Android < 10 (API 29), be more aggressive about disabling Impeller if (Build.VERSION.SDK_INT < SAFE_API_FOR_IMPELLER) { - // For older Android, check GPU renderer if available val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT) - // Check for known problematic GPUs for (pattern in PROBLEMATIC_GPU_PATTERNS) { if (gpuRenderer.contains(pattern)) { android.util.Log.i("SpotiFLAC", "Matched problematic GPU on old Android: $pattern") @@ -203,14 +194,12 @@ class MainActivity: FlutterFragmentActivity() { } } - // For very old Android (< 8.0), always use Skia as Vulkan support is spotty if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { android.util.Log.i("SpotiFLAC", "Android < 8.0, using Skia for safety") return true } } - // 4. For Android 10+, still check for known problematic GPUs val gpuRenderer = getGpuRenderer().lowercase(Locale.ROOT) for (pattern in PROBLEMATIC_GPU_PATTERNS) { if (gpuRenderer.contains(pattern)) { @@ -228,8 +217,6 @@ class MainActivity: FlutterFragmentActivity() { */ private fun getGpuRenderer(): String { return try { - // This might not work before GL context is created, - // but worth trying for additional detection android.opengl.GLES20.glGetString(android.opengl.GLES20.GL_RENDERER) ?: "" } catch (e: Exception) { "" @@ -632,7 +619,6 @@ class MainActivity: FlutterFragmentActivity() { * Resolve extension from a MediaStore URI by querying DISPLAY_NAME or MIME_TYPE. */ private fun resolveMediaStoreExt(uri: Uri, fallbackExt: String?): String { - // Try DISPLAY_NAME first try { contentResolver.query(uri, arrayOf(android.provider.MediaStore.MediaColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> if (cursor.moveToFirst()) { @@ -643,7 +629,6 @@ class MainActivity: FlutterFragmentActivity() { } } catch (_: Exception) {} - // Try MIME_TYPE try { val mime = contentResolver.getType(uri) val ext = extFromMimeType(mime) @@ -869,8 +854,6 @@ class MainActivity: FlutterFragmentActivity() { val mimeType = mimeTypeForExt(outputExt) val fileName = buildSafFileName(req, outputExt) - // Check for existing file WITHOUT creating the directory first. - // This prevents empty folders from being created for duplicate downloads. val existingDir = findDocumentDir(treeUri, relativeDir) if (existingDir != null) { val existing = existingDir.findFile(fileName) @@ -885,7 +868,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // Only create the directory now that we know we need to download val targetDir = ensureDocumentDir(treeUri, relativeDir) ?: return errorJson("Failed to access SAF directory") @@ -908,7 +890,6 @@ class MainActivity: FlutterFragmentActivity() { val respObj = JSONObject(response) if (respObj.optBoolean("success", false)) { // Extension providers write to a local temp path instead of the SAF FD. - // Copy the local file into the SAF document so it is not empty. val goFilePath = respObj.optString("file_path", "") if (goFilePath.isNotEmpty() && !goFilePath.startsWith("content://") && @@ -957,15 +938,10 @@ class MainActivity: FlutterFragmentActivity() { try { val docId = android.provider.DocumentsContract.getDocumentId(childUri) if (docId.isNullOrEmpty()) return null - - // Document IDs typically look like "primary:Music/Album/file.cue" - // Parent would be "primary:Music/Album" val lastSlash = docId.lastIndexOf('/') if (lastSlash <= 0) return null val parentDocId = docId.substring(0, lastSlash) - - // Build a tree document URI for the parent so it supports listing/findFile val treeDocId = android.provider.DocumentsContract.getTreeDocumentId(childUri) if (treeDocId.isNullOrEmpty()) return null @@ -990,21 +966,17 @@ class MainActivity: FlutterFragmentActivity() { val lines = File(cueTempPath).readLines() for (line in lines) { val trimmed = line.trim().let { l -> - // Strip BOM if (l.startsWith("\uFEFF")) l.removePrefix("\uFEFF").trim() else l } if (trimmed.uppercase(Locale.ROOT).startsWith("FILE ")) { val rest = trimmed.substring(5).trim() - // Parse: "filename" TYPE or filename TYPE val filename = if (rest.startsWith("\"")) { val endQuote = rest.indexOf('"', 1) if (endQuote > 0) rest.substring(1, endQuote) else rest } else { - // Last word is the type, everything else is the filename val parts = rest.split("\\s+".toRegex()) if (parts.size >= 2) parts.dropLast(1).joinToString(" ") else rest } - // Return just the filename (strip any path separators) return filename.substringAfterLast("/").substringAfterLast("\\") } } @@ -1089,7 +1061,6 @@ class MainActivity: FlutterFragmentActivity() { val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") val audioFiles = mutableListOf>() - // CUE files: (cueDoc, parentDir) — we need the parent to find sibling audio val cueFiles = mutableListOf>() val visitedDirUris = mutableSetOf() val safChildLookupCache = mutableMapOf>() @@ -1174,7 +1145,6 @@ class MainActivity: FlutterFragmentActivity() { var scanned = 0 var errors = traversalErrors - // --- CUE first pass: parse CUE sheets, expand to tracks, track referenced audio --- val cueReferencedAudioUris = mutableSetOf() for ((cueDoc, parentDir) in cueFiles) { @@ -1213,10 +1183,8 @@ class MainActivity: FlutterFragmentActivity() { continue } - // Mark this audio file so we skip it in the regular audio pass cueReferencedAudioUris.add(audioDoc.uri.toString()) - // Copy audio to same temp dir so Go can resolve it val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) @@ -1230,7 +1198,6 @@ class MainActivity: FlutterFragmentActivity() { continue } - // Rename temp audio to its original name so Go can find it by name val renamedAudio = File(tempDir, audioName) val tempAudioFile = File(tempAudioPath) if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { @@ -1273,14 +1240,12 @@ class MainActivity: FlutterFragmentActivity() { } } - // --- Regular audio file pass: skip files referenced by CUE sheets --- for ((doc, _) in audioFiles) { if (safScanCancel) { updateSafScanProgress { it.isComplete = true } return "[]" } - // Skip audio files that are represented by CUE track entries if (cueReferencedAudioUris.contains(doc.uri.toString())) { scanned++ val pct = scanned.toDouble() / totalItems.toDouble() * 100.0 @@ -1359,7 +1324,6 @@ class MainActivity: FlutterFragmentActivity() { return result.toString() } - // Parse existing files map: URI -> lastModified val existingFiles = mutableMapOf() try { val obj = JSONObject(existingFilesJson) @@ -1378,20 +1342,15 @@ class MainActivity: FlutterFragmentActivity() { } val supportedAudioExt = setOf(".flac", ".m4a", ".mp3", ".opus", ".ogg") - val audioFiles = mutableListOf>() // doc, path, lastModified - // CUE files to scan: (cueDoc, parentDir, lastModified) + val audioFiles = mutableListOf>() val cueFilesToScan = mutableListOf>() - // Unchanged CUE files: (cueDoc, parentDir) — need to discover audio siblings for skip set val unchangedCueFiles = mutableListOf>() val currentUris = mutableSetOf() val visitedDirUris = mutableSetOf() val safChildLookupCache = mutableMapOf>() var traversalErrors = 0 - // Build a map of CUE base URIs -> existing virtual track URIs from the database. - // Virtual paths look like "content://...album.cue#track01". - // We need this to preserve virtual paths for unchanged CUE files. - val existingCueVirtualPaths = mutableMapOf>() // cueUri -> [virtualPaths] + val existingCueVirtualPaths = mutableMapOf>() for (key in existingFiles.keys) { val hashIdx = key.indexOf("#track") if (hashIdx > 0) { @@ -1400,7 +1359,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // Collect all files with lastModified val queue: ArrayDeque> = ArrayDeque() queue.add(root to "") @@ -1456,8 +1414,6 @@ class MainActivity: FlutterFragmentActivity() { } queue.add(child to childPath) } else if (child.isFile) { - // Mark file as present first so it cannot be mis-classified as removed - // when provider-specific metadata calls (e.g., lastModified) fail. val uriStr = child.uri.toString() currentUris.add(uriStr) @@ -1469,18 +1425,15 @@ class MainActivity: FlutterFragmentActivity() { child.lastModified() } catch (_: Exception) { 0L } - // Check if any virtual track from this CUE exists with matching modTime val virtualPaths = existingCueVirtualPaths[uriStr] val existingModified = virtualPaths?.firstOrNull()?.let { existingFiles[it] } if (existingModified != null && existingModified == lastModified) { - // CUE is unchanged — mark virtual paths as current so they aren't removed unchangedCueFiles.add(child to dir) for (vp in virtualPaths) { currentUris.add(vp) } } else { - // CUE is new or modified — needs scanning cueFilesToScan.add(Triple(child, dir, lastModified)) } } else if (ext.isNotBlank() && supportedAudioExt.contains(".$ext")) { @@ -1491,7 +1444,6 @@ class MainActivity: FlutterFragmentActivity() { existingModified ?: 0L } - // Check if file is new or modified if (existingModified == null || existingModified != lastModified) { audioFiles.add(Triple(child, path, lastModified)) } @@ -1508,7 +1460,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // Find removed files (in existing but not in current) val removedUris = existingFiles.keys.filter { !currentUris.contains(it) } val totalFiles = currentUris.size val filesToProcess = audioFiles.size + cueFilesToScan.size @@ -1536,7 +1487,6 @@ class MainActivity: FlutterFragmentActivity() { var scanned = 0 var errors = traversalErrors - // --- CUE first pass: parse new/modified CUE sheets --- val cueReferencedAudioUris = mutableSetOf() for ((cueDoc, parentDir, cueLastModified) in cueFilesToScan) { @@ -1557,7 +1507,6 @@ class MainActivity: FlutterFragmentActivity() { var tempCuePath: String? = null var tempAudioPath: String? = null try { - // Copy CUE to temp tempCuePath = copyUriToTemp(cueDoc.uri, ".cue") if (tempCuePath == null) { errors++ @@ -1566,10 +1515,8 @@ class MainActivity: FlutterFragmentActivity() { continue } - // Extract the audio filename from the CUE sheet text val audioFileName = extractCueAudioFileName(tempCuePath) - // Find the referenced audio file as a sibling in the same SAF directory val audioDoc = resolveCueAudioSibling( parentDir = parentDir, cueName = cueName, @@ -1584,10 +1531,8 @@ class MainActivity: FlutterFragmentActivity() { continue } - // Mark this audio file so we skip it in the regular audio pass cueReferencedAudioUris.add(audioDoc.uri.toString()) - // Copy audio to same temp dir so Go can resolve it val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) @@ -1601,7 +1546,6 @@ class MainActivity: FlutterFragmentActivity() { continue } - // Rename temp audio to its original name so Go can find it by name val renamedAudio = File(tempDir, audioName) val tempAudioFile = File(tempAudioPath) if (renamedAudio.absolutePath != tempAudioFile.absolutePath) { @@ -1609,7 +1553,6 @@ class MainActivity: FlutterFragmentActivity() { tempAudioPath = renamedAudio.absolutePath } - // Call Go to produce library scan entries for each CUE track val cueResultsJson = Gobackend.scanCueSheetForLibrary( tempCuePath, tempDir, @@ -1621,7 +1564,6 @@ class MainActivity: FlutterFragmentActivity() { for (j in 0 until cueArray.length()) { val trackObj = cueArray.getJSONObject(j) results.put(trackObj) - // Register each virtual path as current so deletion detection works val virtualPath = trackObj.optString("filePath", "") if (virtualPath.isNotBlank()) { currentUris.add(virtualPath) @@ -1654,9 +1596,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // Discover audio siblings for unchanged CUE files so we skip them - // in the regular audio pass. Copy the .cue to temp (tiny file) to extract - // the audio filename, then find the sibling by name. for ((cueDoc, parentDir) in unchangedCueFiles) { var tempCue: String? = null try { @@ -1681,7 +1620,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // --- Regular audio file pass: skip files referenced by CUE sheets --- for ((doc, _, lastModified) in audioFiles) { if (safScanCancel) { updateSafScanProgress { it.isComplete = true } @@ -1694,7 +1632,6 @@ class MainActivity: FlutterFragmentActivity() { return result.toString() } - // Skip audio files that are represented by CUE track entries if (cueReferencedAudioUris.contains(doc.uri.toString())) { scanned++ val processed = skippedCount + scanned @@ -1748,7 +1685,6 @@ class MainActivity: FlutterFragmentActivity() { } } - // Recalculate removedUris now that CUE virtual paths have been registered val finalRemovedUris = existingFiles.keys.filter { !currentUris.contains(it) } updateSafScanProgress { @@ -1926,7 +1862,6 @@ class MainActivity: FlutterFragmentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - // Update the intent so receive_sharing_intent can access the new data setIntent(intent) } @@ -2586,7 +2521,6 @@ class MainActivity: FlutterFragmentActivity() { val tempPath = copyUriToTemp(uri) ?: return@withContext """{"error":"Failed to copy SAF file to temp"}""" try { - // Replace file_path with temp path for Go reqObj.put("file_path", tempPath) val raw = Gobackend.reEnrichFile(reqObj.toString()) val obj = JSONObject(raw) @@ -2664,7 +2598,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } - // Deezer API methods "searchDeezerAll" -> { val query = call.argument("query") ?: "" val trackLimit = call.argument("track_limit") ?: 15 @@ -2675,7 +2608,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Tidal search API "searchTidalAll" -> { val query = call.argument("query") ?: "" val trackLimit = call.argument("track_limit") ?: 15 @@ -2686,7 +2618,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Qobuz search API "searchQobuzAll" -> { val query = call.argument("query") ?: "" val trackLimit = call.argument("track_limit") ?: 15 @@ -2816,7 +2747,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Log methods "getLogs" -> { val response = withContext(Dispatchers.IO) { Gobackend.getLogs() @@ -2849,7 +2779,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } - // Extension System methods "initExtensionSystem" -> { val extensionsDir = call.argument("extensions_dir") ?: "" val dataDir = call.argument("data_dir") ?: "" @@ -2994,7 +2923,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } - // Extension Auth API methods "getExtensionPendingAuth" -> { val extensionId = call.argument("extension_id") ?: "" val response = withContext(Dispatchers.IO) { @@ -3044,7 +2972,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Extension FFmpeg API "getPendingFFmpegCommand" -> { val commandId = call.argument("command_id") ?: "" val response = withContext(Dispatchers.IO) { @@ -3072,7 +2999,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Extension Custom Search API "customSearchWithExtension" -> { val extensionId = call.argument("extension_id") ?: "" val query = call.argument("query") ?: "" @@ -3088,7 +3014,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Extension URL Handler API "handleURLWithExtension" -> { val url = call.argument("url") ?: "" val response = withContext(Dispatchers.IO) { @@ -3133,7 +3058,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Extension Post-Processing API "runPostProcessing" -> { val filePath = call.argument("file_path") ?: "" val metadataJson = call.argument("metadata") ?: "" @@ -3177,7 +3101,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Extension Store "initExtensionStore" -> { val cacheDir = call.argument("cache_dir") ?: "" withContext(Dispatchers.IO) { @@ -3239,7 +3162,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(null) } - // Extension Home Feed (Explore) "getExtensionHomeFeed" -> { val extensionId = call.argument("extension_id") ?: "" val response = withContext(Dispatchers.IO) { @@ -3254,7 +3176,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // Local Library Scanning "setLibraryCoverCacheDir" -> { val cacheDir = call.argument("cache_dir") ?: "" withContext(Dispatchers.IO) { @@ -3359,7 +3280,6 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } - // CUE Sheet Parsing "parseCueSheet" -> { val cuePath = call.argument("cue_path") ?: "" val audioDir = call.argument("audio_dir") ?: "" @@ -3371,17 +3291,14 @@ class MainActivity: FlutterFragmentActivity() { ?: return@withContext """{"error":"Failed to copy CUE file to temp"}""" var tempAudioPath: String? = null try { - // Extract audio filename from CUE text val audioFileName = extractCueAudioFileName(tempCuePath) - // Try to find the audio sibling in SAF var audioDoc: DocumentFile? = null val parentDir = safParentDir(uri) if (parentDir != null && !audioFileName.isNullOrBlank()) { audioDoc = try { parentDir.findFile(audioFileName) } catch (_: Exception) { null } } - // Fallback: try common extensions with the CUE base name if (audioDoc == null && parentDir != null) { val cueName = try { DocumentFile.fromSingleUri(this@MainActivity, uri)?.name ?: "" @@ -3400,7 +3317,6 @@ class MainActivity: FlutterFragmentActivity() { val tempDir = File(tempCuePath).parent ?: cacheDir.absolutePath if (audioDoc != null) { - // Copy audio to same temp dir with original name val audioName = try { audioDoc.name ?: "audio.flac" } catch (_: Exception) { "audio.flac" } val audioExt = audioName.substringAfterLast('.', "").lowercase(Locale.ROOT) val fallbackExt = if (audioExt.isNotBlank()) ".$audioExt" else null @@ -3415,15 +3331,11 @@ class MainActivity: FlutterFragmentActivity() { } } - // Parse with audio in temp dir; Go will resolve there val resultJson = Gobackend.parseCueSheet(tempCuePath, tempDir) - // Replace the temp audio_path with the SAF content:// URI - // so Dart knows it's a SAF file and handles it accordingly if (audioDoc != null) { val resultObj = JSONObject(resultJson) resultObj.put("audio_path", audioDoc.uri.toString()) - // Also pass the original CUE URI for reference resultObj.put("cue_path", cuePath) resultObj.toString() } else { diff --git a/go_backend/cover.go b/go_backend/cover.go index 02d1f03f..a368fb8f 100644 --- a/go_backend/cover.go +++ b/go_backend/cover.go @@ -42,7 +42,6 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { maxURL := upgradeToMaxQuality(downloadURL) if maxURL != downloadURL { downloadURL = maxURL - // Log already printed by upgradeToMaxQuality for Deezer if strings.Contains(coverURL, "scdn.co") || strings.Contains(coverURL, "spotifycdn") { GoLog("[Cover] Spotify: upgraded to max resolution (~2000x2000)") } @@ -88,22 +87,18 @@ func downloadCoverToMemory(coverURL string, maxQuality bool) ([]byte, error) { } func upgradeToMaxQuality(coverURL string) string { - // Spotify CDN upgrade if strings.Contains(coverURL, spotifySize640) { return strings.Replace(coverURL, spotifySize640, spotifySizeMax, 1) } - // Deezer CDN upgrade if strings.Contains(coverURL, "cdn-images.dzcdn.net") { return upgradeDeezerCover(coverURL) } - // Tidal CDN upgrade: 1280x1280 → origin if strings.Contains(coverURL, "resources.tidal.com") { return upgradeTidalCover(coverURL) } - // Qobuz CDN upgrade: _600 → _max if strings.Contains(coverURL, "static.qobuz.com") { return upgradeQobuzCover(coverURL) } @@ -152,7 +147,6 @@ func GetCoverFromSpotify(imageURL string, maxQuality bool) string { return "" } - // Always upgrade small to medium first result := convertSmallToMedium(imageURL) if maxQuality { diff --git a/go_backend/cue_parser.go b/go_backend/cue_parser.go index 8cbf1f61..1a0dfa06 100644 --- a/go_backend/cue_parser.go +++ b/go_backend/cue_parser.go @@ -13,7 +13,6 @@ import ( // CueSheet represents a parsed .cue file type CueSheet struct { - // Album-level metadata Performer string `json:"performer"` Title string `json:"title"` FileName string `json:"file_name"` @@ -32,7 +31,6 @@ type CueTrack struct { Performer string `json:"performer"` ISRC string `json:"isrc,omitempty"` Composer string `json:"composer,omitempty"` - // Index positions in seconds (fractional) StartTime float64 `json:"start_time"` // INDEX 01 in seconds PreGap float64 `json:"pre_gap"` // INDEX 00 in seconds (or -1 if not present) } @@ -82,7 +80,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) { continue } - // Handle BOM at start of file if strings.HasPrefix(line, "\xef\xbb\xbf") { line = strings.TrimPrefix(line, "\xef\xbb\xbf") line = strings.TrimSpace(line) @@ -90,7 +87,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) { upper := strings.ToUpper(line) - // REM commands (album-level metadata) if strings.HasPrefix(upper, "REM ") { matches := reRemCommand.FindStringSubmatch(line) if len(matches) == 3 { @@ -136,9 +132,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) { if strings.HasPrefix(upper, "FILE ") { rest := line[len("FILE "):] - // Extract filename and type - // Format: FILE "filename.flac" WAVE - // or: FILE filename.flac WAVE fname, ftype := parseCueFileLine(rest) sheet.FileName = fname sheet.FileType = ftype @@ -146,7 +139,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) { } if strings.HasPrefix(upper, "TRACK ") { - // Save previous track if currentTrack != nil { sheet.Tracks = append(sheet.Tracks, *currentTrack) } @@ -184,7 +176,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) { continue } - // SONGWRITER (used as composer sometimes) if strings.HasPrefix(upper, "SONGWRITER ") { value := unquoteCue(line[len("SONGWRITER "):]) if currentTrack != nil { @@ -196,7 +187,6 @@ func ParseCueFile(cuePath string) (*CueSheet, error) { } } - // Don't forget the last track if currentTrack != nil { sheet.Tracks = append(sheet.Tracks, *currentTrack) } diff --git a/go_backend/deezer_download.go b/go_backend/deezer_download.go index 974ffcf6..fdd0850d 100644 --- a/go_backend/deezer_download.go +++ b/go_backend/deezer_download.go @@ -319,7 +319,6 @@ func (c *DeezerClient) GetMusicDLDownloadURL(deezerTrackURL string) (string, err return "", fmt.Errorf("MusicDL error: %s", errMsg) } - // Try various response fields for download URL for _, key := range []string{"download_url", "url", "link"} { if urlVal, ok := raw[key].(string); ok && strings.TrimSpace(urlVal) != "" { return strings.TrimSpace(urlVal), nil diff --git a/go_backend/exports.go b/go_backend/exports.go index 830f11bb..f104e763 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1906,7 +1906,6 @@ func ReEnrichFile(requestJSON string) (string, error) { } } - // Log metadata summary before embedding GoLog("[ReEnrich] Metadata to embed: title=%s, artist=%s, album=%s, albumArtist=%s\n", req.TrackName, req.ArtistName, req.AlbumName, req.AlbumArtist) GoLog("[ReEnrich] track=%d, disc=%d, date=%s, isrc=%s, genre=%s, label=%s\n", @@ -1989,7 +1988,6 @@ func ReEnrichFile(requestJSON string) (string, error) { } } - // Build enriched metadata response for Dart (includes online search results) enrichedMeta := map[string]interface{}{ "track_name": req.TrackName, "artist_name": req.ArtistName, diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index ed8915ab..6378139a 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -236,7 +236,6 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error s.cacheMu.Lock() defer s.cacheMu.Unlock() - // Check if a registry URL has been configured if s.registryURL == "" { return nil, fmt.Errorf("no registry URL configured. Please add a repository URL first") } @@ -396,7 +395,6 @@ func ResolveRegistryURL(input string) (string, error) { return input, nil } - // Try to match https://github.com//[/...] const ghPrefix = "https://github.com/" if !strings.HasPrefix(input, ghPrefix) { // Also accept http:// and upgrade silently. @@ -500,7 +498,6 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*Sto !containsIgnoreCase(ext.DisplayName, queryLower) && !containsIgnoreCase(ext.Description, queryLower) && !containsIgnoreCase(ext.Author, queryLower) { - // Check tags found := false for _, tag := range ext.Tags { if containsIgnoreCase(tag, queryLower) { diff --git a/go_backend/httputil_utls.go b/go_backend/httputil_utls.go index 4b09deb7..c3a90670 100644 --- a/go_backend/httputil_utls.go +++ b/go_backend/httputil_utls.go @@ -112,7 +112,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { resp, err := sharedClient.Do(req) if err == nil { - // Check for Cloudflare challenge page (403 with specific markers) if resp.StatusCode == 403 || resp.StatusCode == 503 { body, readErr := io.ReadAll(resp.Body) resp.Body.Close() @@ -154,7 +153,6 @@ func DoRequestWithCloudflareBypass(req *http.Request) (*http.Response, error) { return resp, nil } - // Check if error might be TLS-related (Cloudflare blocking) errStr := strings.ToLower(err.Error()) tlsRelated := strings.Contains(errStr, "tls") || strings.Contains(errStr, "handshake") || diff --git a/go_backend/library_scan.go b/go_backend/library_scan.go index ee8874d1..c56b57d9 100644 --- a/go_backend/library_scan.go +++ b/go_backend/library_scan.go @@ -234,8 +234,6 @@ func ScanLibraryFolder(folderPath string) (string, error) { continue } - // Skip audio files that are referenced by a .cue sheet - // (they will be represented by the cue sheet's track entries instead) if cueReferencedAudioFiles[filePath] { GoLog("[LibraryScan] Skipping %s (referenced by .cue sheet)\n", filepath.Base(filePath)) continue @@ -557,9 +555,6 @@ func ReadAudioMetadataWithDisplayName(filePath, displayNameHint string) (string, return string(jsonBytes), nil } -// ScanLibraryFolderIncremental performs an incremental scan of the library folder -// existingFilesJSON is a JSON object mapping filePath -> modTime (unix millis) -// Only files that are new or have changed modification time will be scanned func loadExistingFilesSnapshot(snapshotPath string) (map[string]int64, error) { existingFiles := make(map[string]int64) if snapshotPath == "" { @@ -637,7 +632,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi libraryScanProgress.TotalFiles = totalFiles libraryScanProgressMu.Unlock() - // Find files to scan (new or modified) var filesToScan []libraryAudioFileInfo skippedCount := 0 existingCueTrackModTimes := make(map[string]int64) @@ -653,10 +647,8 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi for _, f := range currentFiles { existingModTime, exists := existingFiles[f.path] if !exists { - // For .cue files, also check if any virtual path entries exist if strings.ToLower(filepath.Ext(f.path)) == ".cue" { if cueTrackModTime, hasCueTracks := existingCueTrackModTimes[f.path]; hasCueTracks { - // CUE file exists in DB via virtual paths; check if modTime changed if f.modTime == cueTrackModTime { skippedCount++ } else { @@ -675,14 +667,11 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi var deletedPaths []string for existingPath := range existingFiles { - // For CUE virtual paths (e.g. "/path/album.cue#track01"), - // check if the base .cue file still exists on disk if idx := strings.LastIndex(existingPath, "#track"); idx > 0 { baseCuePath := existingPath[:idx] if currentPathSet[baseCuePath] { - continue // Base .cue file still exists, not deleted + continue } - // Base CUE file is gone, mark virtual path as deleted deletedPaths = append(deletedPaths, existingPath) } else if !currentPathSet[existingPath] { deletedPaths = append(deletedPaths, existingPath) @@ -713,7 +702,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi scanTime := time.Now().UTC().Format(time.RFC3339) errorCount := 0 - // Track audio files referenced by .cue sheets to avoid duplicates (incremental) cueReferencedAudioFilesInc := make(map[string]bool) parsedCueFiles := make(map[string]scannedCueFileInfo) for _, f := range filesToScan { @@ -748,7 +736,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi ext := strings.ToLower(filepath.Ext(f.path)) - // Handle .cue files: produce multiple track results if ext == ".cue" { var cueResults []LibraryScanResult cueInfo, ok := parsedCueFiles[f.path] @@ -773,7 +760,6 @@ func scanLibraryFolderIncrementalWithExistingFiles(folderPath string, existingFi continue } - // Skip audio files referenced by .cue sheets if cueReferencedAudioFilesInc[f.path] { continue } diff --git a/go_backend/title_match_utils.go b/go_backend/title_match_utils.go index cecf462d..b5089065 100644 --- a/go_backend/title_match_utils.go +++ b/go_backend/title_match_utils.go @@ -24,11 +24,9 @@ func normalizeLooseTitle(title string) string { b.WriteRune(r) case unicode.IsSpace(r): b.WriteByte(' ') - // Treat common separators as spaces. case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': b.WriteByte(' ') default: - // Drop other punctuation/symbols (including emoji) for loose matching. } } @@ -59,7 +57,6 @@ func normalizeLooseArtistName(name string) string { case r == '/', r == '\\', r == '_', r == '-', r == '|', r == '.', r == '&', r == '+': b.WriteByte(' ') default: - // Drop remaining punctuation/symbols for loose artist matching. } } @@ -102,13 +99,11 @@ func normalizeSymbolOnlyTitle(title string) string { return b.String() } -// ==================== Shared Track Verification ==================== - // resolvedTrackInfo holds the metadata fetched from a provider for verification. type resolvedTrackInfo struct { Title string ArtistName string - Duration int // seconds + Duration int } // trackMatchesRequest checks whether a resolved track from a provider matches diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 57f0e1f3..14693e35 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -874,10 +874,6 @@ class DownloadHistoryNotifier extends Notifier { await _db.upsert(updated.toJson()); } - /// Remove history entries where the file no longer exists on disk. - /// Returns the number of orphaned entries removed. - - /// Audio file extensions that the app commonly produces or converts between. static const _audioExtensions = [ '.flac', '.m4a', @@ -888,9 +884,6 @@ class DownloadHistoryNotifier extends Notifier { '.aac', ]; - /// When the original file is missing, check whether a sibling with a - /// different audio extension exists (e.g. the user converted .flac → .opus). - /// Returns the path of the first match found, or `null` if none exist. Future _findConvertedSibling(String originalPath) async { final dotIndex = originalPath.lastIndexOf('.'); if (dotIndex < 0) return null; @@ -2711,7 +2704,6 @@ class DownloadQueueNotifier extends Notifier { static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$'); String _upgradeToMaxQualityCover(String coverUrl) { - // Spotify CDN upgrade (hash-based size identifiers) const spotifySize300 = 'ab67616d00001e02'; const spotifySize640 = 'ab67616d0000b273'; const spotifySizeMax = 'ab67616d000082c1'; @@ -2724,7 +2716,6 @@ class DownloadQueueNotifier extends Notifier { result = result.replaceFirst(spotifySize640, spotifySizeMax); } - // Deezer CDN upgrade (1000x1000 → 1800x1800) if (result.contains('cdn-images.dzcdn.net')) { final upgraded = result.replaceFirst( _deezerSizeRegex, @@ -3405,7 +3396,6 @@ class DownloadQueueNotifier extends Notifier { Future _processQueue() async { if (state.isProcessing) return; - // Check network connectivity before starting final settings = ref.read(settingsProvider); updateSettings(settings); final isSafMode = _isSafMode(settings); @@ -3465,7 +3455,6 @@ class DownloadQueueNotifier extends Notifier { state = state.copyWith(outputDir: musicDir.path); ref.read(settingsProvider.notifier).setDownloadDirectory(musicDir.path); } else if (!isValidIosWritablePath(state.outputDir)) { - // Check for other invalid paths (like container root without Documents/) _log.w( 'iOS: Invalid output path detected (container root?), falling back to app Documents folder', ); @@ -3487,7 +3476,6 @@ class DownloadQueueNotifier extends Notifier { _log.d('Output directory: ${state.outputDir}'); } else { _log.d('Output directory: SAF (tree_uri=${settings.downloadTreeUri})'); - // Validate SAF permission is still accessible try { final testResult = await PlatformBridge.createSafFileFromPath( treeUri: settings.downloadTreeUri, @@ -3496,16 +3484,12 @@ class DownloadQueueNotifier extends Notifier { mimeType: 'application/octet-stream', srcPath: '', ); - // If we got a result, permission is valid (file creation may fail but that's ok) - // If permission is revoked, this will throw if (testResult != null) { - // Clean up test file await PlatformBridge.safDelete(testResult); } } catch (e) { _log.e('SAF permission validation failed: $e'); _log.w('SAF tree URI may be invalid or permission revoked'); - // Mark all queued items as failed for (final item in state.items) { if (item.status == DownloadStatus.queued) { updateItemStatus( @@ -3639,8 +3623,6 @@ class DownloadQueueNotifier extends Notifier { } if (activeDownloads.isNotEmpty) { - // Re-check queue/settings periodically so concurrency changes - // (e.g. 1 -> 3) can take effect before any active item finishes. await Future.any([ Future.any(activeDownloads.values), Future.delayed(_queueSchedulingInterval), @@ -3926,7 +3908,6 @@ class DownloadQueueNotifier extends Notifier { if (resolvedIsrc != null && _isValidISRC(resolvedIsrc)) { _log.d('Resolved ISRC from $provider: $resolvedIsrc'); - // Enrich track with provider metadata final provReleaseDate = normalizeOptionalString( trackData['release_date'] as String?, ); @@ -3962,7 +3943,6 @@ class DownloadQueueNotifier extends Notifier { source: trackToDownload.source, ); - // Search Deezer by the resolved ISRC try { final deezerResult = await PlatformBridge.searchDeezerByISRC( resolvedIsrc, @@ -3988,9 +3968,6 @@ class DownloadQueueNotifier extends Notifier { } } - // Fallback: Use SongLink to convert Spotify ID to Deezer ID - // Skip for tidal:/qobuz: IDs – they are not Spotify URLs and the - // provider ISRC resolution above already handles them. if (!selectedExtensionDownloadProvider && deezerTrackId == null && !shouldSkipExtensionSongLinkPrelookup && @@ -4011,7 +3988,6 @@ class DownloadQueueNotifier extends Notifier { 'track', spotifyId, ); - // Response is TrackResponse: {"track": {"spotify_id": "deezer:XXXXX", ...}} final trackData = deezerData['track']; if (trackData is Map) { final rawId = trackData['spotify_id'] as String?; @@ -4317,7 +4293,6 @@ class DownloadQueueNotifier extends Notifier { finalSafFileName = reportedFileName; } - // Check if file already existed (detected via ISRC match in Go backend) final wasExisting = result['already_exists'] == true; if (wasExisting) { _log.i('File already exists in library: $filePath'); @@ -4330,7 +4305,6 @@ class DownloadQueueNotifier extends Notifier { String actualQuality = quality; if (actualBitDepth != null && actualBitDepth > 0) { - // Format: "24-bit/96kHz" or "16-bit/44.1kHz" final sampleRateKHz = actualSampleRate != null && actualSampleRate > 0 ? (actualSampleRate / 1000).toStringAsFixed( actualSampleRate % 1000 == 0 ? 0 : 1, @@ -4486,7 +4460,6 @@ class DownloadQueueNotifier extends Notifier { } if (isM4aFile || shouldForceTidalSafM4aHandling) { - // At this point filePath is guaranteed non-null by the checks above. final currentFilePath = filePath; if (isContentUriPath && effectiveSafMode) { @@ -4979,9 +4952,6 @@ class DownloadQueueNotifier extends Notifier { return; } - // SAF downloads should end with content URI. If we still have a - // transient FD path, recover URI from SAF metadata to keep history - // dedup/exclusion stable. if (effectiveSafMode && filePath != null && filePath.isNotEmpty && @@ -5296,8 +5266,6 @@ class DownloadQueueNotifier extends Notifier { ); _failedInSession++; - // Immediately cleanup connections after failure to prevent - // poisoned connection pool from affecting subsequent downloads try { await PlatformBridge.cleanupConnections(); } catch (e) { @@ -5350,7 +5318,6 @@ class DownloadQueueNotifier extends Notifier { ); _failedInSession++; - // Immediately cleanup connections after exception try { await PlatformBridge.cleanupConnections(); } catch (cleanupErr) { diff --git a/lib/providers/explore_provider.dart b/lib/providers/explore_provider.dart index e4ffa5f7..f55892fb 100644 --- a/lib/providers/explore_provider.dart +++ b/lib/providers/explore_provider.dart @@ -11,7 +11,7 @@ final _log = AppLogger('ExploreProvider'); class ExploreItem { final String id; final String uri; - final String type; // track, album, playlist, artist, station + final String type; final String name; final String artists; final String? description; @@ -168,7 +168,6 @@ class ExploreNotifier extends Notifier { return const ExploreState(); } - /// Restore cached home feed from SharedPreferences immediately on startup Future _restoreFromCache() async { try { final prefs = await SharedPreferences.getInstance(); @@ -199,7 +198,6 @@ class ExploreNotifier extends Notifier { } } - /// Save home feed to SharedPreferences for instant restore on next launch Future _saveToCache(List sections) async { try { final prefs = await SharedPreferences.getInstance(); @@ -212,11 +210,9 @@ class ExploreNotifier extends Notifier { } } - /// Fetch home feed from spotify-web extension Future fetchHomeFeed({bool forceRefresh = false}) async { _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh'); - // If we have cached content and it's fresh enough, skip network fetch if (!forceRefresh && state.hasContent && state.lastFetched != null && @@ -230,7 +226,6 @@ class ExploreNotifier extends Notifier { return; } - // Only show loading spinner if we have no cached content to display final showLoading = !state.hasContent; state = state.copyWith(isLoading: showLoading, error: null); @@ -247,14 +242,12 @@ class ExploreNotifier extends Notifier { if (!extension.enabled || !extension.hasHomeFeed) { continue; } - // If user has a preference, use that if (preferredId != null && preferredId.isNotEmpty && extension.id == preferredId) { targetExt = extension; break; } - // Otherwise take the first available (fallback to spotify-web if found) if (targetExt == null || extension.id == 'spotify-web') { targetExt = extension; if (preferredId == null && extension.id == 'spotify-web') { @@ -317,7 +310,6 @@ class ExploreNotifier extends Notifier { lastFetched: DateTime.now(), ); - // Save to disk cache for instant restore on next app launch _saveToCache(sections); } catch (e, stack) { _log.e('Error fetching home feed: $e', e, stack); diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 43f25997..3ea5ba7a 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -32,14 +32,12 @@ class Extension { final bool hasMetadataProvider; final bool hasDownloadProvider; final bool hasLyricsProvider; - final bool - skipMetadataEnrichment; // If true, use metadata from extension instead of enriching + final bool skipMetadataEnrichment; final SearchBehavior? searchBehavior; final URLHandler? urlHandler; final TrackMatching? trackMatching; final PostProcessing? postProcessing; - final Map - capabilities; // Extension capabilities (homeFeed, browseCategories, etc.) + final Map capabilities; const Extension({ required this.id, @@ -198,12 +196,10 @@ class SearchBehavior { final String? placeholder; final bool primary; final String? icon; - final String? - thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3) + final String? thumbnailRatio; final int? thumbnailWidth; final int? thumbnailHeight; - final List - filters; // Available search filters (e.g., track, album, artist, playlist) + final List filters; const SearchBehavior({ required this.enabled, @@ -239,11 +235,11 @@ class SearchBehavior { } switch (thumbnailRatio) { - case 'wide': // 16:9 - YouTube style + case 'wide': return (defaultSize * 16 / 9, defaultSize); - case 'portrait': // 2:3 - Poster style + case 'portrait': return (defaultSize * 2 / 3, defaultSize); - case 'square': // 1:1 - Album art style + case 'square': default: return (defaultSize, defaultSize); } @@ -290,7 +286,6 @@ class PostProcessing { } } -/// URL handler configuration for custom URL patterns class URLHandler { final bool enabled; final List patterns; @@ -304,7 +299,6 @@ class URLHandler { ); } - /// Check if a URL matches any of the patterns bool matchesURL(String url) { if (!enabled || patterns.isEmpty) return false; final lowerUrl = url.toLowerCase(); diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index a7a3e373..0fa89735 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -666,7 +666,6 @@ class LibraryCollectionsNotifier extends Notifier { final destPath = p.join(coversDir.path, '$playlistId$ext'); if (playlist.coverImagePath == destPath) return; - // Copy image to persistent location await File(sourceFilePath).copy(destPath); final now = DateTime.now(); @@ -686,7 +685,6 @@ class LibraryCollectionsNotifier extends Notifier { final playlist = state.playlistById(playlistId); if (playlist == null || playlist.coverImagePath == null) return; - // Delete the file if it exists final path = playlist.coverImagePath; if (path != null) { final file = File(path); diff --git a/lib/providers/local_library_provider.dart b/lib/providers/local_library_provider.dart index f63613c5..3e90f0fc 100644 --- a/lib/providers/local_library_provider.dart +++ b/lib/providers/local_library_provider.dart @@ -252,8 +252,6 @@ class LocalLibraryNotifier extends Notifier { _startProgressPolling(); - // On iOS, start accessing the security-scoped bookmark so the Go backend - // can read files outside the app sandbox. String? resolvedPath; bool didStartSecurityAccess = false; if (Platform.isIOS && iosBookmark != null && iosBookmark.isNotEmpty) { @@ -275,9 +273,6 @@ class LocalLibraryNotifier extends Notifier { try { final isSaf = effectiveFolderPath.startsWith('content://'); - // Get all file paths from download history to exclude them. - // Merge DB + in-memory state to avoid race when a fresh download has not - // been flushed to SQLite yet. final downloadedPaths = await _historyDb.getAllFilePaths(); final inMemoryHistoryPaths = ref .read(downloadHistoryProvider) @@ -298,7 +293,6 @@ class LocalLibraryNotifier extends Notifier { ); if (forceFullScan) { - // Full scan path - ignores existing data final results = isSaf ? await PlatformBridge.scanSafTree(effectiveFolderPath) : await PlatformBridge.scanLibraryFolder(effectiveFolderPath); @@ -324,7 +318,6 @@ class LocalLibraryNotifier extends Notifier { _log.i('Skipped $skippedDownloads files already in download history'); } - // Full scan should replace library index atomically. await _db.replaceAll(items.map((e) => e.toJson()).toList()); final persistedItems = [...items]..sort(_compareLibraryItems); @@ -357,7 +350,6 @@ class LocalLibraryNotifier extends Notifier { errorCount: state.scanErrorCount, ); } else { - // Incremental scan path - only scans new/modified files final existingFiles = await _db.getFileModTimes(); _log.i( 'Incremental scan: ${existingFiles.length} existing files in database', @@ -416,7 +408,6 @@ class LocalLibraryNotifier extends Notifier { return; } - // SAF returns 'files' and 'removedUris', non-SAF returns 'scanned' and 'deletedPaths' final scannedList = (result['files'] as List?) ?? (result['scanned'] as List?) ?? @@ -437,10 +428,6 @@ class LocalLibraryNotifier extends Notifier { '$skippedCount skipped, ${deletedPaths.length} deleted, $totalFiles total', ); - // Build the incremental merge base from SQLite, not the current - // provider state. Startup auto-scan can fire before `state.items` has - // finished loading, which would otherwise drop unchanged rows from the - // in-memory library until a manual full rescan. final existingJson = await _db.getAll(); final currentByPath = { for (final item in existingJson.map(LocalLibraryItem.fromJson)) @@ -461,7 +448,6 @@ class LocalLibraryNotifier extends Notifier { ); } - // Upsert new/modified items (excluding downloaded files) final updatedItems = []; int skippedDownloads = existingDownloadedPaths.length; if (scannedList.isNotEmpty) { diff --git a/lib/providers/recent_access_provider.dart b/lib/providers/recent_access_provider.dart index 7b9cc15a..af5e4387 100644 --- a/lib/providers/recent_access_provider.dart +++ b/lib/providers/recent_access_provider.dart @@ -5,18 +5,16 @@ import 'package:spotiflac_android/services/app_state_database.dart'; const _maxRecentItems = 20; -/// Types of items that can be accessed enum RecentAccessType { artist, album, track, playlist } -/// Represents a recently accessed item class RecentAccessItem { final String id; final String name; - final String? subtitle; // Artist name for tracks/albums, null for artists + final String? subtitle; final String? imageUrl; final RecentAccessType type; final DateTime accessedAt; - final String? providerId; // Extension ID or 'deezer' for built-in + final String? providerId; const RecentAccessItem({ required this.id, @@ -53,7 +51,6 @@ class RecentAccessItem { ); } - /// Create a unique key for deduplication String get uniqueKey => '${type.name}:${providerId ?? 'default'}:$id'; @override @@ -67,7 +64,6 @@ class RecentAccessItem { int get hashCode => uniqueKey.hashCode; } -/// State for recent access history class RecentAccessState { final List items; final Set hiddenDownloadIds; @@ -92,7 +88,6 @@ class RecentAccessState { } } -/// Provider for managing recent access history class RecentAccessNotifier extends Notifier { final AppStateDatabase _appStateDb = AppStateDatabase.instance; @@ -135,7 +130,6 @@ class RecentAccessNotifier extends Notifier { } } - /// Record an access to an artist void recordArtistAccess({ required String id, required String name, @@ -154,7 +148,6 @@ class RecentAccessNotifier extends Notifier { ); } - /// Record an access to an album void recordAlbumAccess({ required String id, required String name, @@ -175,7 +168,6 @@ class RecentAccessNotifier extends Notifier { ); } - /// Record an access to a track void recordTrackAccess({ required String id, required String name, @@ -196,7 +188,6 @@ class RecentAccessNotifier extends Notifier { ); } - /// Record an access to a playlist void recordPlaylistAccess({ required String id, required String name, @@ -242,7 +233,6 @@ class RecentAccessNotifier extends Notifier { } } - /// Remove a specific item from history void removeItem(RecentAccessItem item) { final updatedItems = state.items .where((e) => e.uniqueKey != item.uniqueKey) @@ -251,25 +241,21 @@ class RecentAccessNotifier extends Notifier { unawaited(_appStateDb.deleteRecentAccessRow(item.uniqueKey)); } - /// Hide a download item from recents (without deleting the actual download) void hideDownloadFromRecents(String downloadId) { final updatedHidden = {...state.hiddenDownloadIds, downloadId}; state = state.copyWith(hiddenDownloadIds: updatedHidden); unawaited(_appStateDb.addHiddenRecentDownloadId(downloadId)); } - /// Check if a download is hidden from recents bool isDownloadHidden(String downloadId) { return state.hiddenDownloadIds.contains(downloadId); } - /// Clear all history void clearHistory() { state = state.copyWith(items: []); unawaited(_appStateDb.clearRecentAccessRows()); } - /// Clear hidden downloads (show all again) void clearHiddenDownloads() { state = state.copyWith(hiddenDownloadIds: {}); unawaited(_appStateDb.clearHiddenRecentDownloadIds()); diff --git a/lib/providers/store_provider.dart b/lib/providers/store_provider.dart index fc56c011..e6fc5eec 100644 --- a/lib/providers/store_provider.dart +++ b/lib/providers/store_provider.dart @@ -264,13 +264,12 @@ class StoreNotifier extends Notifier { // Read back the resolved URL (may differ from input after normalisation). final resolvedUrl = await PlatformBridge.getStoreRegistryUrl(); - // Persist to SharedPreferences final prefs = await SharedPreferences.getInstance(); await prefs.setString(_registryUrlPrefKey, resolvedUrl); state = state.copyWith( registryUrl: resolvedUrl, - extensions: const [], // Clear old extensions + extensions: const [], ); _log.i('Registry URL set to: $resolvedUrl'); diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index ad95b21b..0ed2c9e0 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -57,7 +57,6 @@ class ThemeNotifier extends Notifier { await _saveToStorage(); } - /// Set custom seed color (used when dynamic color is disabled) Future setSeedColor(Color color) async { state = state.copyWith(seedColorValue: color.toARGB32()); await _saveToStorage(); @@ -81,4 +80,3 @@ class ThemeNotifier extends Notifier { ); } } - diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 5de7d16b..e3cfaf09 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -18,21 +18,18 @@ class TrackState { final String? artistId; final String? artistName; final String? coverUrl; - final String? headerImageUrl; // Artist header image for background + final String? headerImageUrl; final int? monthlyListeners; - final List? artistAlbums; // For artist page - final List? artistTopTracks; // Artist's popular tracks - final List? searchArtists; // For search results - final List? searchAlbums; // For search results (albums) - final List? searchPlaylists; // For search results (playlists) - final bool hasSearchText; // For back button handling - final bool isShowingRecentAccess; // For recent access mode - final String? - searchExtensionId; // Extension ID used for current search results - final String? - selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist") - final String? - searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz") + final List? artistAlbums; + final List? artistTopTracks; + final List? searchArtists; + final List? searchAlbums; + final List? searchPlaylists; + final bool hasSearchText; + final bool isShowingRecentAccess; + final String? searchExtensionId; + final String? selectedSearchFilter; + final String? searchSource; const TrackState({ this.tracks = const [], @@ -127,9 +124,9 @@ class ArtistAlbum { final String releaseDate; final int totalTracks; final String? coverUrl; - final String albumType; // album, single, compilation + final String albumType; final String artists; - final String? providerId; // Extension ID if from extension + final String? providerId; const ArtistAlbum({ required this.id, @@ -204,7 +201,6 @@ class TrackNotifier extends Notifier { return const TrackState(); } - /// Check if request is still valid (not cancelled by newer request) bool _isRequestValid(int requestId) => requestId == _currentRequestId; Future fetchFromUrl(String url, {bool useDeezerFallback = true}) async { @@ -217,7 +213,6 @@ class TrackNotifier extends Notifier { if (extensionHandler != null) { _log.i('Found extension URL handler: $extensionHandler for URL: $url'); - // Retry logic for extension URL handlers (up to 3 attempts) Map? result; for (int attempt = 1; attempt <= 3; attempt++) { result = await PlatformBridge.handleURLWithExtension(url); @@ -541,7 +536,6 @@ class TrackNotifier extends Notifier { return; } - // If URL doesn't match any known service, it's unrecognized final isSpotifyUrl = url.contains('open.spotify.com') || url.contains('spotify.link') || @@ -643,7 +637,6 @@ class TrackNotifier extends Notifier { }) async { final requestId = ++_currentRequestId; - // Preserve selected filter during loading final currentFilter = filterOverride ?? state.selectedSearchFilter; state = TrackState( @@ -662,7 +655,6 @@ class TrackNotifier extends Notifier { final includeExtensions = settings.useExtensionProviders && hasActiveMetadataExtensions; - // Determine the effective search provider final effectiveProvider = builtInSearchProvider ?? 'deezer'; _log.i( @@ -672,7 +664,6 @@ class TrackNotifier extends Notifier { Map results; List> metadataTrackResults = []; - // Only use metadata providers for Deezer search (default behavior) if (effectiveProvider == 'deezer') { try { _log.d('Calling metadata provider search API...'); @@ -692,7 +683,6 @@ class TrackNotifier extends Notifier { } } - // Call the appropriate search API switch (effectiveProvider) { case 'tidal': _log.d('Calling Tidal search API...'); @@ -808,9 +798,8 @@ class TrackNotifier extends Notifier { isLoading: false, hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, - selectedSearchFilter: currentFilter, // Preserve filter in results - searchSource: - effectiveProvider, // Track which service was used for search + selectedSearchFilter: currentFilter, + searchSource: effectiveProvider, ); } catch (e, stackTrace) { if (!_isRequestValid(requestId)) return; @@ -837,7 +826,7 @@ class TrackNotifier extends Notifier { hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: - state.selectedSearchFilter, // Preserve filter during loading + state.selectedSearchFilter, ); try { @@ -876,9 +865,8 @@ class TrackNotifier extends Notifier { isLoading: false, hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, - searchExtensionId: extensionId, // Store which extension was used - selectedSearchFilter: - state.selectedSearchFilter, // Preserve selected filter + searchExtensionId: extensionId, + selectedSearchFilter: state.selectedSearchFilter, ); } catch (e, stackTrace) { if (!_isRequestValid(requestId)) return; @@ -934,7 +922,6 @@ class TrackNotifier extends Notifier { tracks[index] = updatedTrack; state = state.copyWith(tracks: tracks); } catch (_) { - // Silently ignore update failures - track may have been removed } } @@ -942,7 +929,6 @@ class TrackNotifier extends Notifier { state = const TrackState(); } - /// Set selected search filter for extension search void setSearchFilter(String? filter) { if (state.selectedSearchFilter == filter) return; state = state.copyWith( @@ -951,7 +937,6 @@ class TrackNotifier extends Notifier { ); } - /// Set search text state for back button handling void setSearchText(bool hasText) { if (state.hasSearchText == hasText) { return; @@ -966,7 +951,6 @@ class TrackNotifier extends Notifier { state = state.copyWith(isShowingRecentAccess: showing); } - /// Set tracks from a collection (album/playlist) opened from search results void setTracksFromCollection({ required List tracks, String? albumName, @@ -1127,7 +1111,7 @@ class TrackNotifier extends Notifier { 'isrc': isrc, 'track_name': track.name, 'artist_name': track.artistName, - 'spotify_id': track.id, // Include Spotify ID for Amazon lookup + 'spotify_id': track.id, 'service': 'tidal', }); if (cacheRequests.length >= _maxPreWarmTracksPerRequest) { diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index 2690fb33..ca41f0f1 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -1105,7 +1105,6 @@ class _DownloadedAlbumScreenState extends ConsumerState { ? 'Opus' : null; if (ext == null || ext == targetFormat) continue; - // Skip lossy sources when target is lossless (pointless re-encoding) final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; if (isLosslessTarget && !isLosslessSource) continue; diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index d41e6c80..ed0c6e36 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1367,7 +1367,6 @@ class _LocalAlbumScreenState extends ConsumerState { } } if (currentFormat == null || currentFormat == targetFormat) continue; - // Skip lossy sources when target is lossless (pointless re-encoding) final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final isLosslessSource = currentFormat == 'FLAC' || currentFormat == 'M4A'; @@ -1488,7 +1487,7 @@ class _LocalAlbumScreenState extends ConsumerState { bitrate: bitrate, metadata: metadata, coverPath: coverPath, - deleteOriginal: !isSaf, // Only delete original for regular files + deleteOriginal: !isSaf, ); if (coverPath != null) { @@ -1507,15 +1506,9 @@ class _LocalAlbumScreenState extends ConsumerState { } if (isSaf) { - // For SAF: derive the parent tree URI and relative dir from the content URI, - // then create new SAF file and delete old one - // Parse the SAF URI to get the tree document path: - // content://...tree/...document/.../oldName.flac - // We need tree URI and relative dir to create the new file final uri = Uri.parse(item.filePath); final pathSegments = uri.pathSegments; - // Try to find 'tree' and 'document' segments String? treeUri; String relativeDir = ''; String oldFileName = ''; diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index cb292291..49bbe89c 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -162,7 +162,6 @@ class _MainShellState extends ConsumerState if (!Platform.isAndroid) return; final settings = ref.read(settingsProvider); - // Only show if user is still on legacy storage mode with a download dir set if (settings.storageMode == 'saf') return; if (settings.downloadDirectory.isEmpty) return; diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 326c78e4..4b2a5cb0 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -97,7 +97,6 @@ class UnifiedLibraryItem { } else if (item.bitDepth != null && item.bitDepth! > 0 && item.sampleRate != null) { - // Lossless format with actual bit depth quality = buildDisplayAudioQuality( bitDepth: item.bitDepth, sampleRate: item.sampleRate, @@ -108,7 +107,7 @@ class UnifiedLibraryItem { trackName: item.trackName, artistName: item.artistName, albumName: item.albumName, - coverUrl: null, // Local library doesn't have cover URLs + coverUrl: null, localCoverPath: item.coverPath, filePath: item.filePath, quality: quality, @@ -170,9 +169,6 @@ class UnifiedLibraryItem { } if (localItem != null) { final l = localItem!; - // Store coverPath (even local file paths) in coverUrl so playlist - // entries retain the cover. All renderers must check whether the - // value is a URL or a local path and use the appropriate widget. return Track( id: l.id, name: l.trackName, @@ -188,7 +184,6 @@ class UnifiedLibraryItem { source: 'local', ); } - // Fallback — should not happen return Track( id: id, name: trackName, @@ -4889,15 +4884,12 @@ class _QueueTabState extends ConsumerState { for (final id in _selectedIds) { final item = itemsById[id]; if (item == null) continue; - // Detect format: use safFileName for download history SAF items, - // item.localItem?.format for local library items, file extension as fallback String nameToCheck; if (item.historyItem?.safFileName != null && item.historyItem!.safFileName!.isNotEmpty) { nameToCheck = item.historyItem!.safFileName!.toLowerCase(); } else if (item.localItem?.format != null && item.localItem!.format!.isNotEmpty) { - // Synthesize a fake extension to keep detection unified nameToCheck = '.${item.localItem!.format!.toLowerCase()}'; } else { nameToCheck = item.filePath.toLowerCase(); @@ -4912,7 +4904,6 @@ class _QueueTabState extends ConsumerState { ? 'Opus' : null; if (ext == null || ext == targetFormat) continue; - // Skip lossy sources when target is lossless (pointless re-encoding) final isLosslessTarget = targetFormat == 'ALAC' || targetFormat == 'FLAC'; final isLosslessSource = ext == 'FLAC' || ext == 'M4A'; if (isLosslessTarget && !isLosslessSource) continue; @@ -5060,7 +5051,6 @@ class _QueueTabState extends ConsumerState { continue; } - // Handle SAF write-back if (isSaf && item.historyItem != null) { final hi = item.historyItem!; final treeUri = hi.downloadTreeUri; @@ -5113,12 +5103,10 @@ class _QueueTabState extends ConsumerState { continue; } - // Delete old SAF file try { await PlatformBridge.safDelete(item.filePath); } catch (_) {} - // Update history await historyDb.updateFilePath( hi.id, safUri, @@ -5127,7 +5115,6 @@ class _QueueTabState extends ConsumerState { clearAudioSpecs: true, ); } - // Cleanup temp files try { await File(newPath).delete(); } catch (_) {} @@ -5137,7 +5124,6 @@ class _QueueTabState extends ConsumerState { } catch (_) {} } } else if (isSaf && item.localItem != null) { - // Local library SAF item: parse content URI to derive tree and dir final uri = Uri.parse(item.filePath); final pathSegments = uri.pathSegments; @@ -5214,7 +5200,6 @@ class _QueueTabState extends ConsumerState { await LibraryDatabase.instance.deleteByPath(item.filePath); } - // Cleanup temp files try { await File(newPath).delete(); } catch (_) {} @@ -5224,7 +5209,6 @@ class _QueueTabState extends ConsumerState { } catch (_) {} } } else if (item.historyItem != null) { - // Regular file - update history path await historyDb.updateFilePath( item.historyItem!.id, newPath, @@ -5232,17 +5216,13 @@ class _QueueTabState extends ConsumerState { clearAudioSpecs: true, ); } else if (item.localItem != null) { - // Regular local library file - delete old db entry, rescan picks up new file await LibraryDatabase.instance.deleteByPath(item.filePath); } successCount++; - } catch (_) { - // Continue to next item on error - } + } catch (_) {} } - // Reload history and local library to reflect path changes in UI ref.read(downloadHistoryProvider.notifier).reloadFromStorage(); ref.read(localLibraryProvider.notifier).reloadFromStorage(); @@ -5264,7 +5244,6 @@ class _QueueTabState extends ConsumerState { } } - /// Bottom action bar for selection mode (Material Design 3 style) Widget _buildSelectionBottomBar( BuildContext context, ColorScheme colorScheme, diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index c6e00b55..f2f0b950 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -61,7 +61,7 @@ class _ExtensionDetailPageState extends ConsumerState { final hasError = extension.status == 'error'; return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( slivers: [ diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 57979689..7e5aa26e 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -61,7 +61,7 @@ class _ExtensionsPageState extends ConsumerState { final topPadding = normalizedHeaderTopPadding(context); return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( slivers: [ @@ -600,14 +600,12 @@ class _SearchProviderSelector extends ConsumerWidget { .where((e) => e.enabled && e.hasCustomSearch) .toList(); - // Always allow tapping: built-in providers are always available final hasAnyProvider = searchProviders.isNotEmpty || _builtInProviders.isNotEmpty; String currentProviderName = context.l10n.extensionDefaultProvider; if (settings.searchProvider != null && settings.searchProvider!.isNotEmpty) { - // Check built-in first if (_builtInProviders.containsKey(settings.searchProvider)) { currentProviderName = _builtInProviders[settings.searchProvider]!; } else { diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 86c461a3..eae12522 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -23,21 +23,15 @@ class _LibrarySettingsPageState extends ConsumerState { int _androidSdkVersion = 0; bool _hasStoragePermission = false; - /// Convert SAF content URI to a readable display path String _getDisplayPath(String path) { if (!path.startsWith('content://')) return path; - // Extract the path portion from SAF tree URI - // e.g. content://com.android.externalstorage.documents/tree/primary%3AMusic - // -> /storage/emulated/0/Music try { final uri = Uri.parse(path); - final treePath = - uri.pathSegments.last; // e.g. "primary:Music" or "primary%3AMusic" + final treePath = uri.pathSegments.last; final decoded = Uri.decodeComponent(treePath); if (decoded.startsWith('primary:')) { return '/storage/emulated/0/${decoded.substring('primary:'.length)}'; } - // For SD card or other volumes, just show the decoded path return decoded; } catch (_) { return path; diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index 611db06f..b34e172a 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -136,7 +136,7 @@ class _LogScreenState extends State { final logs = _filteredLogs; return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( controller: _scrollController, diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 3f5a2e7c..1025f1b1 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -19,7 +19,7 @@ class OptionsSettingsPage extends ConsumerWidget { final topPadding = normalizedHeaderTopPadding(context); return PopScope( - canPop: true, // Always allow back gesture + canPop: true, child: Scaffold( body: CustomScrollView( slivers: [ diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 5f137482..bc72a18d 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -441,14 +441,9 @@ class _SetupScreenState extends ConsumerState { void _nextPage() { bool canProceed = false; - // Step 0 is Welcome, always can proceed if (_currentStep == 0) { canProceed = true; } else { - // Logic for other steps (offset by 1 because of welcome step) - // Step 1: Storage - // Step 2: Notification (if android 13+) OR Directory - // etc. canProceed = _isStepCompleted(_currentStep); } @@ -470,9 +465,8 @@ class _SetupScreenState extends ConsumerState { } bool _isStepCompleted(int step) { - if (step == 0) return true; // Welcome + if (step == 0) return true; - // Adjust step index for logic because we added Welcome at 0 final logicStep = step - 1; if (_androidSdkVersion >= 33) { diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index d84b9fc9..254d2f06 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -60,19 +60,19 @@ class _TrackMetadataScreenState extends ConsumerState { bool _fileExists = false; bool _hasCheckedFile = false; int? _fileSize; - String? _lyrics; // Cleaned lyrics for display (no timestamps) - String? _rawLyrics; // Raw LRC with timestamps for embedding + String? _lyrics; + String? _rawLyrics; bool _lyricsLoading = false; String? _lyricsError; String? _lyricsSource; bool _showTitleInAppBar = false; bool _lyricsEmbedded = false; - bool _isEmbedding = false; // Track embed operation in progress + bool _isEmbedding = false; bool _isInstrumental = false; - bool _isConverting = false; // Track convert operation in progress + bool _isConverting = false; bool _hasMetadataChanges = false; bool _hasLoadedResolvedAudioMetadata = false; - Map? _editedMetadata; // Overrides after metadata edit + Map? _editedMetadata; String? _embeddedCoverPreviewPath; final ScrollController _scrollController = ScrollController(); static final RegExp _lrcTimestampPattern = RegExp( @@ -577,7 +577,6 @@ class _TrackMetadataScreenState extends ConsumerState { String get cleanFilePath { var path = _filePath; if (path.startsWith('EXISTS:')) path = path.substring(7); - // Strip CUE virtual path suffix for filesystem operations if (isCueVirtualPath(path)) path = stripCueTrackSuffix(path); return path; } @@ -1707,7 +1706,7 @@ class _TrackMetadataScreenState extends ConsumerState { _spotifyId ?? '', trackName, artistName, - filePath: null, // Don't check file again + filePath: null, durationMs: durationMs, ).timeout(const Duration(seconds: 20)); @@ -1733,9 +1732,9 @@ class _TrackMetadataScreenState extends ConsumerState { final cleanLyrics = _cleanLrcForDisplay(lrcText); setState(() { _lyrics = cleanLyrics; - _rawLyrics = lrcText; // Keep raw LRC with timestamps for embedding + _rawLyrics = lrcText; _lyricsSource = source.isNotEmpty ? source : null; - _lyricsEmbedded = false; // Lyrics from online, not embedded + _lyricsEmbedded = false; _lyricsLoading = false; }); } @@ -1762,7 +1761,6 @@ class _TrackMetadataScreenState extends ConsumerState { setState(() => _isEmbedding = true); - // Capture l10n strings before async gaps to avoid use_build_context_synchronously final l10nFailedToWriteStorage = context.l10n.snackbarFailedToWriteStorage; final l10nFailedToEmbedLyrics = context.l10n.snackbarFailedToEmbedLyrics; final l10nUnsupportedFormat = context.l10n.snackbarUnsupportedAudioFormat; @@ -3556,7 +3554,7 @@ class _TrackMetadataScreenState extends ConsumerState { bitrate: bitrate, metadata: metadata, coverPath: coverPath, - deleteOriginal: !isSaf, // Don't delete temp copy for SAF, we handle it + deleteOriginal: !isSaf, ); if (coverPath != null) { @@ -3627,7 +3625,7 @@ class _TrackMetadataScreenState extends ConsumerState { newExt = '.flac'; mimeType = 'audio/flac'; break; - default: // mp3 + default: newExt = '.mp3'; mimeType = 'audio/mpeg'; break; diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index 6cb77e0d..551acd17 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -198,8 +198,8 @@ class CsvImportService { artistName: artistName ?? 'Unknown Artist', albumName: albumName ?? 'Unknown Album', isrc: isrc, - duration: 0, // Will be updated by enrichment later - coverUrl: null, // Will be fetched by enrichment + duration: 0, + coverUrl: null, ), ); } diff --git a/lib/services/ffmpeg_service.dart b/lib/services/ffmpeg_service.dart index 53efe84d..801ff147 100644 --- a/lib/services/ffmpeg_service.dart +++ b/lib/services/ffmpeg_service.dart @@ -1437,7 +1437,6 @@ class FFmpegService { final cmdBuffer = StringBuffer(); cmdBuffer.write('-i "$inputPath" '); - // Cover art as second input for M4A attached picture final hasCover = coverPath != null && coverPath.trim().isNotEmpty && @@ -1455,7 +1454,6 @@ class FFmpegService { cmdBuffer.write('-c:a alac '); cmdBuffer.write('-map_metadata -1 '); - // Embed M4A metadata tags final m4aTags = _convertToM4aTags(metadata); for (final entry in m4aTags.entries) { final sanitized = entry.value.replaceAll('"', '\\"'); @@ -1764,7 +1762,6 @@ class FFmpegService { final outputPaths = []; final inputExt = audioPath.toLowerCase().split('.').last; - // For lossless formats, keep as FLAC; for others, keep original format final outputExt = (inputExt == 'flac' || inputExt == 'wav' || @@ -1836,14 +1833,10 @@ class FFmpegService { final result = await _execute(command); if (!result.success) { _log.e('CUE split failed for track ${track.number}: ${result.output}'); - // Continue with remaining tracks instead of failing completely continue; } - // Embed cover art if available (for FLAC output) if (coverPath != null && coverPath.isNotEmpty && outputExt == 'flac') { - // Use the Go backend for FLAC cover embedding via PlatformBridge - // (handled by the caller) } outputPaths.add(outputPath); diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 313f2f40..401a3afc 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1081,7 +1081,6 @@ class PlatformBridge { } } - /// Set the directory for caching extracted cover art static Future setLibraryCoverCacheDir(String cacheDir) async { _log.i('setLibraryCoverCacheDir: $cacheDir'); await _channel.invokeMethod('setLibraryCoverCacheDir', { @@ -1089,8 +1088,6 @@ class PlatformBridge { }); } - /// Scan a folder for audio files and read their metadata - /// Returns a list of track metadata static Future>> scanLibraryFolder( String folderPath, ) async { @@ -1102,10 +1099,6 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Perform an incremental scan of the library folder - /// Only scans files that are new or have changed since last scan - /// [existingFiles] is a map of filePath -> modTime (unix millis) - /// Returns IncrementalScanResult with scanned items, deleted paths, and skip count static Future> scanLibraryFolderIncremental( String folderPath, Map existingFiles, @@ -1140,8 +1133,6 @@ class PlatformBridge { return list.map((e) => e as Map).toList(); } - /// Incremental SAF tree scan - only scans new or modified files - /// Returns a map with 'files' (new/changed) and 'removedUris' (deleted files) static Future> scanSafTreeIncremental( String treeUri, Map existingFiles, @@ -1167,8 +1158,6 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } - /// Get last-modified timestamps for a list of SAF file URIs. - /// Returns map uri -> modTime (unix millis), only for files that still exist. static Future> getSafFileModTimes(List uris) async { final result = await _channel.invokeMethod('getSafFileModTimes', { 'uris': jsonEncode(uris), @@ -1177,7 +1166,6 @@ class PlatformBridge { return map.map((key, value) => MapEntry(key, (value as num).toInt())); } - /// Get current library scan progress static Future> getLibraryScanProgress() async { final result = await _channel.invokeMethod('getLibraryScanProgress'); return _decodeMapResult(result); @@ -1189,7 +1177,6 @@ class PlatformBridge { ); } - /// Cancel ongoing library scan static Future cancelLibraryScan() async { await _channel.invokeMethod('cancelLibraryScan'); } @@ -1249,7 +1236,6 @@ class PlatformBridge { } } - /// Read metadata from a single audio file static Future?> readAudioMetadata( String filePath, ) async { @@ -1369,10 +1355,6 @@ class PlatformBridge { await _channel.invokeMethod('clearStoreCache'); } - /// Parse a .cue file and return split information (track listing, timing, metadata). - /// Returns a map with: cue_path, audio_path, album, artist, genre, date, tracks[] - /// Each track has: number, title, artist, isrc, composer, start_sec, end_sec - /// [audioDir] optionally overrides the directory for audio file resolution (used for SAF). static Future> parseCueSheet( String cuePath, { String audioDir = '', diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index e958394f..1040c738 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -80,7 +80,6 @@ class ShareIntentService { bool isInitial = false, }) { for (final file in files) { - // Check both path and message - apps may share URL in either field final textsToCheck = [file.path, if (file.message != null) file.message!]; for (final textToCheck in textsToCheck) { @@ -100,13 +99,11 @@ class ShareIntentService { String? _extractMusicUrl(String text) { if (text.isEmpty) return null; - // Try Spotify URI first final uriMatch = _spotifyUriPattern.firstMatch(text); if (uriMatch != null) { return uriMatch.group(0); } - // Try all URL patterns final patterns = [ _spotifyUrlPattern, _deezerUrlPattern, diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart index a4ec68ca..91e40501 100644 --- a/lib/utils/clickable_metadata.dart +++ b/lib/utils/clickable_metadata.dart @@ -27,7 +27,6 @@ Future navigateToArtist( final normalizedArtistId = _normalizeArtistId(artistId); - // If we have a valid artist ID already, navigate directly if (normalizedArtistId != null && _canNavigateArtistDirectly( artistId: normalizedArtistId, @@ -43,7 +42,6 @@ Future navigateToArtist( return; } - // Search Deezer to resolve the artist ID _showLoadingSnackBar(context, 'Looking up artist...'); try { final results = await PlatformBridge.searchDeezerAll( @@ -60,7 +58,6 @@ Future navigateToArtist( return; } - // Find best match - prefer exact name match (case-insensitive) Map? bestMatch; final lowerName = artistName.toLowerCase().trim(); for (final a in artistList) { @@ -113,7 +110,6 @@ Future navigateToAlbum( }) async { if (albumName.isEmpty) return; - // If we have a valid album ID already, navigate directly if (albumId != null && albumId.isNotEmpty && albumId != 'unknown' && @@ -128,16 +124,13 @@ Future navigateToAlbum( return; } - // If it's extension-based content without an ID, can't search Deezer for it if (extensionId != null) { _showUnavailable(context, 'Album'); return; } - // Search Deezer to resolve the album ID _showLoadingSnackBar(context, 'Looking up album...'); try { - // Build search query: "albumName artistName" for better accuracy final query = artistName != null && artistName.isNotEmpty ? '$albumName $artistName' : albumName; @@ -156,7 +149,6 @@ Future navigateToAlbum( return; } - // Find best match - prefer exact name match (case-insensitive) Map? bestMatch; final lowerName = albumName.toLowerCase().trim(); for (final a in albumList) { diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 396adde9..8aac0215 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -14,8 +14,6 @@ import 'package:path_provider/path_provider.dart'; import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; -// Data models - class AudioAnalysisData { final String filePath; final int fileSize; @@ -98,8 +96,6 @@ class SpectrogramData { }); } -// Audio Analysis Card Widget - class AudioAnalysisCard extends StatefulWidget { final String filePath; @@ -179,7 +175,6 @@ class _AudioAnalysisCardState extends State { }); try { - // Try loading from cache first final cached = await _loadFromCache(widget.filePath); AudioAnalysisData data; @@ -187,7 +182,6 @@ class _AudioAnalysisCardState extends State { data = cached; } else { data = await _runAnalysis(widget.filePath); - // Save to cache (fire-and-forget) _saveToCache(widget.filePath, data); } @@ -214,8 +208,6 @@ class _AudioAnalysisCardState extends State { } } - // Analysis cache - static String _cacheKey(String filePath) { var hash = 0xcbf29ce484222325; for (final byte in utf8.encode(filePath)) { @@ -267,8 +259,6 @@ class _AudioAnalysisCardState extends State { } catch (_) {} } - // Analysis pipeline - Future _runAnalysis(String filePath) async { await FFmpegKitConfig.setLogLevel(Level.avLogError); @@ -302,7 +292,6 @@ class _AudioAnalysisCardState extends State { ), ); - // Total samples from file metadata (not truncated PCM) final trueTotalSamples = (info.duration * info.sampleRate * info.channels).round(); @@ -468,7 +457,6 @@ class _AudioAnalysisCardState extends State { final cs = Theme.of(context).colorScheme; final l10n = context.l10n; - // Still checking cache, show nothing yet if (_checkingCache) return const SizedBox.shrink(); if (_analyzing) { @@ -575,8 +563,6 @@ class _AudioAnalysisCardState extends State { } } -// Internal types - class _MediaInfo { final int fileSize; final int sampleRate; @@ -623,8 +609,6 @@ class _AnalysisResult { }); } -// Isolate: PCM analysis + FFT spectrogram - _AnalysisResult _analyzeInIsolate(_AnalysisParams params) { final byteData = ByteData.sublistView(params.pcmBytes); final sampleCount = params.pcmBytes.length ~/ 2; @@ -767,8 +751,6 @@ Float64List _fft(Float64List realInput) { return data; } -// Audio Info Card - class _AudioInfoCard extends StatelessWidget { final AudioAnalysisData data; @@ -945,8 +927,6 @@ class _MetricChip extends StatelessWidget { } } -// Spectrogram View - class _SpectrogramView extends StatelessWidget { final ui.Image image; final SpectrogramData spectrum; @@ -1011,8 +991,6 @@ class _ImagePainter extends CustomPainter { bool shouldRepaint(covariant _ImagePainter old) => old.image != image; } -// Spectrogram pixel-buffer rendering (runs in isolate) - class _SpectrogramRenderParams { final SpectrogramData spectrum; final int width; @@ -1031,7 +1009,6 @@ Uint8List _renderSpectrogramPixels(_SpectrogramRenderParams params) { final spectrum = params.spectrum; final pixels = Uint8List(w * h * 4); - // Fill black for (int i = 3; i < pixels.length; i += 4) { pixels[i] = 255; } @@ -1041,7 +1018,6 @@ Uint8List _renderSpectrogramPixels(_SpectrogramRenderParams params) { final freqBins = spectrum.freqBins; - // dB range double minDB = 0; double maxDB = -200; for (final slice in slices) { diff --git a/lib/widgets/donate_icons.dart b/lib/widgets/donate_icons.dart index 21ba4195..fa34125a 100644 --- a/lib/widgets/donate_icons.dart +++ b/lib/widgets/donate_icons.dart @@ -166,7 +166,6 @@ class _GitHubPainter extends CustomPainter { 9.47 * scale, 17.93 * scale, 9.81 * scale, 17.63 * scale, ); - // Bottom path.cubicTo( 7.15 * scale, 17.33 * scale, 4.34 * scale, 16.33 * scale, From 4f2e677e8b1153b6ed7a517202d2c6e78206e9a4 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 26 Mar 2026 17:32:54 +0700 Subject: [PATCH 19/33] fix: improve skeleton visibility and artist header for light mode --- lib/screens/artist_screen.dart | 8 ++++++-- lib/widgets/animation_utils.dart | 32 +++++++++++++++++++++++++++----- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index ef72e14d..401a90ac 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -1156,6 +1156,8 @@ class _ArtistScreenState extends ConsumerState { imageUrl.isNotEmpty && Uri.tryParse(imageUrl)?.hasAuthority == true; + final isDark = Theme.of(context).brightness == Brightness.dark; + String? listenersText; final listeners = _monthlyListeners ?? widget.monthlyListeners; if (listeners != null && listeners > 0) { @@ -1226,7 +1228,9 @@ class _ArtistScreenState extends ConsumerState { Colors.transparent, Colors.black.withValues(alpha: 0.3), Colors.black.withValues(alpha: 0.7), - colorScheme.surface, + isDark + ? colorScheme.surface + : Colors.black.withValues(alpha: 0.85), ], stops: const [0.0, 0.5, 0.75, 1.0], ), @@ -1267,7 +1271,7 @@ class _ArtistScreenState extends ConsumerState { listenersText, style: Theme.of(context).textTheme.bodyMedium ?.copyWith( - color: Colors.white.withValues(alpha: 0.8), + color: Colors.white, shadows: [ Shadow( offset: const Offset(0, 1), diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index 7d8a33f6..cfecb731 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -146,11 +146,23 @@ class _ShimmerLoadingState extends State final isDark = Theme.of(context).brightness == Brightness.dark; final baseColor = isDark - ? colorScheme.surfaceContainerHighest - : colorScheme.surfaceContainerHigh; + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.10), + colorScheme.surface, + ); final highlightColor = isDark - ? colorScheme.surfaceContainerHigh - : colorScheme.surface; + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.14), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.01), + colorScheme.surface, + ); return AnimatedBuilder( animation: _controller, @@ -194,11 +206,21 @@ class SkeletonBox extends StatelessWidget { @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; + final isDark = Theme.of(context).brightness == Brightness.dark; + final color = isDark + ? Color.alphaBlend( + Colors.white.withValues(alpha: 0.08), + colorScheme.surface, + ) + : Color.alphaBlend( + Colors.black.withValues(alpha: 0.06), + colorScheme.surface, + ); return Container( width: width, height: height, decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest, + color: color, borderRadius: BorderRadius.circular(borderRadius), ), ); From 0f327cd1f6350a5849c181e3455fc34a93b771e8 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 26 Mar 2026 18:15:37 +0700 Subject: [PATCH 20/33] feat: add flat singles folder option (Artist/song.flac without Singles subfolder) --- lib/l10n/app_localizations.dart | 12 ++++++++++++ lib/l10n/app_localizations_de.dart | 7 +++++++ lib/l10n/app_localizations_en.dart | 7 +++++++ lib/l10n/app_localizations_es.dart | 7 +++++++ lib/l10n/app_localizations_fr.dart | 7 +++++++ lib/l10n/app_localizations_hi.dart | 7 +++++++ lib/l10n/app_localizations_id.dart | 7 +++++++ lib/l10n/app_localizations_ja.dart | 7 +++++++ lib/l10n/app_localizations_ko.dart | 7 +++++++ lib/l10n/app_localizations_nl.dart | 7 +++++++ lib/l10n/app_localizations_pt.dart | 7 +++++++ lib/l10n/app_localizations_ru.dart | 7 +++++++ lib/l10n/app_localizations_tr.dart | 7 +++++++ lib/l10n/app_localizations_zh.dart | 7 +++++++ lib/l10n/arb/app_en.arb | 8 ++++++++ lib/providers/download_queue_provider.dart | 14 ++++++++++++++ lib/screens/settings/download_settings_page.dart | 16 ++++++++++++++++ 17 files changed, 141 insertions(+) diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 588eac10..64eee3fb 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -2902,6 +2902,18 @@ abstract class AppLocalizations { /// **'Artist/Album/ and Artist/Singles/'** String get albumFolderArtistAlbumSinglesSubtitle; + /// Album folder option with singles directly in artist folder + /// + /// In en, this message translates to: + /// **'Artist / Album (Singles flat)'** + String get albumFolderArtistAlbumFlat; + + /// Folder structure example for flat singles + /// + /// In en, this message translates to: + /// **'Artist/Album/ and Artist/song.flac'** + String get albumFolderArtistAlbumFlatSubtitle; + /// Button - delete selected tracks /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index f6e211bc..15888ae5 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -1578,6 +1578,13 @@ class AppLocalizationsDe extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Künstler/Album/ und Künstler/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Ausgewählte löschen'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 2a51e017..210342d2 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -1552,6 +1552,13 @@ class AppLocalizationsEn extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 0442fc0e..f39ba881 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -1552,6 +1552,13 @@ class AppLocalizationsEs extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 9f27a85a..f0e783a5 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -1554,6 +1554,13 @@ class AppLocalizationsFr extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_hi.dart b/lib/l10n/app_localizations_hi.dart index 9999389b..d36edd4b 100644 --- a/lib/l10n/app_localizations_hi.dart +++ b/lib/l10n/app_localizations_hi.dart @@ -1552,6 +1552,13 @@ class AppLocalizationsHi extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_id.dart b/lib/l10n/app_localizations_id.dart index ac0980df..45d363fa 100644 --- a/lib/l10n/app_localizations_id.dart +++ b/lib/l10n/app_localizations_id.dart @@ -1561,6 +1561,13 @@ class AppLocalizationsId extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artis/Album/ dan Artis/Single/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Hapus yang Dipilih'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index 2d6606ce..3d64d7d0 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -1539,6 +1539,13 @@ class AppLocalizationsJa extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => '選択済みを削除'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 4a6244d6..69036d66 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -1532,6 +1532,13 @@ class AppLocalizationsKo extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index b0e709d9..1ee2d0b9 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -1552,6 +1552,13 @@ class AppLocalizationsNl extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index e22c9ed1..444a91e0 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -1552,6 +1552,13 @@ class AppLocalizationsPt extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index 1875d49f..fcd6c96b 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -1581,6 +1581,13 @@ class AppLocalizationsRu extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Исполнитель/Альбом и Исполнитель/Сингл/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Удалить выбранные'; diff --git a/lib/l10n/app_localizations_tr.dart b/lib/l10n/app_localizations_tr.dart index 9ca79ab1..42e95073 100644 --- a/lib/l10n/app_localizations_tr.dart +++ b/lib/l10n/app_localizations_tr.dart @@ -1558,6 +1558,13 @@ class AppLocalizationsTr extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index cb4ee004..9b379784 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -1552,6 +1552,13 @@ class AppLocalizationsZh extends AppLocalizations { String get albumFolderArtistAlbumSinglesSubtitle => 'Artist/Album/ and Artist/Singles/'; + @override + String get albumFolderArtistAlbumFlat => 'Artist / Album (Singles flat)'; + + @override + String get albumFolderArtistAlbumFlatSubtitle => + 'Artist/Album/ and Artist/song.flac'; + @override String get downloadedAlbumDeleteSelected => 'Delete Selected'; diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 465cefc6..4107d8b9 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -2029,6 +2029,14 @@ "@albumFolderArtistAlbumSinglesSubtitle": { "description": "Folder structure example" }, + "albumFolderArtistAlbumFlat": "Artist / Album (Singles flat)", + "@albumFolderArtistAlbumFlat": { + "description": "Album folder option with singles directly in artist folder" + }, + "albumFolderArtistAlbumFlatSubtitle": "Artist/Album/ and Artist/song.flac", + "@albumFolderArtistAlbumFlatSubtitle": { + "description": "Folder structure example for flat singles" + }, "downloadedAlbumDeleteSelected": "Delete Selected", "@downloadedAlbumDeleteSelected": { "description": "Button - delete selected tracks" diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 14693e35..e99151ff 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1908,6 +1908,20 @@ class DownloadQueueNotifier extends Notifier { } } + if (albumFolderStructure == 'artist_album_flat') { + if (isSingle) { + final artistPath = '$baseDir${Platform.pathSeparator}$artistName'; + await _ensureDirExists(artistPath, label: 'Artist folder'); + return artistPath; + } else { + final albumName = _sanitizeFolderName(track.albumName); + final albumPath = + '$baseDir${Platform.pathSeparator}$artistName${Platform.pathSeparator}$albumName'; + await _ensureDirExists(albumPath, label: 'Artist Album folder'); + return albumPath; + } + } + if (isSingle) { final singlesPath = '$baseDir${Platform.pathSeparator}Singles'; await _ensureDirExists(singlesPath, label: 'Singles folder'); diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index b7c34cae..6b60e332 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -841,6 +841,8 @@ class _DownloadSettingsPageState extends ConsumerState { return 'Albums/[Year] Album/'; case 'artist_album_singles': return 'Artist/Album/ + Artist/Singles/'; + case 'artist_album_flat': + return 'Artist/Album/ + Artist/song.flac'; default: return 'Albums/Artist/Album Name/'; } @@ -930,6 +932,20 @@ class _DownloadSettingsPageState extends ConsumerState { Navigator.pop(context); }, ), + ListTile( + leading: const Icon(Icons.person_outline_outlined), + title: Text(context.l10n.albumFolderArtistAlbumFlat), + subtitle: Text(context.l10n.albumFolderArtistAlbumFlatSubtitle), + trailing: current == 'artist_album_flat' + ? const Icon(Icons.check) + : null, + onTap: () { + ref + .read(settingsProvider.notifier) + .setAlbumFolderStructure('artist_album_flat'); + Navigator.pop(context); + }, + ), ], ), ), From b48462a945d47e3d0d87e03d220315c6cd4d8669 Mon Sep 17 00:00:00 2001 From: zarzet Date: Thu, 26 Mar 2026 18:31:00 +0700 Subject: [PATCH 21/33] fix: add artist_album_flat case to SAF relative output dir builder --- lib/providers/download_queue_provider.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index e99151ff..7ac7453a 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2080,6 +2080,14 @@ class DownloadQueueNotifier extends Notifier { return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); } + if (albumFolderStructure == 'artist_album_flat') { + if (isSingle) { + return _joinRelativePath(playlistPrefix, artistName); + } + final albumName = _sanitizeFolderName(track.albumName); + return _joinRelativePath(playlistPrefix, '$artistName/$albumName'); + } + if (isSingle) { return _joinRelativePath(playlistPrefix, 'Singles'); } From 198ed5ce6fa4d6cce3b2bb037e0cd2c323bfde83 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Mar 2026 03:53:31 +0700 Subject: [PATCH 22/33] docs: move badges below screenshots in README --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1ae27fbf..732523af 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,15 @@ +## Screenshots + +

+ + + + +

+
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) @@ -25,15 +34,6 @@
-## Screenshots - -

- - - - -

- --- ## Extensions From fc7220b5725f5cdab3a2e5a96465334d72d19038 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Mar 2026 03:54:31 +0700 Subject: [PATCH 23/33] docs: update VirusTotal hash for v4.1.0 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 732523af..1a442946 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](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/cc11355330c76f97548b8d26452b91746db9d9c1edbcfc4c18250133484d1487) +[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/31d1bf3c3b2015c13e83c4f909a7c6093a9423e3e702f0c582a3e0035c849424) [![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile) [![Telegram Channel](https://img.shields.io/badge/CHANNEL-2CA5E0?style=for-the-badge&logo=telegram&logoColor=white)](https://t.me/spotiflac) From 3d6e5615fa1f3be9442eee52b7a963393b1dadb2 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Mar 2026 03:56:57 +0700 Subject: [PATCH 24/33] Revert "docs: move badges below screenshots in README" This reverts commit 198ed5ce6fa4d6cce3b2bb037e0cd2c323bfde83. --- README.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 1a442946..91bf53f9 100644 --- a/README.md +++ b/README.md @@ -14,15 +14,6 @@
-## Screenshots - -

- - - - -

-
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases) @@ -34,6 +25,15 @@
+## Screenshots + +

+ + + + +

+ --- ## Extensions From e9b24712c5c8bacf7e4ac8d6137aefc4b5d0b497 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Mar 2026 04:21:11 +0700 Subject: [PATCH 25/33] feat: cache spectrogram as PNG for instant loading on subsequent views --- lib/widgets/audio_analysis_widget.dart | 55 +++++++++++++++++++++----- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 8aac0215..6b2b0a90 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -150,14 +150,12 @@ class _AudioAnalysisCardState extends State { _data = cached; _checkingCache = false; }); - if (cached.spectrum != null && cached.spectrum!.sliceCount > 0) { - final image = await _renderSpectrogramToImage(cached.spectrum!); - if (mounted) { - setState(() { - _spectrogramImage?.dispose(); - _spectrogramImage = image; - }); - } + final image = await _loadSpectrogramFromCache(widget.filePath); + if (image != null && mounted) { + setState(() { + _spectrogramImage?.dispose(); + _spectrogramImage = image; + }); } return; } @@ -177,17 +175,25 @@ class _AudioAnalysisCardState extends State { try { final cached = await _loadFromCache(widget.filePath); AudioAnalysisData data; + bool fromCache = false; if (cached != null) { data = cached; + fromCache = true; } else { data = await _runAnalysis(widget.filePath); _saveToCache(widget.filePath, data); } ui.Image? image; - if (data.spectrum != null && data.spectrum!.sliceCount > 0) { + if (fromCache) { + image = await _loadSpectrogramFromCache(widget.filePath); + } + if (image == null && + data.spectrum != null && + data.spectrum!.sliceCount > 0) { image = await _renderSpectrogramToImage(data.spectrum!); + _saveSpectrogramToCache(widget.filePath, image); } if (mounted) { @@ -259,6 +265,37 @@ class _AudioAnalysisCardState extends State { } catch (_) {} } + static Future _saveSpectrogramToCache( + String filePath, + ui.Image image, + ) async { + try { + final dir = await _cacheDir(); + final key = _cacheKey(filePath); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + if (byteData != null) { + final file = File('${dir.path}/$key.png'); + await file.writeAsBytes(byteData.buffer.asUint8List()); + } + } catch (_) {} + } + + static Future _loadSpectrogramFromCache(String filePath) async { + try { + final dir = await _cacheDir(); + final key = _cacheKey(filePath); + final file = File('${dir.path}/$key.png'); + if (!await file.exists()) return null; + + final bytes = await file.readAsBytes(); + final completer = Completer(); + ui.decodeImageFromList(bytes, completer.complete); + return completer.future; + } catch (_) { + return null; + } + } + Future _runAnalysis(String filePath) async { await FFmpegKitConfig.setLogLevel(Level.avLogError); From 8979210804fc07f530d0db095d97abf74fc38090 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Mar 2026 04:24:19 +0700 Subject: [PATCH 26/33] fix: null check crash in SpectrogramView when spectrum loaded from PNG cache --- lib/widgets/audio_analysis_widget.dart | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 6b2b0a90..41a9ba1b 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -593,7 +593,11 @@ class _AudioAnalysisCardState extends State { _AudioInfoCard(data: data), if (_spectrogramImage != null) ...[ const SizedBox(height: 12), - _SpectrogramView(image: _spectrogramImage!, spectrum: data.spectrum!), + _SpectrogramView( + image: _spectrogramImage!, + sampleRate: data.sampleRate, + maxFreq: data.spectrum?.maxFreq ?? data.sampleRate / 2, + ), ], ], ); @@ -966,9 +970,14 @@ class _MetricChip extends StatelessWidget { class _SpectrogramView extends StatelessWidget { final ui.Image image; - final SpectrogramData spectrum; + final int sampleRate; + final double maxFreq; - const _SpectrogramView({required this.image, required this.spectrum}); + const _SpectrogramView({ + required this.image, + required this.sampleRate, + required this.maxFreq, + }); @override Widget build(BuildContext context) { @@ -992,12 +1001,12 @@ class _SpectrogramView extends StatelessWidget { child: Row( children: [ Text( - '${context.l10n.audioAnalysisSampleRate}: ${spectrum.sampleRate} Hz', + '${context.l10n.audioAnalysisSampleRate}: $sampleRate Hz', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), ), const Spacer(), Text( - '${context.l10n.audioAnalysisNyquist}: ${(spectrum.maxFreq / 1000).toStringAsFixed(1)} kHz', + '${context.l10n.audioAnalysisNyquist}: ${(maxFreq / 1000).toStringAsFixed(1)} kHz', style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11), ), ], From 0b20cb895ecb4bd6a3156afea7fd53e8b2e5e69a Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Mar 2026 04:35:22 +0700 Subject: [PATCH 27/33] fix: conditionally show cover header in artist skeleton and add showCoverHeader param to ArtistScreenSkeleton --- lib/screens/artist_screen.dart | 10 +++++++++- lib/widgets/animation_utils.dart | 14 +++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 401a90ac..c21825cd 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -492,7 +492,15 @@ class _ArtistScreenState extends ConsumerState { hasDiscography: hasDiscography, ), if (_isLoadingDiscography) - const SliverToBoxAdapter(child: ArtistScreenSkeleton()), + SliverToBoxAdapter( + child: ArtistScreenSkeleton( + showCoverHeader: + (_headerImageUrl ?? + widget.headerImageUrl ?? + widget.coverUrl) == + null, + ), + ), if (_error != null) SliverToBoxAdapter( child: Padding( diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index cfecb731..c52c0b0b 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -443,11 +443,13 @@ class GridSkeleton extends StatelessWidget { class ArtistScreenSkeleton extends StatelessWidget { final int popularCount; final int albumCount; + final bool showCoverHeader; const ArtistScreenSkeleton({ super.key, this.popularCount = 5, this.albumCount = 5, + this.showCoverHeader = true, }); @override @@ -459,11 +461,13 @@ class ArtistScreenSkeleton extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - SkeletonBox( - width: screenWidth, - height: screenWidth * 0.75, - borderRadius: 0, - ), + if (showCoverHeader) ...[ + SkeletonBox( + width: screenWidth, + height: screenWidth * 0.75, + borderRadius: 0, + ), + ], Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 4), child: SkeletonBox(width: 180, height: 24, borderRadius: 4), From 3fd13e9930fa21763929cd401eee65a5bc9d6bd3 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Mar 2026 04:39:29 +0700 Subject: [PATCH 28/33] fix(ui): match GridSkeleton cover height with actual album cards --- lib/widgets/animation_utils.dart | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index c52c0b0b..30f0efff 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -405,16 +405,19 @@ class GridSkeleton extends StatelessWidget { crossAxisCount: crossAxisCount, mainAxisSpacing: 12, crossAxisSpacing: 12, - childAspectRatio: 0.78, + childAspectRatio: 0.75, ), itemCount: itemCount, itemBuilder: (context, index) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const AspectRatio( - aspectRatio: 1, - child: SkeletonBox(width: double.infinity, height: 0), + const Expanded( + child: SkeletonBox( + width: double.infinity, + height: double.infinity, + borderRadius: 12, + ), ), const SizedBox(height: 8), SkeletonBox( From f7c0e417d74197ecd0f1628573953975f61f1c42 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Mar 2026 04:50:40 +0700 Subject: [PATCH 29/33] refactor: unexport extension store types and methods (package-internal only) --- go_backend/exports.go | 36 ++++++------- go_backend/extension_store.go | 95 ++++++++++++++++++----------------- 2 files changed, 66 insertions(+), 65 deletions(-) diff --git a/go_backend/exports.go b/go_backend/exports.go index f104e763..a5d17122 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -3111,17 +3111,17 @@ func GetPostProcessingProvidersJSON() (string, error) { } func InitExtensionStoreJSON(cacheDir string) error { - InitExtensionStore(cacheDir) + initExtensionStore(cacheDir) return nil } func SetStoreRegistryURLJSON(registryURL string) error { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return fmt.Errorf("extension store not initialized") } - resolved, err := ResolveRegistryURL(registryURL) + resolved, err := resolveRegistryURL(registryURL) if err != nil { return err } @@ -3130,32 +3130,32 @@ func SetStoreRegistryURLJSON(registryURL string) error { return err } - store.SetRegistryURL(resolved) + store.setRegistryURL(resolved) return nil } func ClearStoreRegistryURLJSON() error { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return fmt.Errorf("extension store not initialized") } - store.SetRegistryURL("") - store.ClearCache() + store.setRegistryURL("") + store.clearCache() return nil } func GetStoreRegistryURLJSON() (string, error) { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return "", fmt.Errorf("extension store not initialized") } - return store.GetRegistryURL(), nil + return store.getRegistryURL(), nil } func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return "", fmt.Errorf("extension store not initialized") } @@ -3174,12 +3174,12 @@ func GetStoreExtensionsJSON(forceRefresh bool) (string, error) { } func SearchStoreExtensionsJSON(query, category string) (string, error) { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return "", fmt.Errorf("extension store not initialized") } - extensions, err := store.SearchExtensions(query, category) + extensions, err := store.searchExtensions(query, category) if err != nil { return "", err } @@ -3193,12 +3193,12 @@ func SearchStoreExtensionsJSON(query, category string) (string, error) { } func GetStoreCategoriesJSON() (string, error) { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return "", fmt.Errorf("extension store not initialized") } - categories := store.GetCategories() + categories := store.getCategories() jsonBytes, err := json.Marshal(categories) if err != nil { return "", err @@ -3217,7 +3217,7 @@ func buildStoreExtensionDestPath(destDir, extensionID string) (string, error) { } func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return "", fmt.Errorf("extension store not initialized") } @@ -3226,7 +3226,7 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { if err != nil { return "", err } - err = store.DownloadExtension(extensionID, destPath) + err = store.downloadExtension(extensionID, destPath) if err != nil { return "", err } @@ -3235,12 +3235,12 @@ func DownloadStoreExtensionJSON(extensionID, destDir string) (string, error) { } func ClearStoreCacheJSON() error { - store := GetExtensionStore() + store := getExtensionStore() if store == nil { return fmt.Errorf("extension store not initialized") } - store.ClearCache() + store.clearCache() return nil } diff --git a/go_backend/extension_store.go b/go_backend/extension_store.go index 6378139a..3a2c479b 100644 --- a/go_backend/extension_store.go +++ b/go_backend/extension_store.go @@ -21,7 +21,7 @@ const ( CategoryIntegration = "integration" ) -type StoreExtension struct { +type storeExtension struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"display_name,omitempty"` @@ -41,7 +41,7 @@ type StoreExtension struct { MinAppVersionAlt string `json:"minAppVersion,omitempty"` } -func (e *StoreExtension) getDisplayName() string { +func (e *storeExtension) getDisplayName() string { if e.DisplayName != "" { return e.DisplayName } @@ -51,34 +51,34 @@ func (e *StoreExtension) getDisplayName() string { return e.Name } -func (e *StoreExtension) getDownloadURL() string { +func (e *storeExtension) getDownloadURL() string { if e.DownloadURL != "" { return e.DownloadURL } return e.DownloadURLAlt } -func (e *StoreExtension) getIconURL() string { +func (e *storeExtension) getIconURL() string { if e.IconURL != "" { return e.IconURL } return e.IconURLAlt } -func (e *StoreExtension) getMinAppVersion() string { +func (e *storeExtension) getMinAppVersion() string { if e.MinAppVersion != "" { return e.MinAppVersion } return e.MinAppVersionAlt } -type StoreRegistry struct { +type storeRegistry struct { Version int `json:"version"` UpdatedAt string `json:"updated_at"` - Extensions []StoreExtension `json:"extensions"` + Extensions []storeExtension `json:"extensions"` } -type StoreExtensionResponse struct { +type storeExtensionResponse struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"display_name"` @@ -97,8 +97,8 @@ type StoreExtensionResponse struct { HasUpdate bool `json:"has_update"` } -func (e *StoreExtension) ToResponse() *StoreExtensionResponse { - return &StoreExtensionResponse{ +func (e *storeExtension) toResponse() storeExtensionResponse { + resp := storeExtensionResponse{ ID: e.ID, Name: e.Name, DisplayName: e.getDisplayName(), @@ -108,25 +108,30 @@ func (e *StoreExtension) ToResponse() *StoreExtensionResponse { DownloadURL: e.getDownloadURL(), IconURL: e.getIconURL(), Category: e.Category, - Tags: e.Tags, Downloads: e.Downloads, UpdatedAt: e.UpdatedAt, MinAppVersion: e.getMinAppVersion(), } + + if len(e.Tags) > 0 { + resp.Tags = append([]string(nil), e.Tags...) + } + + return resp } -type ExtensionStore struct { +type extensionStore struct { registryURL string cacheDir string - cache *StoreRegistry + cache *storeRegistry cacheMu sync.RWMutex cacheTime time.Time cacheTTL time.Duration } var ( - extensionStore *ExtensionStore - extensionStoreMu sync.Mutex + globalExtensionStore *extensionStore + extensionStoreMu sync.Mutex ) const ( @@ -134,24 +139,24 @@ const ( cacheFileName = "store_cache.json" ) -func InitExtensionStore(cacheDir string) *ExtensionStore { +func initExtensionStore(cacheDir string) *extensionStore { extensionStoreMu.Lock() defer extensionStoreMu.Unlock() - if extensionStore == nil { - extensionStore = &ExtensionStore{ + if globalExtensionStore == nil { + globalExtensionStore = &extensionStore{ registryURL: "", // No default - user must provide a registry URL cacheDir: cacheDir, cacheTTL: cacheTTL, } - extensionStore.loadDiskCache() + globalExtensionStore.loadDiskCache() } - return extensionStore + return globalExtensionStore } // SetRegistryURL updates the registry URL and clears the in-memory cache // so the next fetch will use the new URL. Disk cache is also cleared. -func (s *ExtensionStore) SetRegistryURL(registryURL string) { +func (s *extensionStore) setRegistryURL(registryURL string) { s.cacheMu.Lock() defer s.cacheMu.Unlock() @@ -173,19 +178,19 @@ func (s *ExtensionStore) SetRegistryURL(registryURL string) { } // GetRegistryURL returns the currently configured registry URL. -func (s *ExtensionStore) GetRegistryURL() string { +func (s *extensionStore) getRegistryURL() string { s.cacheMu.RLock() defer s.cacheMu.RUnlock() return s.registryURL } -func GetExtensionStore() *ExtensionStore { +func getExtensionStore() *extensionStore { extensionStoreMu.Lock() defer extensionStoreMu.Unlock() - return extensionStore + return globalExtensionStore } -func (s *ExtensionStore) loadDiskCache() { +func (s *extensionStore) loadDiskCache() { if s.cacheDir == "" { return } @@ -197,7 +202,7 @@ func (s *ExtensionStore) loadDiskCache() { } var cacheData struct { - Registry StoreRegistry `json:"registry"` + Registry storeRegistry `json:"registry"` CacheTime int64 `json:"cache_time"` } @@ -210,13 +215,13 @@ func (s *ExtensionStore) loadDiskCache() { LogDebug("ExtensionStore", "Loaded %d extensions from disk cache", len(s.cache.Extensions)) } -func (s *ExtensionStore) saveDiskCache() { +func (s *extensionStore) saveDiskCache() { if s.cacheDir == "" || s.cache == nil { return } cacheData := struct { - Registry StoreRegistry `json:"registry"` + Registry storeRegistry `json:"registry"` CacheTime int64 `json:"cache_time"` }{ Registry: *s.cache, @@ -232,7 +237,7 @@ func (s *ExtensionStore) saveDiskCache() { os.WriteFile(cachePath, data, 0644) } -func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error) { +func (s *extensionStore) fetchRegistry(forceRefresh bool) (*storeRegistry, error) { s.cacheMu.Lock() defer s.cacheMu.Unlock() @@ -275,7 +280,7 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error return nil, fmt.Errorf("failed to read registry: %w", err) } - var registry StoreRegistry + var registry storeRegistry if err := json.Unmarshal(body, ®istry); err != nil { return nil, fmt.Errorf("failed to parse registry: %w", err) } @@ -288,8 +293,8 @@ func (s *ExtensionStore) FetchRegistry(forceRefresh bool) (*StoreRegistry, error return ®istry, nil } -func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExtensionResponse, error) { - registry, err := s.FetchRegistry(forceRefresh) +func (s *extensionStore) getExtensionsWithStatus(forceRefresh bool) ([]storeExtensionResponse, error) { + registry, err := s.fetchRegistry(forceRefresh) if err != nil { return nil, err } @@ -305,10 +310,10 @@ func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExt LogDebug("ExtensionStore", "Building store response for %d registry extensions (%d installed)", len(registry.Extensions), len(installed)) - result := make([]*StoreExtensionResponse, 0, len(registry.Extensions)) + result := make([]storeExtensionResponse, 0, len(registry.Extensions)) for i := range registry.Extensions { ext := ®istry.Extensions[i] - resp := ext.ToResponse() + resp := ext.toResponse() if installedVersion, ok := installed[ext.ID]; ok { resp.IsInstalled = true resp.InstalledVersion = installedVersion @@ -322,17 +327,13 @@ func (s *ExtensionStore) getExtensionsWithStatus(forceRefresh bool) ([]*StoreExt return result, nil } -func (s *ExtensionStore) GetExtensionsWithStatus() ([]*StoreExtensionResponse, error) { - return s.getExtensionsWithStatus(false) -} - -func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) error { - registry, err := s.FetchRegistry(false) +func (s *extensionStore) downloadExtension(extensionID string, destPath string) error { + registry, err := s.fetchRegistry(false) if err != nil { return err } - var ext *StoreExtension + var ext *storeExtension for _, e := range registry.Extensions { if e.ID == extensionID { ext = &e @@ -384,7 +385,7 @@ func (s *ExtensionStore) DownloadExtension(extensionID string, destPath string) // - https://github.com/owner/repo (with optional trailing path / .git) → resolved via // the GitHub API to discover the default branch, then converted to the raw URL // - Any other HTTPS URL → returned as-is (assumed to be a direct link) -func ResolveRegistryURL(input string) (string, error) { +func resolveRegistryURL(input string) (string, error) { input = strings.TrimSpace(input) if input == "" { return "", fmt.Errorf("registry URL is empty") @@ -465,7 +466,7 @@ func requireHTTPSURL(rawURL string, context string) error { return nil } -func (s *ExtensionStore) GetCategories() []string { +func (s *extensionStore) getCategories() []string { return []string{ CategoryMetadata, CategoryDownload, @@ -475,8 +476,8 @@ func (s *ExtensionStore) GetCategories() []string { } } -func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*StoreExtensionResponse, error) { - extensions, err := s.GetExtensionsWithStatus() +func (s *extensionStore) searchExtensions(query string, category string) ([]storeExtensionResponse, error) { + extensions, err := s.getExtensionsWithStatus(false) if err != nil { return nil, err } @@ -485,7 +486,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*Sto return extensions, nil } - result := make([]*StoreExtensionResponse, 0, len(extensions)) + result := make([]storeExtensionResponse, 0, len(extensions)) queryLower := toLower(query) for _, ext := range extensions { @@ -517,7 +518,7 @@ func (s *ExtensionStore) SearchExtensions(query string, category string) ([]*Sto return result, nil } -func (s *ExtensionStore) ClearCache() { +func (s *extensionStore) clearCache() { s.cacheMu.Lock() defer s.cacheMu.Unlock() From 18d3612674092a5a133ad622e6e5fce437cf27b2 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Mar 2026 13:27:07 +0700 Subject: [PATCH 30/33] fix(ui): skip popular section in artist skeleton for providers without top tracks --- lib/screens/artist_screen.dart | 4 ++ lib/widgets/animation_utils.dart | 98 +++++++++++++++++--------------- 2 files changed, 57 insertions(+), 45 deletions(-) diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index c21825cd..78549d98 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -499,6 +499,10 @@ class _ArtistScreenState extends ConsumerState { widget.headerImageUrl ?? widget.coverUrl) == null, + showPopularSection: + !widget.artistId.startsWith('deezer:') && + !widget.artistId.startsWith('qobuz:') && + !widget.artistId.startsWith('tidal:'), ), ), if (_error != null) diff --git a/lib/widgets/animation_utils.dart b/lib/widgets/animation_utils.dart index 30f0efff..14c3d978 100644 --- a/lib/widgets/animation_utils.dart +++ b/lib/widgets/animation_utils.dart @@ -447,12 +447,14 @@ class ArtistScreenSkeleton extends StatelessWidget { final int popularCount; final int albumCount; final bool showCoverHeader; + final bool showPopularSection; const ArtistScreenSkeleton({ super.key, this.popularCount = 5, this.albumCount = 5, this.showCoverHeader = true, + this.showPopularSection = true, }); @override @@ -479,55 +481,61 @@ class ArtistScreenSkeleton extends StatelessWidget { padding: const EdgeInsets.fromLTRB(16, 4, 16, 16), child: SkeletonBox(width: 120, height: 14, borderRadius: 4), ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), - child: SkeletonBox(width: 90, height: 20, borderRadius: 4), - ), - ...List.generate(popularCount, (index) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 8, - ), - child: Row( - children: [ - SizedBox( - width: 24, - child: Center( - child: SkeletonBox( - width: 12, - height: 14, - borderRadius: 4, - ), - ), - ), - const SizedBox(width: 12), - const SkeletonBox(width: 48, height: 48, borderRadius: 4), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SkeletonBox( - width: 110 + (index % 4) * 30, + if (showPopularSection) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: SkeletonBox(width: 90, height: 20, borderRadius: 4), + ), + ...List.generate(popularCount, (index) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Row( + children: [ + SizedBox( + width: 24, + child: Center( + child: SkeletonBox( + width: 12, height: 14, borderRadius: 4, ), - const SizedBox(height: 6), - SkeletonBox( - width: 70 + (index % 3) * 15, - height: 11, - borderRadius: 4, - ), - ], + ), ), - ), - const SkeletonBox(width: 20, height: 20, borderRadius: 10), - ], - ), - ); - }), - const SizedBox(height: 16), + const SizedBox(width: 12), + const SkeletonBox(width: 48, height: 48, borderRadius: 4), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SkeletonBox( + width: 110 + (index % 4) * 30, + height: 14, + borderRadius: 4, + ), + const SizedBox(height: 6), + SkeletonBox( + width: 70 + (index % 3) * 15, + height: 11, + borderRadius: 4, + ), + ], + ), + ), + const SkeletonBox( + width: 20, + height: 20, + borderRadius: 10, + ), + ], + ), + ); + }), + const SizedBox(height: 16), + ], Padding( padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), child: SkeletonBox(width: 80, height: 20, borderRadius: 4), From f29177216dd3fd34522746f9b699d349af75715b Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Mar 2026 19:28:42 +0700 Subject: [PATCH 31/33] refactor: enable strict analysis options and fix type safety across codebase Enable strict-casts, strict-inference, and strict-raw-types in analysis_options.yaml. Add custom_lint with riverpod_lint. Fix all resulting type warnings with explicit type parameters and safer casts. Also improves APK update checker to detect device ABIs for correct variant selection and fixes Deezer artist name parsing edge case. --- analysis_options.yaml | 20 +++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 1 + lib/providers/download_queue_provider.dart | 16 +- .../library_collections_provider.dart | 8 +- lib/providers/settings_provider.dart | 12 +- lib/providers/track_provider.dart | 14 +- lib/screens/artist_screen.dart | 16 +- lib/screens/downloaded_album_screen.dart | 4 +- lib/screens/home_tab.dart | 48 ++--- lib/screens/library_playlists_screen.dart | 4 +- lib/screens/library_tracks_folder_screen.dart | 18 +- lib/screens/local_album_screen.dart | 2 +- lib/screens/main_shell.dart | 6 +- lib/screens/playlist_screen.dart | 2 +- lib/screens/queue_tab.dart | 14 +- .../settings/appearance_settings_page.dart | 2 +- .../settings/download_settings_page.dart | 22 +-- .../settings/extension_detail_page.dart | 8 +- lib/screens/settings/extensions_page.dart | 17 +- .../settings/library_settings_page.dart | 2 +- lib/screens/settings/log_screen.dart | 2 +- .../settings/options_settings_page.dart | 4 +- lib/screens/settings/settings_tab.dart | 2 +- lib/screens/setup_screen.dart | 6 +- lib/screens/store_tab.dart | 4 +- lib/screens/track_metadata_screen.dart | 14 +- lib/screens/tutorial_screen.dart | 4 +- lib/services/app_state_database.dart | 4 +- lib/services/csv_import_service.dart | 2 +- lib/services/history_database.dart | 4 +- .../library_collections_database.dart | 16 +- lib/services/platform_bridge.dart | 4 +- lib/services/share_intent_service.dart | 2 +- lib/services/update_checker.dart | 170 +++++++++++++++--- lib/utils/clickable_metadata.dart | 6 +- lib/utils/logger.dart | 11 +- lib/widgets/audio_analysis_widget.dart | 4 +- lib/widgets/download_service_picker.dart | 2 +- .../track_collection_quick_actions.dart | 2 +- pubspec.lock | 68 ++++++- pubspec.yaml | 4 +- 41 files changed, 397 insertions(+), 174 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d290213..577a9667 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -9,6 +9,19 @@ # packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml +analyzer: + exclude: + - build/** + - .dart_tool/** + - lib/**/*.g.dart + - lib/l10n/*.dart + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + plugins: + - custom_lint + linter: # The lint rules applied to this project can be customized in the # section below to disable rules from the `package:flutter_lints/flutter.yaml` @@ -23,6 +36,13 @@ linter: rules: # avoid_print: false # Uncomment to disable the `avoid_print` rule # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + avoid_dynamic_calls: true + cancel_subscriptions: true + close_sinks: true + +custom_lint: + rules: + - avoid_public_notifier_properties # Additional information about this file can be found at # https://dart.dev/guides/language/analysis-options diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index dbdd74ff..497da4cf 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -304,6 +304,7 @@ class MainActivity: FlutterFragmentActivity() { ".mp3" -> "audio/mpeg" ".opus" -> "audio/ogg" ".flac" -> "audio/flac" + ".lrc" -> "application/octet-stream" else -> "application/octet-stream" } } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 7ac7453a..e157bec6 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -510,7 +510,7 @@ class DownloadHistoryNotifier extends Notifier { } if ((c + 1) % _safRepairBatchSize == 0) { - await Future.delayed(const Duration(milliseconds: 16)); + await Future.delayed(const Duration(milliseconds: 16)); } } @@ -762,7 +762,7 @@ class DownloadHistoryNotifier extends Notifier { _historyLog.d('Added new history entry: ${mergedItem.trackName}'); } - _db.upsert(mergedItem.toJson()).catchError((e) { + _db.upsert(mergedItem.toJson()).catchError((Object e) { _historyLog.e('Failed to save to database: $e'); }); } @@ -771,7 +771,7 @@ class DownloadHistoryNotifier extends Notifier { state = state.copyWith( items: state.items.where((item) => item.id != id).toList(), ); - _db.deleteById(id).catchError((e) { + _db.deleteById(id).catchError((Object e) { _historyLog.e('Failed to delete from database: $e'); }); } @@ -780,7 +780,7 @@ class DownloadHistoryNotifier extends Notifier { state = state.copyWith( items: state.items.where((item) => item.spotifyId != spotifyId).toList(), ); - _db.deleteBySpotifyId(spotifyId).catchError((e) { + _db.deleteBySpotifyId(spotifyId).catchError((Object e) { _historyLog.e('Failed to delete from database: $e'); }); _historyLog.d('Removed item with spotifyId: $spotifyId'); @@ -1081,7 +1081,7 @@ class DownloadHistoryNotifier extends Notifier { void clearHistory() { state = DownloadHistoryState(); - _db.clearAll().catchError((e) { + _db.clearAll().catchError((Object e) { _historyLog.e('Failed to clear database: $e'); }); } @@ -3602,7 +3602,7 @@ class DownloadQueueNotifier extends Notifier { _log.d('Queue is paused, waiting for active downloads...'); await Future.any([ Future.wait(activeDownloads.values), - Future.delayed(_queueSchedulingInterval), + Future.delayed(_queueSchedulingInterval), ]); continue; } @@ -3647,10 +3647,10 @@ class DownloadQueueNotifier extends Notifier { if (activeDownloads.isNotEmpty) { await Future.any([ Future.any(activeDownloads.values), - Future.delayed(_queueSchedulingInterval), + Future.delayed(_queueSchedulingInterval), ]); } else { - await Future.delayed(_queueSchedulingInterval); + await Future.delayed(_queueSchedulingInterval); } } diff --git a/lib/providers/library_collections_provider.dart b/lib/providers/library_collections_provider.dart index 0fa89735..d6fece8e 100644 --- a/lib/providers/library_collections_provider.dart +++ b/lib/providers/library_collections_provider.dart @@ -118,7 +118,7 @@ class UserPlaylistCollection { createdAt: createdAt, updatedAt: updatedAt, tracks: tracksRaw - .whereType() + .whereType>() .map( (e) => CollectionTrackEntry.fromJson(Map.from(e)), ) @@ -233,19 +233,19 @@ class LibraryCollectionsState { return LibraryCollectionsState( wishlist: wishlistRaw - .whereType() + .whereType>() .map( (e) => CollectionTrackEntry.fromJson(Map.from(e)), ) .toList(growable: false), loved: lovedRaw - .whereType() + .whereType>() .map( (e) => CollectionTrackEntry.fromJson(Map.from(e)), ) .toList(growable: false), playlists: playlistsRaw - .whereType() + .whereType>() .map( (e) => UserPlaylistCollection.fromJson(Map.from(e)), diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 77feff76..832ecff5 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -34,7 +34,9 @@ class SettingsNotifier extends Notifier { final prefs = await _prefs; final json = prefs.getString(_settingsKey); if (json != null) { - state = AppSettings.fromJson(jsonDecode(json)); + state = AppSettings.fromJson( + Map.from(jsonDecode(json) as Map), + ); await _runMigrations(prefs); await _normalizeIosDownloadDirectoryIfNeeded(); @@ -52,7 +54,9 @@ class SettingsNotifier extends Notifier { void _syncLyricsSettingsToBackend() { if (!PlatformBridge.supportsCoreBackend) return; - PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) { + PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError(( + Object e, + ) { _log.w('Failed to sync lyrics providers to backend: $e'); }); @@ -61,7 +65,7 @@ class SettingsNotifier extends Notifier { 'include_romanization_netease': state.lyricsIncludeRomanizationNetease, 'multi_person_word_by_word': state.lyricsMultiPersonWordByWord, 'musixmatch_language': state.musixmatchLanguage, - }).catchError((e) { + }).catchError((Object e) { _log.w('Failed to sync lyrics fetch options to backend: $e'); }); } @@ -73,7 +77,7 @@ class SettingsNotifier extends Notifier { PlatformBridge.setNetworkCompatibilityOptions( allowHttp: compatibilityMode, insecureTls: compatibilityMode, - ).catchError((e) { + ).catchError((Object e) { _log.w('Failed to sync network compatibility options to backend: $e'); }); } diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index e3cfaf09..20780c49 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -234,7 +234,7 @@ class TrackNotifier extends Notifier { } if (attempt < 3) { - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); } } @@ -275,10 +275,12 @@ class TrackNotifier extends Notifier { state = TrackState( tracks: tracks, isLoading: false, - albumId: result['album']?['id'] as String?, + albumId: + (result['album'] as Map?)?['id'] as String?, albumName: result['name'] as String? ?? - result['album']?['name'] as String?, + (result['album'] as Map?)?['name'] + as String?, playlistName: type == 'playlist' ? result['name'] as String? : null, @@ -825,8 +827,7 @@ class TrackNotifier extends Notifier { isLoading: true, hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, - selectedSearchFilter: - state.selectedSearchFilter, + selectedSearchFilter: state.selectedSearchFilter, ); try { @@ -921,8 +922,7 @@ class TrackNotifier extends Notifier { final tracks = List.from(state.tracks); tracks[index] = updatedTrack; state = state.copyWith(tracks: tracks); - } catch (_) { - } + } catch (_) {} } void clear() { diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 78549d98..1267b694 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -805,7 +805,7 @@ class _ArtistScreenState extends ConsumerState { ); final singleTracks = singles.fold(0, (sum, a) => sum + a.totalTracks); - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -939,7 +939,7 @@ class _ArtistScreenState extends ConsumerState { return; } - showDialog( + showDialog( context: context, barrierDismissible: false, builder: (ctx) => _FetchingProgressDialog( @@ -1121,6 +1121,10 @@ class _ArtistScreenState extends ConsumerState { Track _parseTrackFromDeezer(Map data, ArtistAlbum album) { int durationMs = 0; final durationValue = data['duration']; + final artistData = data['artist']; + final artistName = artistData is Map + ? (artistData['name'] as String? ?? widget.artistName) + : (artistData?.toString() ?? widget.artistName); if (durationValue is int) { durationMs = durationValue * 1000; // Deezer returns seconds } else if (durationValue is double) { @@ -1130,9 +1134,7 @@ class _ArtistScreenState extends ConsumerState { return Track( id: 'deezer:${data['id']}', name: (data['title'] ?? data['name'] ?? '').toString(), - artistName: - (data['artist']?['name'] ?? data['artist'] ?? widget.artistName) - .toString(), + artistName: artistName, albumName: album.name, albumArtist: widget.artistName, artistId: widget.artistId, @@ -1938,7 +1940,7 @@ class _ArtistScreenState extends ConsumerState { if (album.providerId != null && album.providerId!.isNotEmpty) { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: album.providerId!, albumId: album.id, @@ -1950,7 +1952,7 @@ class _ArtistScreenState extends ConsumerState { } else { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => AlbumScreen( albumId: album.id, albumName: album.name, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index ca41f0f1..3a35dee0 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -309,7 +309,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { if (!mounted) return; final result = await navigator.push( - slidePageRoute(page: TrackMetadataScreen(item: item)), + slidePageRoute(page: TrackMetadataScreen(item: item)), ); await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( item.filePath, @@ -932,7 +932,7 @@ class _DownloadedAlbumScreenState extends ConsumerState { ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k'); - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, shape: const RoundedRectangleBorder( diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 504e7edc..5483bc7d 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -556,7 +556,7 @@ class _HomeTabState extends ConsumerState pending != query && mounted && _urlController.text.trim() == pending) { - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); if (mounted && _urlController.text.trim() == pending) { _executeLiveSearch(pending); } @@ -681,7 +681,7 @@ class _HomeTabState extends ConsumerState final extensionId = trackState.searchExtensionId; Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => AlbumScreen( albumId: trackState.albumId!, albumName: trackState.albumName!, @@ -708,7 +708,7 @@ class _HomeTabState extends ConsumerState Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => PlaylistScreen( playlistName: trackState.playlistName!, coverUrl: trackState.coverUrl, @@ -729,7 +729,7 @@ class _HomeTabState extends ConsumerState final extensionId = trackState.searchExtensionId; Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ArtistScreen( artistId: trackState.artistId!, artistName: trackState.artistName!, @@ -798,7 +798,7 @@ class _HomeTabState extends ConsumerState if (progressDialogInitialized || !mounted) return; progressDialogInitialized = true; progressDialogVisible = true; - showDialog( + showDialog( context: this.context, useRootNavigator: false, barrierDismissible: false, @@ -1691,7 +1691,7 @@ class _HomeTabState extends ConsumerState case 'album': Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: extensionId, albumId: item.id, @@ -1704,7 +1704,7 @@ class _HomeTabState extends ConsumerState case 'playlist': Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionPlaylistScreen( extensionId: extensionId, playlistId: item.id, @@ -1717,7 +1717,7 @@ class _HomeTabState extends ConsumerState case 'artist': Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionArtistScreen( extensionId: extensionId, artistId: item.id, @@ -1738,7 +1738,7 @@ class _HomeTabState extends ConsumerState void _showTrackBottomSheet(ExploreItem item) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surface, @@ -1884,7 +1884,7 @@ class _HomeTabState extends ConsumerState if (item.albumId != null && item.albumId!.isNotEmpty) { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: item.providerId ?? 'spotify-web', albumId: item.albumId!, @@ -2148,7 +2148,7 @@ class _HomeTabState extends ConsumerState item.providerId != 'qobuz') { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionArtistScreen( extensionId: item.providerId!, artistId: item.id, @@ -2160,7 +2160,7 @@ class _HomeTabState extends ConsumerState } else { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ArtistScreen( artistId: item.id, artistName: item.name, @@ -2174,7 +2174,7 @@ class _HomeTabState extends ConsumerState if (item.providerId == 'download') { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => DownloadedAlbumScreen( albumName: item.name, artistName: item.subtitle ?? '', @@ -2190,7 +2190,7 @@ class _HomeTabState extends ConsumerState item.providerId != 'qobuz') { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: item.providerId!, albumId: item.id, @@ -2202,7 +2202,7 @@ class _HomeTabState extends ConsumerState } else { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => AlbumScreen( albumId: item.id, albumName: item.name, @@ -2240,7 +2240,7 @@ class _HomeTabState extends ConsumerState item.providerId != 'qobuz') { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionPlaylistScreen( extensionId: item.providerId!, playlistId: item.id, @@ -2252,7 +2252,7 @@ class _HomeTabState extends ConsumerState } else { Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => PlaylistScreen( playlistName: item.name, coverUrl: item.imageUrl, @@ -2275,7 +2275,7 @@ class _HomeTabState extends ConsumerState ); if (!mounted) return; final result = await navigator.push( - slidePageRoute(page: TrackMetadataScreen(item: item)), + slidePageRoute(page: TrackMetadataScreen(item: item)), ); await DownloadedEmbeddedCoverResolver.scheduleRefreshForPath( item.filePath, @@ -2910,7 +2910,7 @@ class _HomeTabState extends ConsumerState Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ArtistScreen( artistId: artistId, artistName: artistName, @@ -2936,7 +2936,7 @@ class _HomeTabState extends ConsumerState // Keep the full ID with prefix (e.g., "deezer:123") for AlbumScreen to detect source Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => AlbumScreen( albumId: album.id, albumName: album.name, @@ -2963,7 +2963,7 @@ class _HomeTabState extends ConsumerState // Keep the full ID with prefix (e.g., "deezer:123") for PlaylistScreen to detect source Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => PlaylistScreen( playlistName: playlist.name, coverUrl: playlist.imageUrl, @@ -2999,7 +2999,7 @@ class _HomeTabState extends ConsumerState Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionAlbumScreen( extensionId: extensionId, albumId: albumItem.id, @@ -3035,7 +3035,7 @@ class _HomeTabState extends ConsumerState Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionPlaylistScreen( extensionId: extensionId, playlistId: playlistItem.id, @@ -3070,7 +3070,7 @@ class _HomeTabState extends ConsumerState Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionArtistScreen( extensionId: extensionId, artistId: artistItem.id, diff --git a/lib/screens/library_playlists_screen.dart b/lib/screens/library_playlists_screen.dart index d3258424..388a8327 100644 --- a/lib/screens/library_playlists_screen.dart +++ b/lib/screens/library_playlists_screen.dart @@ -119,7 +119,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { ), onTap: () { Navigator.of(context).push( - MaterialPageRoute( + MaterialPageRoute( builder: (_) => LibraryTracksFolderScreen( mode: LibraryTracksFolderMode.playlist, playlistId: playlist.id, @@ -149,7 +149,7 @@ class LibraryPlaylistsScreen extends ConsumerWidget { ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, diff --git a/lib/screens/library_tracks_folder_screen.dart b/lib/screens/library_tracks_folder_screen.dart index 17444c7b..a4acc687 100644 --- a/lib/screens/library_tracks_folder_screen.dart +++ b/lib/screens/library_tracks_folder_screen.dart @@ -847,7 +847,7 @@ class _LibraryTracksFolderScreenState void _confirmDownloadAll(List tracks) { if (tracks.isEmpty) return; - showDialog( + showDialog( context: context, builder: (dialogContext) { final colorScheme = Theme.of(dialogContext).colorScheme; @@ -980,7 +980,7 @@ class _LibraryTracksFolderScreenState void _showCoverOptionsSheet(BuildContext context, bool hasCustomCover) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1338,7 +1338,7 @@ class _CollectionTrackTile extends ConsumerWidget { final showAddToPlaylist = mode != LibraryTracksFolderMode.wishlist || isDownloaded; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1523,9 +1523,9 @@ class _CollectionTrackTile extends ConsumerWidget { ); if (historyItem != null) { - await Navigator.of( - context, - ).push(slidePageRoute(page: TrackMetadataScreen(item: historyItem))); + await Navigator.of(context).push( + slidePageRoute(page: TrackMetadataScreen(item: historyItem)), + ); return; } @@ -1540,9 +1540,9 @@ class _CollectionTrackTile extends ConsumerWidget { localItem ??= localState.findByTrackAndArtist(track.name, track.artistName); if (localItem != null) { - await Navigator.of( - context, - ).push(slidePageRoute(page: TrackMetadataScreen(localItem: localItem))); + await Navigator.of(context).push( + slidePageRoute(page: TrackMetadataScreen(localItem: localItem)), + ); return; } diff --git a/lib/screens/local_album_screen.dart b/lib/screens/local_album_screen.dart index ed0c6e36..09ff33df 100644 --- a/lib/screens/local_album_screen.dart +++ b/lib/screens/local_album_screen.dart @@ -1180,7 +1180,7 @@ class _LocalAlbumScreenState extends ConsumerState { ? '320k' : (selectedFormat == 'Opus' ? '128k' : '320k'); - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, shape: const RoundedRectangleBorder( diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 49bbe89c..c6a8b768 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -79,7 +79,7 @@ class _MainShellState extends ConsumerState _log.d('Received shared URL from stream: $url'); _handleSharedUrl(url); }, - onError: (error) { + onError: (Object error) { _log.e('Share stream error: $error'); }, cancelOnError: false, @@ -92,7 +92,7 @@ class _MainShellState extends ConsumerState if (!extState.isInitialized) { _log.d('Waiting for extensions to initialize before handling URL...'); for (int i = 0; i < 50; i++) { - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); if (!mounted) return; if (ref.read(extensionProvider).isInitialized) { _log.d('Extensions initialized, proceeding with URL handling'); @@ -177,7 +177,7 @@ class _MainShellState extends ConsumerState final colorScheme = Theme.of(context).colorScheme; - showDialog( + showDialog( context: context, barrierDismissible: false, builder: (ctx) => AlertDialog( diff --git a/lib/screens/playlist_screen.dart b/lib/screens/playlist_screen.dart index 1ac74e77..364324a0 100644 --- a/lib/screens/playlist_screen.dart +++ b/lib/screens/playlist_screen.dart @@ -578,7 +578,7 @@ class _PlaylistScreenState extends ConsumerState { void _confirmDownloadAll(BuildContext context) { if (_tracks.isEmpty) return; - showDialog( + showDialog( context: context, builder: (dialogContext) { final colorScheme = Theme.of(dialogContext).colorScheme; diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 4b2a5cb0..5e3f9fcb 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -656,8 +656,8 @@ final _queueFilteredAlbumsProvider = }); Map> _filterHistoryInIsolate(Map payload) { - final entries = (payload['entries'] as List).cast(); - final albumCounts = (payload['albumCounts'] as Map).cast(); + final entries = (payload['entries'] as List).cast>(); + final albumCounts = Map.from(payload['albumCounts'] as Map); final query = (payload['query'] as String?) ?? ''; final hasQuery = query.isNotEmpty; @@ -1968,7 +1968,7 @@ class _QueueTabState extends ConsumerState { String? tempFormat = _filterFormat; String tempSortMode = _sortMode; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, @@ -2280,7 +2280,7 @@ class _QueueTabState extends ConsumerState { final beforeModTime = await _readFileModTimeMillis(historyItem.filePath); if (!mounted) return; final result = await navigator.push( - slidePageRoute(page: TrackMetadataScreen(item: historyItem)), + slidePageRoute(page: TrackMetadataScreen(item: historyItem)), ); _searchFocusNode.unfocus(); if (result == true) { @@ -2306,7 +2306,7 @@ class _QueueTabState extends ConsumerState { final beforeModTime = await _readFileModTimeMillis(item.filePath); if (!mounted) return; final result = await navigator.push( - slidePageRoute(page: TrackMetadataScreen(item: item)), + slidePageRoute(page: TrackMetadataScreen(item: item)), ); _searchFocusNode.unfocus(); if (result == true) { @@ -2327,7 +2327,7 @@ class _QueueTabState extends ConsumerState { _searchFocusNode.unfocus(); Navigator.push( context, - slidePageRoute(page: TrackMetadataScreen(localItem: item)), + slidePageRoute(page: TrackMetadataScreen(localItem: item)), ).then((_) => _searchFocusNode.unfocus()); } @@ -4711,7 +4711,7 @@ class _QueueTabState extends ConsumerState { _hideSelectionOverlay(); _hidePlaylistSelectionOverlay(); - await showModalBottomSheet( + await showModalBottomSheet( context: context, useRootNavigator: true, shape: const RoundedRectangleBorder( diff --git a/lib/screens/settings/appearance_settings_page.dart b/lib/screens/settings/appearance_settings_page.dart index 4884328a..75d93e6e 100644 --- a/lib/screens/settings/appearance_settings_page.dart +++ b/lib/screens/settings/appearance_settings_page.dart @@ -770,7 +770,7 @@ class _LanguageSelector extends StatelessWidget { void _showLanguagePicker(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surface, diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index 6b60e332..39b116e6 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -510,7 +510,7 @@ class _DownloadSettingsPageState extends ConsumerState { ), onTap: () => Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (_) => const LyricsProviderPriorityPage(), ), ), @@ -853,7 +853,7 @@ class _DownloadSettingsPageState extends ConsumerState { WidgetRef ref, String current, ) { - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, builder: (context) => SafeArea( @@ -1002,7 +1002,7 @@ class _DownloadSettingsPageState extends ConsumerState { ); } - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, @@ -1220,7 +1220,7 @@ class _DownloadSettingsPageState extends ConsumerState { final settings = ref.read(settingsProvider); final isSafMode = settings.storageMode == 'saf' && settings.downloadTreeUri.isNotEmpty; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1298,7 +1298,7 @@ class _DownloadSettingsPageState extends ConsumerState { void _showIOSDirectoryOptions(BuildContext context, WidgetRef ref) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1493,7 +1493,7 @@ class _DownloadSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1598,7 +1598,7 @@ class _DownloadSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1685,7 +1685,7 @@ class _DownloadSettingsPageState extends ConsumerState { final colorScheme = Theme.of(context).colorScheme; final controller = TextEditingController(text: currentLanguage); - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1771,7 +1771,7 @@ class _DownloadSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1843,7 +1843,7 @@ class _DownloadSettingsPageState extends ConsumerState { ) { final colorScheme = Theme.of(context).colorScheme; final normalizedCurrent = current.trim().toUpperCase(); - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -1911,7 +1911,7 @@ class _DownloadSettingsPageState extends ConsumerState { String current, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, diff --git a/lib/screens/settings/extension_detail_page.dart b/lib/screens/settings/extension_detail_page.dart index f2f0b950..48d23d0d 100644 --- a/lib/screens/settings/extension_detail_page.dart +++ b/lib/screens/settings/extension_detail_page.dart @@ -832,9 +832,9 @@ class _SettingItemState extends State<_SettingItem> { } } catch (e) { if (context.mounted) { - ScaffoldMessenger.of( - context, - ).showSnackBar(SnackBar(content: Text(context.l10n.snackbarError(e.toString())))); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarError(e.toString()))), + ); } } finally { if (mounted) { @@ -849,7 +849,7 @@ class _SettingItemState extends State<_SettingItem> { ); final colorScheme = Theme.of(context).colorScheme; - showDialog( + showDialog( context: context, builder: (context) => AlertDialog( title: Text(widget.setting.label), diff --git a/lib/screens/settings/extensions_page.dart b/lib/screens/settings/extensions_page.dart index 7e5aa26e..9fec23ee 100644 --- a/lib/screens/settings/extensions_page.dart +++ b/lib/screens/settings/extensions_page.dart @@ -4,6 +4,7 @@ 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/l10n/l10n.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/explore_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; @@ -212,7 +213,7 @@ class _ExtensionsPageState extends ConsumerState { showDivider: index < extState.extensions.length - 1, onTap: () => Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (_) => ExtensionDetailPage(extensionId: ext.id), ), @@ -469,7 +470,9 @@ class _DownloadPriorityItem extends ConsumerWidget { onTap: hasDownloadExtensions ? () => Navigator.push( context, - MaterialPageRoute(builder: (_) => const ProviderPriorityPage()), + MaterialPageRoute( + builder: (_) => const ProviderPriorityPage(), + ), ) : null, child: Padding( @@ -534,7 +537,7 @@ class _MetadataPriorityItem extends ConsumerWidget { onTap: hasMetadataExtensions ? () => Navigator.push( context, - MaterialPageRoute( + MaterialPageRoute( builder: (_) => const MetadataProviderPriorityPage(), ), ) @@ -678,12 +681,12 @@ class _SearchProviderSelector extends ConsumerWidget { void _showSearchProviderPicker( BuildContext context, WidgetRef ref, - dynamic settings, + AppSettings settings, List searchProviders, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, @@ -859,12 +862,12 @@ class _HomeFeedProviderSelector extends ConsumerWidget { void _showHomeFeedProviderPicker( BuildContext context, WidgetRef ref, - dynamic settings, + AppSettings settings, List homeFeedProviders, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index eae12522..83014ec9 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -255,7 +255,7 @@ class _LibrarySettingsPageState extends ConsumerState { void _showAutoScanPicker(BuildContext context, String current) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, diff --git a/lib/screens/settings/log_screen.dart b/lib/screens/settings/log_screen.dart index b34e172a..06c0173b 100644 --- a/lib/screens/settings/log_screen.dart +++ b/lib/screens/settings/log_screen.dart @@ -92,7 +92,7 @@ class _LogScreenState extends State { } void _clearLogs() { - showDialog( + showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.logClearLogsTitle), diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index 1025f1b1..30fbb798 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -241,7 +241,7 @@ class OptionsSettingsPage extends ConsumerWidget { WidgetRef ref, ColorScheme colorScheme, ) { - showDialog( + showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.dialogClearHistoryTitle), @@ -273,7 +273,7 @@ class OptionsSettingsPage extends ConsumerWidget { BuildContext context, WidgetRef ref, ) async { - showDialog( + showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( diff --git a/lib/screens/settings/settings_tab.dart b/lib/screens/settings/settings_tab.dart index 7e4d03f2..f08d967a 100644 --- a/lib/screens/settings/settings_tab.dart +++ b/lib/screens/settings/settings_tab.dart @@ -151,6 +151,6 @@ class SettingsTab extends ConsumerWidget { void _navigateTo(BuildContext context, Widget page) { FocusManager.instance.primaryFocus?.unfocus(); - Navigator.of(context).push(slidePageRoute(page: page)); + Navigator.of(context).push(slidePageRoute(page: page)); } } diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index bc72a18d..9a9ddd9f 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -124,7 +124,7 @@ class _SetupScreenState extends ConsumerState { final shouldOpen = await _showAndroid11StorageDialog(); if (shouldOpen == true) { await Permission.manageExternalStorage.request(); - await Future.delayed(const Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); manageStatus = await Permission.manageExternalStorage.status; } } @@ -203,7 +203,7 @@ class _SetupScreenState extends ConsumerState { } Future _showPermissionDeniedDialog(String permissionType) async { - await showDialog( + await showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.setupPermissionRequired(permissionType)), @@ -286,7 +286,7 @@ class _SetupScreenState extends ConsumerState { Future _showIOSDirectoryOptions() async { final colorScheme = Theme.of(context).colorScheme; - await showModalBottomSheet( + await showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, diff --git a/lib/screens/store_tab.dart b/lib/screens/store_tab.dart index 2d149f87..8270bec3 100644 --- a/lib/screens/store_tab.dart +++ b/lib/screens/store_tab.dart @@ -416,7 +416,7 @@ class _StoreTabState extends ConsumerState { void _showChangeRepoDialog(String currentUrl) { final changeUrlController = TextEditingController(text: currentUrl); - showDialog( + showDialog( context: context, builder: (context) => AlertDialog( title: Text(context.l10n.storeRepoDialogTitle), @@ -583,7 +583,7 @@ class _StoreTabState extends ConsumerState { void _showExtensionDetails(StoreExtension ext) { Navigator.of(context).push( - MaterialPageRoute( + MaterialPageRoute( builder: (context) => ExtensionDetailsScreen(extension: ext), ), ); diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index 254d2f06..0e3538a8 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -2135,7 +2135,7 @@ class _TrackMetadataScreenState extends ConsumerState { treeUri: treeUri, relativeDir: relativeDir, fileName: '$baseName.lrc', - mimeType: 'text/plain', + mimeType: 'application/octet-stream', srcPath: tempOutput, ); try { @@ -2533,7 +2533,7 @@ class _TrackMetadataScreenState extends ConsumerState { WidgetRef ref, ColorScheme colorScheme, ) { - showModalBottomSheet( + showModalBottomSheet( context: screenContext, useRootNavigator: true, shape: const RoundedRectangleBorder( @@ -2824,7 +2824,7 @@ class _TrackMetadataScreenState extends ConsumerState { bool isLosslessTarget = selectedFormat == 'ALAC' || selectedFormat == 'FLAC'; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, shape: const RoundedRectangleBorder( @@ -3023,7 +3023,7 @@ class _TrackMetadataScreenState extends ConsumerState { if (!mounted) return; - showModalBottomSheet( + showModalBottomSheet( context: this.context, useRootNavigator: true, isScrollControlled: true, @@ -3186,7 +3186,7 @@ class _TrackMetadataScreenState extends ConsumerState { required String date, required List tracks, }) { - showDialog( + showDialog( context: context, builder: (dialogContext) { return AlertDialog( @@ -3442,7 +3442,7 @@ class _TrackMetadataScreenState extends ConsumerState { final isLossless = targetFormat.toUpperCase() == 'ALAC' || targetFormat.toUpperCase() == 'FLAC'; - showDialog( + showDialog( context: context, builder: (dialogContext) { return AlertDialog( @@ -3792,7 +3792,7 @@ class _TrackMetadataScreenState extends ConsumerState { WidgetRef ref, ColorScheme colorScheme, ) { - showDialog( + showDialog( context: screenContext, useRootNavigator: true, builder: (dialogContext) => AlertDialog( diff --git a/lib/screens/tutorial_screen.dart b/lib/screens/tutorial_screen.dart index a944f6a8..a8d35705 100644 --- a/lib/screens/tutorial_screen.dart +++ b/lib/screens/tutorial_screen.dart @@ -527,7 +527,7 @@ class _InteractiveDownloadExampleState for (int i = 0; i <= 100; i += 5) { if (!mounted) return; - await Future.delayed(const Duration(milliseconds: 50)); + await Future.delayed(const Duration(milliseconds: 50)); setState(() => _progress = i / 100); } @@ -536,7 +536,7 @@ class _InteractiveDownloadExampleState _isCompleted = true; }); - await Future.delayed(const Duration(seconds: 2)); + await Future.delayed(const Duration(seconds: 2)); if (mounted) { setState(() { _isCompleted = false; diff --git a/lib/services/app_state_database.dart b/lib/services/app_state_database.dart index 92ca49f8..471c251c 100644 --- a/lib/services/app_state_database.dart +++ b/lib/services/app_state_database.dart @@ -119,7 +119,7 @@ class AppStateDatabase { final db = await database; await db.transaction((txn) async { final batch = txn.batch(); - for (final entry in decoded.whereType()) { + for (final entry in decoded.whereType>()) { final map = Map.from(entry); final id = map['id'] as String?; if (id == null || id.isEmpty) continue; @@ -179,7 +179,7 @@ class AppStateDatabase { final decoded = jsonDecode(rawRecent); if (decoded is List) { final batch = txn.batch(); - for (final entry in decoded.whereType()) { + for (final entry in decoded.whereType>()) { final map = Map.from(entry); final type = map['type'] as String?; final id = map['id'] as String?; diff --git a/lib/services/csv_import_service.dart b/lib/services/csv_import_service.dart index 551acd17..45d59d18 100644 --- a/lib/services/csv_import_service.dart +++ b/lib/services/csv_import_service.dart @@ -124,7 +124,7 @@ class CsvImportService { ); if (i < tracks.length - 1) { - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(const Duration(milliseconds: 100)); } continue; } diff --git a/lib/services/history_database.dart b/lib/services/history_database.dart index 59b7f883..4c851305 100644 --- a/lib/services/history_database.dart +++ b/lib/services/history_database.dart @@ -224,7 +224,7 @@ class HistoryDatabase { } try { - final List jsonList = jsonDecode(jsonStr); + final jsonList = List.from(jsonDecode(jsonStr) as List); _log.i( 'Migrating ${jsonList.length} items from SharedPreferences to SQLite', ); @@ -233,7 +233,7 @@ class HistoryDatabase { final batch = db.batch(); for (final json in jsonList) { - final map = json as Map; + final map = Map.from(json as Map); batch.insert( 'history', _jsonToDbRow(map), diff --git a/lib/services/library_collections_database.dart b/lib/services/library_collections_database.dart index 65577cce..db813114 100644 --- a/lib/services/library_collections_database.dart +++ b/lib/services/library_collections_database.dart @@ -155,11 +155,11 @@ class LibraryCollectionsDatabase { final db = await database; await db.transaction((txn) async { - for (final entry in wishlistRaw.whereType()) { + for (final entry in wishlistRaw.whereType>()) { final map = Map.from(entry); final trackKey = map['key'] as String?; final track = map['track']; - if (trackKey == null || track is! Map) continue; + if (trackKey == null || track is! Map) continue; final addedAt = (map['addedAt'] as String?) ?? nowIso; await txn.insert(_tableWishlist, { 'track_key': trackKey, @@ -168,11 +168,11 @@ class LibraryCollectionsDatabase { }, conflictAlgorithm: ConflictAlgorithm.replace); } - for (final entry in lovedRaw.whereType()) { + for (final entry in lovedRaw.whereType>()) { final map = Map.from(entry); final trackKey = map['key'] as String?; final track = map['track']; - if (trackKey == null || track is! Map) continue; + if (trackKey == null || track is! Map) continue; final addedAt = (map['addedAt'] as String?) ?? nowIso; await txn.insert(_tableLoved, { 'track_key': trackKey, @@ -181,7 +181,8 @@ class LibraryCollectionsDatabase { }, conflictAlgorithm: ConflictAlgorithm.replace); } - for (final playlistEntry in playlistsRaw.whereType()) { + for (final playlistEntry + in playlistsRaw.whereType>()) { final playlist = Map.from(playlistEntry); final playlistId = playlist['id'] as String?; if (playlistId == null || playlistId.isEmpty) continue; @@ -197,11 +198,12 @@ class LibraryCollectionsDatabase { }, conflictAlgorithm: ConflictAlgorithm.replace); final tracksRaw = (playlist['tracks'] as List?) ?? const []; - for (final trackEntry in tracksRaw.whereType()) { + for (final trackEntry + in tracksRaw.whereType>()) { final trackMap = Map.from(trackEntry); final trackKey = trackMap['key'] as String?; final track = trackMap['track']; - if (trackKey == null || track is! Map) continue; + if (trackKey == null || track is! Map) continue; final addedAt = (trackMap['addedAt'] as String?) ?? nowIso; await txn.insert(_tablePlaylistTracks, { 'playlist_id': playlistId, diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 401a3afc..0dfa2bcf 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -67,8 +67,8 @@ class PlatformBridge { if (response['success'] == true) { final service = response['service'] ?? payload.service; final filePath = response['file_path'] ?? ''; - final bitDepth = response['actual_bit_depth']; - final sampleRate = response['actual_sample_rate']; + final bitDepth = response['actual_bit_depth'] as num?; + final sampleRate = response['actual_sample_rate'] as num?; final qualityStr = bitDepth != null && sampleRate != null ? ' ($bitDepth-bit/${(sampleRate / 1000).toStringAsFixed(1)}kHz)' : ''; diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 1040c738..c5f174d0 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -65,7 +65,7 @@ class ShareIntentService { _mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen( _handleSharedMedia, - onError: (err) => _log.e('Error: $err'), + onError: (Object err) => _log.e('Error: $err'), ); final initialMedia = await ReceiveSharingIntent.instance.getInitialMedia(); diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index c24b47f0..7ea63307 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -1,11 +1,26 @@ import 'dart:convert'; import 'dart:io'; +import 'package:device_info_plus/device_info_plus.dart'; import 'package:http/http.dart' as http; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/utils/logger.dart'; final _log = AppLogger('UpdateChecker'); +enum _ApkVariant { arm64, arm32, universal } + +class _ApkAsset { + final String name; + final String url; + final _ApkVariant variant; + + const _ApkAsset({ + required this.name, + required this.url, + required this.variant, + }); +} + class UpdateInfo { final String version; final String changelog; @@ -94,32 +109,15 @@ class UpdateChecker { DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); - String? arm64Url; - String? universalUrl; - - final assets = releaseData['assets'] as List? ?? []; - for (final asset in assets) { - final name = (asset['name'] as String? ?? '').toLowerCase(); - if (name.endsWith('.apk')) { - final downloadUrl = asset['browser_download_url'] as String?; - final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null; - if (uri == null || uri.scheme != 'https') { - _log.w('Skipping non-HTTPS APK URL: $downloadUrl'); - continue; - } - if (name.contains('arm64') || name.contains('v8a')) { - arm64Url = downloadUrl; - } else if (name.contains('universal')) { - universalUrl = downloadUrl; - } - } - } - - // Only arm64 is supported; fall back to universal if available - final apkUrl = arm64Url ?? universalUrl; + final assets = _collectApkAssets( + releaseData['assets'] as List? ?? const [], + ); + final selectedAsset = await _selectApkForCurrentDevice(assets); + final apkUrl = selectedAsset?.url; _log.i( - 'Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl', + 'Update available: $latestVersion (prerelease: $isPrerelease), ' + 'APK asset: ${selectedAsset?.name ?? 'none'}, APK URL: $apkUrl', ); return UpdateInfo( @@ -169,4 +167,128 @@ class UpdateChecker { } static String get currentVersion => AppInfo.version; + + static List<_ApkAsset> _collectApkAssets(List assets) { + final apkAssets = <_ApkAsset>[]; + + for (final asset in assets.whereType>()) { + final assetMap = Map.from(asset); + final name = (assetMap['name'] as String? ?? '').trim(); + final normalizedName = name.toLowerCase(); + if (!normalizedName.endsWith('.apk')) { + continue; + } + + final downloadUrl = assetMap['browser_download_url'] as String?; + final uri = downloadUrl != null ? Uri.tryParse(downloadUrl) : null; + if (uri == null || uri.scheme != 'https') { + _log.w('Skipping non-HTTPS APK URL: $downloadUrl'); + continue; + } + + final variant = _apkVariantFromName(normalizedName); + if (variant == null) { + _log.w('Skipping APK with unknown variant: $name'); + continue; + } + + apkAssets.add( + _ApkAsset(name: name, url: uri.toString(), variant: variant), + ); + } + + return apkAssets; + } + + static _ApkVariant? _apkVariantFromName(String name) { + if (name.contains('universal')) { + return _ApkVariant.universal; + } + if (name.contains('arm64') || name.contains('arm64-v8a')) { + return _ApkVariant.arm64; + } + if (name.contains('arm32') || + name.contains('armeabi') || + name.contains('armv7') || + name.contains('v7a')) { + return _ApkVariant.arm32; + } + return null; + } + + static Future<_ApkAsset?> _selectApkForCurrentDevice( + List<_ApkAsset> assets, + ) async { + if (assets.isEmpty) { + return null; + } + + _ApkAsset? arm64Asset; + _ApkAsset? arm32Asset; + _ApkAsset? universalAsset; + for (final asset in assets) { + switch (asset.variant) { + case _ApkVariant.arm64: + arm64Asset ??= asset; + break; + case _ApkVariant.arm32: + arm32Asset ??= asset; + break; + case _ApkVariant.universal: + universalAsset ??= asset; + break; + } + } + + final supportedAbis = await _getSupportedAndroidAbis(); + final hasArm64 = supportedAbis.any(_isArm64Abi); + final hasArm32 = supportedAbis.any(_isArm32Abi); + + if (hasArm64) { + return arm64Asset ?? universalAsset ?? arm32Asset; + } + if (hasArm32) { + return arm32Asset ?? universalAsset; + } + + if (universalAsset != null) { + _log.w( + 'Could not match APK asset to supported ABIs ${supportedAbis.join(', ')}; ' + 'falling back to universal APK.', + ); + return universalAsset; + } + + _log.w( + 'Could not match APK asset to supported ABIs ${supportedAbis.join(', ')}; ' + 'no universal APK available.', + ); + return null; + } + + static Future> _getSupportedAndroidAbis() async { + if (!Platform.isAndroid) { + return const []; + } + + try { + final androidInfo = await DeviceInfoPlugin().androidInfo; + final supportedAbis = androidInfo.supportedAbis + .map((abi) => abi.toLowerCase()) + .where((abi) => abi.isNotEmpty) + .toSet() + .toList(); + _log.i('Detected supported Android ABIs: ${supportedAbis.join(', ')}'); + return supportedAbis; + } catch (e) { + _log.w('Failed to detect supported Android ABIs: $e'); + return const []; + } + } + + static bool _isArm64Abi(String abi) => + abi.contains('arm64') || abi.contains('aarch64'); + + static bool _isArm32Abi(String abi) => + abi.contains('armeabi') || abi.contains('armv7') || abi.contains('arm'); } diff --git a/lib/utils/clickable_metadata.dart b/lib/utils/clickable_metadata.dart index 91e40501..b39f1bd1 100644 --- a/lib/utils/clickable_metadata.dart +++ b/lib/utils/clickable_metadata.dart @@ -252,7 +252,7 @@ void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) { identical(currentNavigator, rootNavigator) && activeTabNavigator != null; if (!shouldRouteToTabNavigator) { - currentNavigator.push(MaterialPageRoute(builder: builder)); + currentNavigator.push(MaterialPageRoute(builder: builder)); return; } @@ -264,12 +264,12 @@ void _pushViaPreferredNavigator(BuildContext context, WidgetBuilder builder) { currentNavigator.pop(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!activeTabNavigator.mounted) return; - activeTabNavigator.push(MaterialPageRoute(builder: builder)); + activeTabNavigator.push(MaterialPageRoute(builder: builder)); }); return; } - activeTabNavigator.push(MaterialPageRoute(builder: builder)); + activeTabNavigator.push(MaterialPageRoute(builder: builder)); } void _showLoadingSnackBar(BuildContext context, String message) { diff --git a/lib/utils/logger.dart b/lib/utils/logger.dart index 3a32375c..2601c70c 100644 --- a/lib/utils/logger.dart +++ b/lib/utils/logger.dart @@ -179,15 +179,16 @@ class LogBuffer extends ChangeNotifier { final nextIndex = result['next_index'] as int? ?? _lastGoLogIndex; final keepNonErrorLogs = _loggingEnabled; - for (final log in logs) { - final level = log['level'] as String? ?? 'INFO'; + for (final log in logs.whereType>()) { + final logMap = Map.from(log); + final level = logMap['level'] as String? ?? 'INFO'; if (!keepNonErrorLogs && level != 'ERROR' && level != 'FATAL') { continue; } - final timestamp = log['timestamp'] as String? ?? ''; - final tag = log['tag'] as String? ?? 'Go'; - final message = log['message'] as String? ?? ''; + final timestamp = logMap['timestamp'] as String? ?? ''; + final tag = logMap['tag'] as String? ?? 'Go'; + final message = logMap['message'] as String? ?? ''; DateTime parsedTime = DateTime.now(); if (timestamp.isNotEmpty) { diff --git a/lib/widgets/audio_analysis_widget.dart b/lib/widgets/audio_analysis_widget.dart index 41a9ba1b..d88c55b0 100644 --- a/lib/widgets/audio_analysis_widget.dart +++ b/lib/widgets/audio_analysis_widget.dart @@ -239,7 +239,9 @@ class _AudioAnalysisCardState extends State { final file = File('${dir.path}/$key.json'); if (!await file.exists()) return null; - final json = jsonDecode(await file.readAsString()); + final json = Map.from( + jsonDecode(await file.readAsString()) as Map, + ); final cachedSize = json['fileSize'] as int; if (!filePath.startsWith('content://')) { diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 51f4aa37..dfff48cb 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -110,7 +110,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { }) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, backgroundColor: colorScheme.surfaceContainerHigh, diff --git a/lib/widgets/track_collection_quick_actions.dart b/lib/widgets/track_collection_quick_actions.dart index b8f75708..5fe9d3d4 100644 --- a/lib/widgets/track_collection_quick_actions.dart +++ b/lib/widgets/track_collection_quick_actions.dart @@ -19,7 +19,7 @@ class TrackCollectionQuickActions extends ConsumerWidget { Track track, ) { final colorScheme = Theme.of(context).colorScheme; - showModalBottomSheet( + showModalBottomSheet( context: context, useRootNavigator: true, isScrollControlled: true, diff --git a/pubspec.lock b/pubspec.lock index 5f81e3da..cc6cea93 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,14 +9,22 @@ packages: url: "https://pub.dev" source: hosted version: "91.0.0" + analysis_server_plugin: + dependency: transitive + description: + name: analysis_server_plugin + sha256: "26844e7f977087567135d62532b67d5639fe206c5194c3f410ba75e1a04a2747" + url: "https://pub.dev" + source: hosted + version: "0.3.3" analyzer: dependency: transitive description: name: analyzer - sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08 + sha256: a40a0cee526a7e1f387c6847bd8a5ccbf510a75952ef8a28338e989558072cb0 url: "https://pub.dev" source: hosted - version: "8.4.1" + version: "8.4.0" analyzer_buffer: dependency: transitive description: @@ -25,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.11" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "08cfefa90b4f4dd3b447bda831cecf644029f9f8e22820f6ee310213ebe2dd53" + url: "https://pub.dev" + source: hosted + version: "0.13.10" archive: dependency: transitive description: @@ -145,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + ci: + dependency: transitive + description: + name: ci + sha256: "145d095ce05cddac4d797a158bc4cf3b6016d1fe63d8c3d2fbd7212590adca13" + url: "https://pub.dev" + source: hosted + version: "0.1.0" cli_config: dependency: transitive description: @@ -241,6 +265,30 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.7" + custom_lint: + dependency: "direct dev" + description: + name: custom_lint + sha256: "751ee9440920f808266c3ec2553420dea56d3c7837dd2d62af76b11be3fcece5" + url: "https://pub.dev" + source: hosted + version: "0.8.1" + custom_lint_core: + dependency: transitive + description: + name: custom_lint_core + sha256: "85b339346154d5646952d44d682965dfe9e12cae5febd706f0db3aa5010d6423" + url: "https://pub.dev" + source: hosted + version: "0.8.1" + custom_lint_visitor: + dependency: transitive + description: + name: custom_lint_visitor + sha256: "91f2a81e9f0abb4b9f3bb529f78b6227ce6050300d1ae5b1e2c69c66c7a566d8" + url: "https://pub.dev" + source: hosted + version: "1.0.0+8.4.0" dart_style: dependency: transitive description: @@ -925,6 +973,14 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0+1" + riverpod_lint: + dependency: "direct dev" + description: + name: riverpod_lint + sha256: "4d2eb0d19bbe7e3323bd0ce4553b2e6170d161a13914bfdd85a3612329edcb43" + url: "https://pub.dev" + source: hosted + version: "3.1.0" rxdart: dependency: transitive description: @@ -1402,6 +1458,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" sdks: dart: ">=3.10.0 <4.0.0" flutter: ">=3.38.1" diff --git a/pubspec.yaml b/pubspec.yaml index 3a146a5d..8821346b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: # Localization flutter_localizations: sdk: flutter - intl: any + intl: ^0.20.2 # State Management flutter_riverpod: ^3.1.0 @@ -68,7 +68,9 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 build_runner: ^2.10.4 + custom_lint: ^0.8.1 riverpod_generator: ^4.0.0 + riverpod_lint: ^3.1.0 json_serializable: ^6.11.2 flutter_launcher_icons: ^0.14.3 From 8ee29199347dae93da6760e62569955091b23eed Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Mar 2026 21:58:01 +0700 Subject: [PATCH 32/33] feat: track byte-level download progress for extension downloads Pass active download item ID through extension download pipeline so fileDownload can report bytes received/total via ItemProgressWriter. Add bytesTotal field to DownloadItem model and show X/Y MB progress in queue tab when total size is known. --- go_backend/extension_providers.go | 10 +++++-- go_backend/extension_runtime.go | 21 ++++++++++++++ go_backend/extension_runtime_file.go | 17 +++++++++++- lib/models/download_item.dart | 16 +++++------ lib/models/download_item.g.dart | 2 ++ lib/providers/download_queue_provider.dart | 8 +++++- lib/screens/queue_tab.dart | 32 +++++++++++++++------- 7 files changed, 82 insertions(+), 24 deletions(-) diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index ecddfbcf..df7467f9 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -510,7 +510,7 @@ func (p *ExtensionProviderWrapper) GetDownloadURL(trackID, quality string) (*Ext const ExtDownloadTimeout = DownloadTimeout -func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, onProgress func(percent int)) (*ExtDownloadResult, error) { +func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath, itemID string, onProgress func(percent int)) (*ExtDownloadResult, error) { if !p.extension.Manifest.IsDownloadProvider() { return nil, fmt.Errorf("extension '%s' is not a download provider", p.extension.ID) } @@ -526,6 +526,10 @@ func (p *ExtensionProviderWrapper) Download(trackID, quality, outputPath string, }, nil } defer p.extension.VMMu.Unlock() + if p.extension.runtime != nil { + p.extension.runtime.setActiveDownloadItemID(itemID) + defer p.extension.runtime.clearActiveDownloadItemID() + } p.vm.Set("__onProgress", func(call goja.FunctionCall) goja.Value { if len(call.Arguments) > 0 { @@ -1128,7 +1132,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro StartItemProgress(req.ItemID) } - result, err := provider.Download(trackID, req.Quality, outputPath, func(percent int) { + result, err := provider.Download(trackID, req.Quality, outputPath, req.ItemID, func(percent int) { if req.ItemID != "" { normalized := float64(percent) / 100.0 if normalized < 0 { @@ -1356,7 +1360,7 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro StartItemProgress(req.ItemID) } - result, err := provider.Download(availability.TrackID, req.Quality, outputPath, func(percent int) { + result, err := provider.Download(availability.TrackID, req.Quality, outputPath, req.ItemID, func(percent int) { if req.ItemID != "" { normalized := float64(percent) / 100.0 if normalized < 0 { diff --git a/go_backend/extension_runtime.go b/go_backend/extension_runtime.go index 58068a42..7f2c0848 100644 --- a/go_backend/extension_runtime.go +++ b/go_backend/extension_runtime.go @@ -90,6 +90,9 @@ type ExtensionRuntime struct { dataDir string vm *goja.Runtime + activeDownloadMu sync.RWMutex + activeDownloadItemID string + storageMu sync.RWMutex storageCache map[string]interface{} storageLoaded bool @@ -139,6 +142,24 @@ func NewExtensionRuntime(ext *LoadedExtension) *ExtensionRuntime { return runtime } +func (r *ExtensionRuntime) setActiveDownloadItemID(itemID string) { + r.activeDownloadMu.Lock() + defer r.activeDownloadMu.Unlock() + r.activeDownloadItemID = strings.TrimSpace(itemID) +} + +func (r *ExtensionRuntime) clearActiveDownloadItemID() { + r.activeDownloadMu.Lock() + defer r.activeDownloadMu.Unlock() + r.activeDownloadItemID = "" +} + +func (r *ExtensionRuntime) getActiveDownloadItemID() string { + r.activeDownloadMu.RLock() + defer r.activeDownloadMu.RUnlock() + return r.activeDownloadItemID +} + func newExtensionHTTPClient(ext *LoadedExtension, jar http.CookieJar, timeout time.Duration) *http.Client { // Extension sandbox enforces HTTPS-only domains. Do not apply global // allow_http scheme downgrade here, because some extension APIs (e.g. diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 92312c98..9e442aea 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -205,13 +205,22 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { defer out.Close() contentLength := resp.ContentLength + activeItemID := r.getActiveDownloadItemID() + if activeItemID != "" && contentLength > 0 { + SetItemBytesTotal(activeItemID, contentLength) + } + + var progressWriter interface{ Write([]byte) (int, error) } = out + if activeItemID != "" { + progressWriter = NewItemProgressWriter(out, activeItemID) + } var written int64 buf := make([]byte, 32*1024) for { nr, er := resp.Body.Read(buf) if nr > 0 { - nw, ew := out.Write(buf[0:nr]) + nw, ew := progressWriter.Write(buf[0:nr]) if nw < 0 || nr < nw { nw = 0 if ew == nil { @@ -220,6 +229,12 @@ func (r *ExtensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } written += int64(nw) if ew != nil { + if ew == ErrDownloadCancelled { + return r.vm.ToValue(map[string]interface{}{ + "success": false, + "error": "download cancelled", + }) + } return r.vm.ToValue(map[string]interface{}{ "success": false, "error": fmt.Sprintf("failed to write file: %v", ew), diff --git a/lib/models/download_item.dart b/lib/models/download_item.dart index db55029b..d8ac97cb 100644 --- a/lib/models/download_item.dart +++ b/lib/models/download_item.dart @@ -12,13 +12,7 @@ enum DownloadStatus { skipped, } -enum DownloadErrorType { - unknown, - notFound, - rateLimit, - network, - permission, -} +enum DownloadErrorType { unknown, notFound, rateLimit, network, permission } @JsonSerializable() class DownloadItem { @@ -28,7 +22,8 @@ class DownloadItem { final DownloadStatus status; final double progress; final double speedMBps; - final int bytesReceived; // Bytes downloaded so far (for unknown size downloads) + final int bytesReceived; // Bytes downloaded so far + final int bytesTotal; // Total bytes when the server provides content length final String? filePath; final String? error; final DownloadErrorType? errorType; @@ -44,6 +39,7 @@ class DownloadItem { this.progress = 0.0, this.speedMBps = 0.0, this.bytesReceived = 0, + this.bytesTotal = 0, this.filePath, this.error, this.errorType, @@ -60,6 +56,7 @@ class DownloadItem { double? progress, double? speedMBps, int? bytesReceived, + int? bytesTotal, String? filePath, String? error, DownloadErrorType? errorType, @@ -75,6 +72,7 @@ class DownloadItem { progress: progress ?? this.progress, speedMBps: speedMBps ?? this.speedMBps, bytesReceived: bytesReceived ?? this.bytesReceived, + bytesTotal: bytesTotal ?? this.bytesTotal, filePath: filePath ?? this.filePath, error: error ?? this.error, errorType: errorType ?? this.errorType, @@ -86,7 +84,7 @@ class DownloadItem { String get errorMessage { if (error == null) return ''; - + switch (errorType) { case DownloadErrorType.notFound: return 'Song not found on any service'; diff --git a/lib/models/download_item.g.dart b/lib/models/download_item.g.dart index 961e6d6d..7aee835c 100644 --- a/lib/models/download_item.g.dart +++ b/lib/models/download_item.g.dart @@ -16,6 +16,7 @@ DownloadItem _$DownloadItemFromJson(Map json) => DownloadItem( progress: (json['progress'] as num?)?.toDouble() ?? 0.0, speedMBps: (json['speedMBps'] as num?)?.toDouble() ?? 0.0, bytesReceived: (json['bytesReceived'] as num?)?.toInt() ?? 0, + bytesTotal: (json['bytesTotal'] as num?)?.toInt() ?? 0, filePath: json['filePath'] as String?, error: json['error'] as String?, errorType: $enumDecodeNullable(_$DownloadErrorTypeEnumMap, json['errorType']), @@ -33,6 +34,7 @@ Map _$DownloadItemToJson(DownloadItem instance) => 'progress': instance.progress, 'speedMBps': instance.speedMBps, 'bytesReceived': instance.bytesReceived, + 'bytesTotal': instance.bytesTotal, 'filePath': instance.filePath, 'error': instance.error, 'errorType': _$DownloadErrorTypeEnumMap[instance.errorType], diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index e157bec6..123a0c97 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1166,12 +1166,14 @@ class _ProgressUpdate { final double progress; final double? speedMBps; final int? bytesReceived; + final int? bytesTotal; const _ProgressUpdate({ required this.status, required this.progress, this.speedMBps, this.bytesReceived, + this.bytesTotal, }); } @@ -1587,6 +1589,7 @@ class DownloadQueueNotifier extends Notifier { progress: normalizedProgress, speedMBps: normalizedSpeed, bytesReceived: normalizedBytes, + bytesTotal: bytesTotal, ); if (LogBuffer.loggingEnabled) { @@ -1624,11 +1627,13 @@ class DownloadQueueNotifier extends Notifier { progress: update.progress, speedMBps: update.speedMBps ?? current.speedMBps, bytesReceived: update.bytesReceived ?? current.bytesReceived, + bytesTotal: update.bytesTotal ?? current.bytesTotal, ); if (current.status != next.status || current.progress != next.progress || current.speedMBps != next.speedMBps || - current.bytesReceived != next.bytesReceived) { + current.bytesReceived != next.bytesReceived || + current.bytesTotal != next.bytesTotal) { if (!changed) { updatedItems = List.from(updatedItems); changed = true; @@ -2408,6 +2413,7 @@ class DownloadQueueNotifier extends Notifier { progress: 0, speedMBps: 0, bytesReceived: 0, + bytesTotal: 0, ); }) .toList(growable: false); diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 5e3f9fcb..7a4465ed 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -5537,17 +5537,29 @@ class _QueueTabState extends ConsumerState { ), const SizedBox(width: 8), Text( - // When progress is 0 (unknown size, e.g. YouTube tunnel mode), - // show bytes downloaded instead of percentage - item.progress > 0 - ? (item.speedMBps > 0 - ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : '${(item.progress * 100).toStringAsFixed(0)}%') + item.bytesTotal > 0 && item.bytesReceived > 0 + ? (() { + final receivedMB = + item.bytesReceived / (1024 * 1024); + final totalMB = + item.bytesTotal / (1024 * 1024); + final progressLabel = item.progress > 0 + ? '${(item.progress * 100).toStringAsFixed(0)}% • ' + : ''; + final speedLabel = item.speedMBps > 0 + ? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : ''; + return '$progressLabel${receivedMB.toStringAsFixed(1)} / ${totalMB.toStringAsFixed(1)} MB$speedLabel'; + })() : (item.bytesReceived > 0 - ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : (item.speedMBps > 0 - ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' - : 'Starting...')), + ? '${(item.bytesReceived / (1024 * 1024)).toStringAsFixed(1)} MB${item.speedMBps > 0 ? ' • ${item.speedMBps.toStringAsFixed(1)} MB/s' : ''}' + : (item.progress > 0 + ? (item.speedMBps > 0 + ? '${(item.progress * 100).toStringAsFixed(0)}% • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : '${(item.progress * 100).toStringAsFixed(0)}%') + : (item.speedMBps > 0 + ? 'Downloading • ${item.speedMBps.toStringAsFixed(1)} MB/s' + : 'Starting...'))), style: Theme.of(context).textTheme.labelSmall ?.copyWith( color: colorScheme.primary, From bd73eb292d49583f7892231f303c523cebfce4d2 Mon Sep 17 00:00:00 2001 From: zarzet Date: Fri, 27 Mar 2026 22:27:02 +0700 Subject: [PATCH 33/33] chore: bump version to 4.1.1+118 --- lib/constants/app_info.dart | 4 ++-- pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index c489cd2e..3ff69130 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -3,8 +3,8 @@ import 'package:flutter/foundation.dart'; /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '4.1.0'; - static const String buildNumber = '117'; + static const String version = '4.1.1'; + static const String buildNumber = '118'; static const String fullVersion = '$version+$buildNumber'; /// Shows "Internal" in debug builds, actual version in release. diff --git a/pubspec.yaml b/pubspec.yaml index 8821346b..ab896e79 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Deezer publish_to: "none" -version: 4.1.0+117 +version: 4.1.1+118 environment: sdk: ^3.10.0