refactor: consolidate lyrics builders, cancel registries, and priority plumbing

This commit is contained in:
zarzet
2026-07-26 19:08:22 +07:00
parent 8d077ae2da
commit 74615a60b5
14 changed files with 235 additions and 460 deletions
+95 -135
View File
@@ -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)
}
+6 -6
View File
@@ -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)
}
+2 -20
View File
@@ -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")
}
+4 -42
View File
@@ -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")
}
+3 -31
View File
@@ -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")
}
+14 -9
View File
@@ -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}
}
+2 -20
View File
@@ -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")
}
+3 -3
View File
@@ -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)
}
+52 -75
View File
@@ -1427,38 +1427,50 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
/// 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<List<String>> _loadPriorityList({
required String prefsKey,
required String label,
required List<String> Function(List<String>) sanitizeStored,
required List<String> Function(List<String>) sanitizeBackend,
required Future<List<String>> Function() fetchBackend,
required Future<void> Function(List<String>) pushBackend,
}) async {
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(prefsKey);
List<String> 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<void> loadProviderPriority() async {
try {
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(_providerPriorityKey);
List<String> 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<ExtensionState> {
Future<void> loadMetadataProviderPriority() async {
try {
final prefs = await SharedPreferences.getInstance();
final savedJson = prefs.getString(_metadataProviderPriorityKey);
List<String> 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');
+30 -55
View File
@@ -364,15 +364,33 @@ class RepoNotifier extends Notifier<RepoState> {
String extensionsDir,
) {
return _runSerialized(
() => _installExtensionInternal(extensionId, tempDir, extensionsDir),
() => _downloadAndApplyExtension(
extensionId,
tempDir,
action: 'install',
apply: (notifier, path) => notifier.installExtension(path),
),
);
}
Future<bool> _installExtensionInternal(
Future<bool> updateExtension(String extensionId, String tempDir) {
return _runSerialized(
() => _downloadAndApplyExtension(
extensionId,
tempDir,
action: 'update',
apply: (notifier, path) => notifier.upgradeExtension(path),
),
);
}
Future<bool> _downloadAndApplyExtension(
String extensionId,
String tempDir,
String extensionsDir,
) async {
String tempDir, {
required String action,
required Future<bool> Function(ExtensionNotifier notifier, String path)
apply,
}) async {
state = state.copyWith(
isDownloading: true,
downloadingId: extensionId,
@@ -386,64 +404,21 @@ class RepoNotifier extends Notifier<RepoState> {
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<bool> updateExtension(String extensionId, String tempDir) {
return _runSerialized(
() => _updateExtensionInternal(extensionId, tempDir),
);
}
Future<bool> _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,
+2 -23
View File
@@ -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<DownloadedAlbumScreen>
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<DownloadedAlbumScreen>
);
}
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;
+1 -20
View File
@@ -2359,7 +2359,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
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<HomeTab>
);
}
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 =
+2 -21
View File
@@ -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<void> _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;
+19
View File
@@ -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, {