mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-03 09:38:35 +02:00
refactor: consolidate lyrics builders, cancel registries, and priority plumbing
This commit is contained in:
+95
-135
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user