diff --git a/go_backend/cancel.go b/go_backend/cancel.go index 0c0cdcfb..06ec2460 100644 --- a/go_backend/cancel.go +++ b/go_backend/cancel.go @@ -20,23 +20,25 @@ type cancelEntry struct { refs int } -var ( - cancelMu sync.Mutex - cancelMap = make(map[string]*cancelEntry) +type cancelRegistry struct { + mu sync.Mutex + entries map[string]*cancelEntry +} - extensionRequestCancelMu sync.Mutex - extensionRequestCancelMap = make(map[string]*cancelEntry) +var ( + downloadCancels = &cancelRegistry{entries: make(map[string]*cancelEntry)} + extensionRequestCancels = &cancelRegistry{entries: make(map[string]*cancelEntry)} ) -func initDownloadCancel(itemID string) context.Context { - if itemID == "" { +func (r *cancelRegistry) init(id string) context.Context { + if id == "" { return context.Background() } - cancelMu.Lock() - defer cancelMu.Unlock() + r.mu.Lock() + defer r.mu.Unlock() - if entry, ok := cancelMap[itemID]; ok { + if entry, ok := r.entries[id]; ok { if entry.ctx == nil { ctx, cancel := context.WithCancel(context.Background()) entry.ctx = ctx @@ -50,7 +52,7 @@ func initDownloadCancel(itemID string) context.Context { } ctx, cancel := context.WithCancel(context.Background()) - cancelMap[itemID] = &cancelEntry{ + r.entries[id] = &cancelEntry{ ctx: ctx, cancel: cancel, canceled: false, @@ -59,164 +61,122 @@ func initDownloadCancel(itemID string) context.Context { return ctx } -func downloadCancelContext(itemID string) context.Context { - if itemID == "" { +func (r *cancelRegistry) context(id string) context.Context { + if id == "" { return context.Background() } - cancelMu.Lock() - defer cancelMu.Unlock() - if entry, ok := cancelMap[itemID]; ok && entry.ctx != nil { + r.mu.Lock() + defer r.mu.Unlock() + if entry, ok := r.entries[id]; ok && entry.ctx != nil { return entry.ctx } return context.Background() } +func (r *cancelRegistry) requestCancel(id string) { + if id == "" { + return + } + + r.mu.Lock() + if entry, ok := r.entries[id]; ok { + entry.canceled = true + if entry.cancel != nil { + entry.cancel() + } + } else { + r.entries[id] = &cancelEntry{canceled: true} + } + r.mu.Unlock() +} + +func (r *cancelRegistry) isCancelled(id string) bool { + if id == "" { + return false + } + + r.mu.Lock() + entry, ok := r.entries[id] + canceled := ok && entry.canceled + r.mu.Unlock() + return canceled +} + +// resetIfIdle removes a cancellation entry that has no active work attached +// (refs <= 0). Such entries exist to catch an item that is just about to +// start, but if the item never starts the flag lingers and the next explicit +// retry would consume it and abort immediately. +func (r *cancelRegistry) resetIfIdle(id string) { + if id == "" { + return + } + + r.mu.Lock() + if entry, ok := r.entries[id]; ok && entry.refs <= 0 { + delete(r.entries, id) + } + r.mu.Unlock() +} + +func (r *cancelRegistry) release(id string) { + if id == "" { + return + } + + r.mu.Lock() + if entry, ok := r.entries[id]; ok { + entry.refs-- + if entry.refs <= 0 { + delete(r.entries, id) + } + } + r.mu.Unlock() +} + +func initDownloadCancel(itemID string) context.Context { + return downloadCancels.init(itemID) +} + +func downloadCancelContext(itemID string) context.Context { + return downloadCancels.context(itemID) +} + func cancelDownload(itemID string) { if itemID == "" { return } - - cancelMu.Lock() - entry, ok := cancelMap[itemID] - if ok { - entry.canceled = true - if entry.cancel != nil { - entry.cancel() - } - } else { - cancelMap[itemID] = &cancelEntry{canceled: true} - } - cancelMu.Unlock() - + downloadCancels.requestCancel(itemID) RemoveItemProgress(itemID) } func isDownloadCancelled(itemID string) bool { - if itemID == "" { - return false - } - - cancelMu.Lock() - entry, ok := cancelMap[itemID] - canceled := ok && entry.canceled - cancelMu.Unlock() - return canceled + return downloadCancels.isCancelled(itemID) } -// resetDownloadCancel removes a cancellation entry that has no active -// download attached (refs <= 0). Such entries exist to catch an item that is -// just about to start, but if the item never starts the flag lingers and the -// next explicit retry would consume it and abort immediately. func resetDownloadCancel(itemID string) { - if itemID == "" { - return - } - - cancelMu.Lock() - if entry, ok := cancelMap[itemID]; ok && entry.refs <= 0 { - delete(cancelMap, itemID) - } - cancelMu.Unlock() + downloadCancels.resetIfIdle(itemID) } func clearDownloadCancel(itemID string) { - if itemID == "" { - return - } - - cancelMu.Lock() - if entry, ok := cancelMap[itemID]; ok { - entry.refs-- - if entry.refs <= 0 { - delete(cancelMap, itemID) - } - } - cancelMu.Unlock() + downloadCancels.release(itemID) } func initExtensionRequestCancel(requestID string) context.Context { - if requestID == "" { - return context.Background() - } - - extensionRequestCancelMu.Lock() - defer extensionRequestCancelMu.Unlock() - - if entry, ok := extensionRequestCancelMap[requestID]; ok { - if entry.ctx == nil { - ctx, cancel := context.WithCancel(context.Background()) - entry.ctx = ctx - entry.cancel = cancel - if entry.canceled && entry.cancel != nil { - entry.cancel() - } - } - entry.refs++ - return entry.ctx - } - - ctx, cancel := context.WithCancel(context.Background()) - extensionRequestCancelMap[requestID] = &cancelEntry{ - ctx: ctx, - cancel: cancel, - canceled: false, - refs: 1, - } - return ctx + return extensionRequestCancels.init(requestID) } func extensionRequestCancelContext(requestID string) context.Context { - if requestID == "" { - return context.Background() - } - extensionRequestCancelMu.Lock() - defer extensionRequestCancelMu.Unlock() - if entry, ok := extensionRequestCancelMap[requestID]; ok && entry.ctx != nil { - return entry.ctx - } - return context.Background() + return extensionRequestCancels.context(requestID) } func cancelExtensionRequest(requestID string) { - if requestID == "" { - return - } - - extensionRequestCancelMu.Lock() - if entry, ok := extensionRequestCancelMap[requestID]; ok { - entry.canceled = true - if entry.cancel != nil { - entry.cancel() - } - } else { - extensionRequestCancelMap[requestID] = &cancelEntry{canceled: true} - } - extensionRequestCancelMu.Unlock() + extensionRequestCancels.requestCancel(requestID) } func isExtensionRequestCancelled(requestID string) bool { - if requestID == "" { - return false - } - - extensionRequestCancelMu.Lock() - entry, ok := extensionRequestCancelMap[requestID] - canceled := ok && entry.canceled - extensionRequestCancelMu.Unlock() - return canceled + return extensionRequestCancels.isCancelled(requestID) } func clearExtensionRequestCancel(requestID string) { - if requestID == "" { - return - } - - extensionRequestCancelMu.Lock() - if entry, ok := extensionRequestCancelMap[requestID]; ok { - entry.refs-- - if entry.refs <= 0 { - delete(extensionRequestCancelMap, requestID) - } - } - extensionRequestCancelMu.Unlock() + extensionRequestCancels.release(requestID) } diff --git a/go_backend/extension_test.go b/go_backend/extension_test.go index 4e5d0f41..0c873a3f 100644 --- a/go_backend/extension_test.go +++ b/go_backend/extension_test.go @@ -379,9 +379,9 @@ func TestExtensionRuntime_BindDownloadCancelContext(t *testing.T) { } req = runtime.bindDownloadCancelContext(req) - cancelMu.Lock() - refs := cancelMap["test-item"].refs - cancelMu.Unlock() + downloadCancels.mu.Lock() + refs := downloadCancels.entries["test-item"].refs + downloadCancels.mu.Unlock() if refs != 1 { t.Fatalf("binding a request leaked a cancellation reference: %d", refs) } @@ -468,9 +468,9 @@ func TestExtensionRuntime_BindExtensionRequestCancelContext(t *testing.T) { t.Fatalf("new request: %v", err) } req = runtime.bindDownloadCancelContext(req) - extensionRequestCancelMu.Lock() - refs := extensionRequestCancelMap[requestID].refs - extensionRequestCancelMu.Unlock() + extensionRequestCancels.mu.Lock() + refs := extensionRequestCancels.entries[requestID].refs + extensionRequestCancels.mu.Unlock() if refs != 1 { t.Fatalf("binding a request leaked a cancellation reference: %d", refs) } diff --git a/go_backend/lyrics_apple.go b/go_backend/lyrics_apple.go index 64227976..20081a63 100644 --- a/go_backend/lyrics_apple.go +++ b/go_backend/lyrics_apple.go @@ -440,26 +440,8 @@ func (c *AppleMusicClient) FetchLyrics( lrcText = rawLyrics } - lines := parseSyncedLyrics(lrcText) - if len(lines) > 0 { - return &LyricsResponse{ - Lines: lines, - SyncType: "LINE_SYNCED", - Provider: "Apple Music", - Source: "Apple Music", - }, nil + if resp := lyricsResponseFromLRCText(lrcText, "Apple Music", "Apple Music"); resp != nil { + return resp, nil } - - resultLines := plainTextLyricsLines(lrcText) - - if len(resultLines) > 0 { - return &LyricsResponse{ - Lines: resultLines, - SyncType: "UNSYNCED", - Provider: "Apple Music", - Source: "Apple Music", - }, nil - } - return nil, lyricsNotFoundErrorf("no lyrics found on apple music") } diff --git a/go_backend/lyrics_musixmatch.go b/go_backend/lyrics_musixmatch.go index bc48fca7..bf15f524 100644 --- a/go_backend/lyrics_musixmatch.go +++ b/go_backend/lyrics_musixmatch.go @@ -97,28 +97,9 @@ func (c *MusixmatchClient) FetchLyricsInLanguage(trackName, artistName string, d return nil, err } - lines := parseSyncedLyrics(lrcText) - if len(lines) > 0 { - return &LyricsResponse{ - Lines: lines, - SyncType: "LINE_SYNCED", - PlainLyrics: plainLyricsFromTimedLines(lines), - Provider: "Musixmatch", - Source: fmt.Sprintf("Musixmatch (%s)", lang), - }, nil + if resp := lyricsResponseFromLRCText(lrcText, "Musixmatch", fmt.Sprintf("Musixmatch (%s)", lang)); resp != nil { + return resp, nil } - - plainLines := plainTextLyricsLines(lrcText) - if len(plainLines) > 0 { - return &LyricsResponse{ - Lines: plainLines, - SyncType: "UNSYNCED", - PlainLyrics: lrcText, - Provider: "Musixmatch", - Source: fmt.Sprintf("Musixmatch (%s)", lang), - }, nil - } - return nil, lyricsNotFoundErrorf("no lyrics found on musixmatch for language %s", lang) } @@ -136,27 +117,8 @@ func (c *MusixmatchClient) FetchLyrics(trackName, artistName string, durationSec return nil, err } - lines := parseSyncedLyrics(lrcText) - if len(lines) > 0 { - return &LyricsResponse{ - Lines: lines, - SyncType: "LINE_SYNCED", - PlainLyrics: plainLyricsFromTimedLines(lines), - Provider: "Musixmatch", - Source: "Musixmatch", - }, nil + if resp := lyricsResponseFromLRCText(lrcText, "Musixmatch", "Musixmatch"); resp != nil { + return resp, nil } - - plainLines := plainTextLyricsLines(lrcText) - if len(plainLines) > 0 { - return &LyricsResponse{ - Lines: plainLines, - SyncType: "UNSYNCED", - PlainLyrics: lrcText, - Provider: "Musixmatch", - Source: "Musixmatch", - }, nil - } - return nil, lyricsNotFoundErrorf("no lyrics found on musixmatch") } diff --git a/go_backend/lyrics_netease.go b/go_backend/lyrics_netease.go index feaefeac..e2d44031 100644 --- a/go_backend/lyrics_netease.go +++ b/go_backend/lyrics_netease.go @@ -199,36 +199,8 @@ func (c *NeteaseClient) FetchLyrics( return nil, err } - lines := parseSyncedLyrics(lrcText) - if len(lines) == 0 { - plainLines := strings.Split(lrcText, "\n") - for _, line := range plainLines { - trimmed := strings.TrimSpace(line) - if trimmed != "" { - lines = append(lines, LyricsLine{ - StartTimeMs: 0, - Words: trimmed, - EndTimeMs: 0, - }) - } - } - - if len(lines) == 0 { - return nil, fmt.Errorf("netease returned empty lyrics") - } - - return &LyricsResponse{ - Lines: lines, - SyncType: "UNSYNCED", - Provider: "Netease", - Source: "Netease", - }, nil + if resp := lyricsResponseFromLRCText(lrcText, "Netease", "Netease"); resp != nil { + return resp, nil } - - return &LyricsResponse{ - Lines: lines, - SyncType: "LINE_SYNCED", - Provider: "Netease", - Source: "Netease", - }, nil + return nil, fmt.Errorf("netease returned empty lyrics") } diff --git a/go_backend/lyrics_paxsenix.go b/go_backend/lyrics_paxsenix.go index 97463ba5..0d27905d 100644 --- a/go_backend/lyrics_paxsenix.go +++ b/go_backend/lyrics_paxsenix.go @@ -194,29 +194,34 @@ func parsePaxsenixLyricsPayload(raw, provider string, multiPersonWordByWord bool return nil, fmt.Errorf("failed to decode %s lyrics response", provider) } -func lyricsResponseFromText(text, provider string) *LyricsResponse { - lines := parseSyncedLyrics(text) - if len(lines) > 0 { +// lyricsResponseFromLRCText parses LRC-or-plain text into a response, or nil +// when the text contains no usable lines. +func lyricsResponseFromLRCText(text, provider, source string) *LyricsResponse { + if lines := parseSyncedLyrics(text); len(lines) > 0 { return &LyricsResponse{ Lines: lines, SyncType: "LINE_SYNCED", PlainLyrics: plainLyricsFromTimedLines(lines), Provider: provider, - Source: provider, + Source: source, } } - - plainLines := plainTextLyricsLines(text) - if len(plainLines) > 0 { + if lines := plainTextLyricsLines(text); len(lines) > 0 { return &LyricsResponse{ - Lines: plainLines, + Lines: lines, SyncType: "UNSYNCED", PlainLyrics: text, Provider: provider, - Source: provider, + Source: source, } } + return nil +} +func lyricsResponseFromText(text, provider string) *LyricsResponse { + if resp := lyricsResponseFromLRCText(text, provider, provider); resp != nil { + return resp + } return &LyricsResponse{Provider: provider, Source: provider} } diff --git a/go_backend/lyrics_qqmusic.go b/go_backend/lyrics_qqmusic.go index 163f25e5..a6b6d19a 100644 --- a/go_backend/lyrics_qqmusic.go +++ b/go_backend/lyrics_qqmusic.go @@ -113,26 +113,8 @@ func (c *QQMusicClient) FetchLyrics( } } - lines := parseSyncedLyrics(lrcText) - if len(lines) > 0 { - return &LyricsResponse{ - Lines: lines, - SyncType: "LINE_SYNCED", - Provider: "QQ Music", - Source: "QQ Music", - }, nil + if resp := lyricsResponseFromLRCText(lrcText, "QQ Music", "QQ Music"); resp != nil { + return resp, nil } - - resultLines := plainTextLyricsLines(lrcText) - - if len(resultLines) > 0 { - return &LyricsResponse{ - Lines: resultLines, - SyncType: "UNSYNCED", - Provider: "QQ Music", - Source: "QQ Music", - }, nil - } - return nil, lyricsNotFoundErrorf("no lyrics found on qqmusic") } diff --git a/go_backend/misc_coverage_supplement_test.go b/go_backend/misc_coverage_supplement_test.go index 47c5e094..d2ea69c0 100644 --- a/go_backend/misc_coverage_supplement_test.go +++ b/go_backend/misc_coverage_supplement_test.go @@ -229,9 +229,9 @@ func TestExtensionHealthInitializeVMAndCustomSearchWrappers(t *testing.T) { if tracks, err := provider.CustomSearch("needle", map[string]any{"type": "track"}); err != nil || len(tracks) == 0 { t.Fatalf("CustomSearch = %#v/%v", tracks, err) } - cancelMu.Lock() - delete(cancelMap, "custom-item-unique") - cancelMu.Unlock() + downloadCancels.mu.Lock() + delete(downloadCancels.entries, "custom-item-unique") + downloadCancels.mu.Unlock() if tracks, err := provider.customSearch("needle", nil, "custom-item-unique", ""); err != nil || len(tracks) == 0 { t.Fatalf("customSearch (item ID) = %#v/%v", tracks, err) } diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index c5b2ad6b..949bed2d 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -1427,38 +1427,50 @@ class ExtensionNotifier extends Notifier { } } + /// Shared load path for the download/metadata priority lists: prefs first + /// (sanitized), falling back to backend defaults, then persist + push the + /// result back to the backend. + Future> _loadPriorityList({ + required String prefsKey, + required String label, + required List Function(List) sanitizeStored, + required List Function(List) sanitizeBackend, + required Future> Function() fetchBackend, + required Future Function(List) pushBackend, + }) async { + final prefs = await SharedPreferences.getInstance(); + final savedJson = prefs.getString(prefsKey); + + List priority; + if (savedJson != null) { + final saved = _tryDecodeStringListPreference(savedJson, prefsKey); + if (saved != null) { + priority = sanitizeStored(saved); + _log.d('Loaded $label from prefs: $priority'); + } else { + await prefs.remove(prefsKey); + priority = sanitizeBackend(await fetchBackend()); + _log.d('Recovered $label from defaults: $priority'); + } + } else { + priority = sanitizeBackend(await fetchBackend()); + _log.d('Using default $label: $priority'); + } + await prefs.setString(prefsKey, jsonEncode(priority)); + await pushBackend(priority); + return priority; + } + Future loadProviderPriority() async { try { - final prefs = await SharedPreferences.getInstance(); - final savedJson = prefs.getString(_providerPriorityKey); - - List priority; - if (savedJson != null) { - final saved = _tryDecodeStringListPreference( - savedJson, - _providerPriorityKey, - ); - if (saved != null) { - priority = _sanitizeDownloadProviderPriority(saved); - _log.d('Loaded provider priority from prefs: $priority'); - await prefs.setString(_providerPriorityKey, jsonEncode(priority)); - await PlatformBridge.setProviderPriority(priority); - } else { - await prefs.remove(_providerPriorityKey); - priority = await PlatformBridge.getProviderPriority(); - priority = _sanitizeDownloadProviderPriority(priority); - await prefs.setString(_providerPriorityKey, jsonEncode(priority)); - await PlatformBridge.setProviderPriority(priority); - _log.d('Recovered provider priority from defaults: $priority'); - } - } else { - priority = await PlatformBridge.getProviderPriority(); - priority = _sanitizeDownloadProviderPriority(priority); - await prefs.setString(_providerPriorityKey, jsonEncode(priority)); - await PlatformBridge.setProviderPriority(priority); - _log.d('Using default provider priority: $priority'); - } - + final priority = await _loadPriorityList( + prefsKey: _providerPriorityKey, + label: 'provider priority', + sanitizeStored: _sanitizeDownloadProviderPriority, + sanitizeBackend: _sanitizeDownloadProviderPriority, + fetchBackend: PlatformBridge.getProviderPriority, + pushBackend: PlatformBridge.setProviderPriority, + ); state = state.copyWith(providerPriority: priority); } catch (e) { _log.e('Failed to load provider priority: $e'); @@ -1502,51 +1514,16 @@ class ExtensionNotifier extends Notifier { Future loadMetadataProviderPriority() async { try { - final prefs = await SharedPreferences.getInstance(); - final savedJson = prefs.getString(_metadataProviderPriorityKey); - - List priority; - if (savedJson != null) { - final saved = _tryDecodeStringListPreference( - savedJson, - _metadataProviderPriorityKey, - ); - if (saved != null) { - priority = _sanitizeMetadataProviderPriority( - _replaceRetiredBuiltInMetadataProviders(saved), - ); - _log.d('Loaded metadata provider priority from prefs: $priority'); - await prefs.setString( - _metadataProviderPriorityKey, - jsonEncode(priority), - ); - await PlatformBridge.setMetadataProviderPriority(priority); - } else { - await prefs.remove(_metadataProviderPriorityKey); - final backendPriority = - await PlatformBridge.getMetadataProviderPriority(); - priority = _sanitizeMetadataProviderPriority(backendPriority); - await prefs.setString( - _metadataProviderPriorityKey, - jsonEncode(priority), - ); - await PlatformBridge.setMetadataProviderPriority(priority); - _log.d( - 'Recovered metadata provider priority from defaults: $priority', - ); - } - } else { - final backendPriority = - await PlatformBridge.getMetadataProviderPriority(); - priority = _sanitizeMetadataProviderPriority(backendPriority); - _log.d('Using default metadata provider priority: $priority'); - await prefs.setString( - _metadataProviderPriorityKey, - jsonEncode(priority), - ); - await PlatformBridge.setMetadataProviderPriority(priority); - } - + final priority = await _loadPriorityList( + prefsKey: _metadataProviderPriorityKey, + label: 'metadata provider priority', + sanitizeStored: (saved) => _sanitizeMetadataProviderPriority( + _replaceRetiredBuiltInMetadataProviders(saved), + ), + sanitizeBackend: _sanitizeMetadataProviderPriority, + fetchBackend: PlatformBridge.getMetadataProviderPriority, + pushBackend: PlatformBridge.setMetadataProviderPriority, + ); state = state.copyWith(metadataProviderPriority: priority); } catch (e) { _log.e('Failed to load metadata provider priority: $e'); diff --git a/lib/providers/repo_provider.dart b/lib/providers/repo_provider.dart index 9b4616cf..5ca2a461 100644 --- a/lib/providers/repo_provider.dart +++ b/lib/providers/repo_provider.dart @@ -364,15 +364,33 @@ class RepoNotifier extends Notifier { String extensionsDir, ) { return _runSerialized( - () => _installExtensionInternal(extensionId, tempDir, extensionsDir), + () => _downloadAndApplyExtension( + extensionId, + tempDir, + action: 'install', + apply: (notifier, path) => notifier.installExtension(path), + ), ); } - Future _installExtensionInternal( + Future updateExtension(String extensionId, String tempDir) { + return _runSerialized( + () => _downloadAndApplyExtension( + extensionId, + tempDir, + action: 'update', + apply: (notifier, path) => notifier.upgradeExtension(path), + ), + ); + } + + Future _downloadAndApplyExtension( String extensionId, - String tempDir, - String extensionsDir, - ) async { + String tempDir, { + required String action, + required Future Function(ExtensionNotifier notifier, String path) + apply, + }) async { state = state.copyWith( isDownloading: true, downloadingId: extensionId, @@ -386,64 +404,21 @@ class RepoNotifier extends Notifier { tempDir, ); - _log.i('Installing extension from: $downloadPath'); - final extNotifier = ref.read(extensionProvider.notifier); - final success = await extNotifier.installExtension(downloadPath); + _log.i('Applying $action from: $downloadPath'); + final success = await apply( + ref.read(extensionProvider.notifier), + downloadPath, + ); if (success) { - _log.i('Extension installed: $extensionId'); + _log.i('Extension $action succeeded: $extensionId'); await refresh(); } state = state.copyWith(isDownloading: false, clearDownloadingId: true); return success; } catch (e) { - _log.e('Failed to install extension: $e'); - state = state.copyWith( - isDownloading: false, - clearDownloadingId: true, - error: e.toString(), - ); - return false; - } - } - - Future updateExtension(String extensionId, String tempDir) { - return _runSerialized( - () => _updateExtensionInternal(extensionId, tempDir), - ); - } - - Future _updateExtensionInternal( - String extensionId, - String tempDir, - ) async { - state = state.copyWith( - isDownloading: true, - downloadingId: extensionId, - clearError: true, - ); - - try { - _log.i('Downloading update for: $extensionId'); - final downloadPath = await PlatformBridge.downloadRepoExtension( - extensionId, - tempDir, - ); - - _log.i('Upgrading extension from: $downloadPath'); - final extNotifier = ref.read(extensionProvider.notifier); - final success = await extNotifier.upgradeExtension(downloadPath); - - if (success) { - _log.i('Extension updated: $extensionId'); - await refresh(); - } - - state = state.copyWith(isDownloading: false, clearDownloadingId: true); - return success; - } catch (e) { - _log.e('Failed to update extension: $e'); + _log.e('Failed to $action extension: $e'); state = state.copyWith( isDownloading: false, clearDownloadingId: true, diff --git a/lib/screens/downloaded_album_screen.dart b/lib/screens/downloaded_album_screen.dart index d35f5e72..579bc993 100644 --- a/lib/screens/downloaded_album_screen.dart +++ b/lib/screens/downloaded_album_screen.dart @@ -23,6 +23,7 @@ import 'package:spotiflac_android/screens/selection_mode_mixin.dart'; import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart'; import 'package:spotiflac_android/widgets/album_scaffold_body.dart'; +import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/widgets/album_track_tile.dart'; import 'package:spotiflac_android/widgets/animation_utils.dart'; import 'package:spotiflac_android/widgets/destructive_selection_button.dart'; @@ -206,7 +207,7 @@ class _DownloadedAlbumScreenState extends ConsumerState required int navigationIndex, }) async { final navigator = Navigator.of(context); - _precacheCover(item.coverUrl); + precacheCoverImage(context, item.coverUrl); final beforeModTime = await DownloadedEmbeddedCoverResolver.readFileModTimeMillis( item.filePath, @@ -230,28 +231,6 @@ class _DownloadedAlbumScreenState extends ConsumerState ); } - void _precacheCover(String? url) { - if (url == null || url.isEmpty) return; - if (!url.startsWith('http://') && !url.startsWith('https://')) { - return; - } - final dpr = MediaQuery.devicePixelRatioOf( - context, - ).clamp(1.0, 3.0).toDouble(); - final targetSize = (360 * dpr).round().clamp(512, 1024).toInt(); - precacheImage( - ResizeImage( - CachedNetworkImageProvider( - url, - cacheManager: CoverCacheManager.instance, - ), - width: targetSize, - height: targetSize, - ), - context, - ); - } - @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index b659fc7c..aacacc54 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -2359,7 +2359,7 @@ class _HomeTabState extends ConsumerState int? navigationIndex, }) async { final navigator = Navigator.of(context); - _precacheCover(item.coverUrl); + precacheCoverImage(context, item.coverUrl); final beforeModTime = await DownloadedEmbeddedCoverResolver.readFileModTimeMillis( item.filePath, @@ -2382,25 +2382,6 @@ class _HomeTabState extends ConsumerState ); } - void _precacheCover(String? url) { - if (url == null || url.isEmpty) return; - if (!url.startsWith('http://') && !url.startsWith('https://')) { - return; - } - final dpr = MediaQuery.devicePixelRatioOf( - context, - ).clamp(1.0, 3.0).toDouble(); - final targetSize = (360 * dpr).round().clamp(512, 1024).toInt(); - precacheImage( - ResizeImage( - cachedCoverImageProvider(url), - width: targetSize, - height: targetSize, - ), - context, - ); - } - Widget _buildErrorWidget(String error, ColorScheme colorScheme) { final l10n = context.l10n; final isRateLimit = diff --git a/lib/screens/queue_tab_navigation.dart b/lib/screens/queue_tab_navigation.dart index f8a5d8af..a0b782dc 100644 --- a/lib/screens/queue_tab_navigation.dart +++ b/lib/screens/queue_tab_navigation.dart @@ -93,25 +93,6 @@ extension _QueueTabNavigation on _QueueTabState { ); } - void _precacheCover(String? url) { - if (url == null || url.isEmpty) return; - if (!url.startsWith('http://') && !url.startsWith('https://')) { - return; - } - final dpr = MediaQuery.devicePixelRatioOf( - context, - ).clamp(1.0, 3.0).toDouble(); - final targetSize = (360 * dpr).round().clamp(512, 1024).toInt(); - precacheImage( - ResizeImage( - cachedCoverImageProvider(url), - width: targetSize, - height: targetSize, - ), - context, - ); - } - Future _navigateToMetadataScreen(DownloadItem item) async { final historyItem = ref .read(downloadHistoryProvider) @@ -131,7 +112,7 @@ extension _QueueTabNavigation on _QueueTabState { ); final navigator = Navigator.of(context); - _precacheCover(historyItem.coverUrl); + precacheCoverImage(context, historyItem.coverUrl); _searchFocusNode.unfocus(); final beforeModTime = await _readFileModTimeMillis(historyItem.filePath); if (!mounted) return; @@ -159,7 +140,7 @@ extension _QueueTabNavigation on _QueueTabState { int? navigationIndex, }) async { final navigator = Navigator.of(context); - _precacheCover(item.coverUrl); + precacheCoverImage(context, item.coverUrl); _searchFocusNode.unfocus(); final beforeModTime = await _readFileModTimeMillis(item.filePath); if (!mounted) return; diff --git a/lib/widgets/cached_cover_image.dart b/lib/widgets/cached_cover_image.dart index effb5222..c6490048 100644 --- a/lib/widgets/cached_cover_image.dart +++ b/lib/widgets/cached_cover_image.dart @@ -180,6 +180,25 @@ CachedNetworkImageProvider cachedCoverImageProvider(String url) { ); } +/// Pre-warms the cover cache at the metadata-screen display size so the hero +/// transition doesn't pop in a low-res frame. Http(s) URLs only. +void precacheCoverImage(BuildContext context, String? url) { + if (url == null || url.isEmpty) return; + if (!url.startsWith('http://') && !url.startsWith('https://')) { + return; + } + final dpr = MediaQuery.devicePixelRatioOf(context).clamp(1.0, 3.0).toDouble(); + final targetSize = (360 * dpr).round().clamp(512, 1024).toInt(); + precacheImage( + ResizeImage( + cachedCoverImageProvider(url), + width: targetSize, + height: targetSize, + ), + context, + ); +} + int coverImageCacheExtent( BuildContext context, double logicalSize, {