From 03fd734048c798468b5efdede9f13d485efef163 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 19:55:02 +0700 Subject: [PATCH] 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,