fix provider fallbacks and public branding

This commit is contained in:
zarzet
2026-05-04 00:51:52 +07:00
parent 1b4a6cd042
commit e187ac461d
45 changed files with 615 additions and 238 deletions
+109 -34
View File
@@ -110,6 +110,7 @@ class ExploreState {
final bool isLoading;
final String? error;
final String? greeting;
final String? providerId;
final List<ExploreSection> sections;
final DateTime? lastFetched;
@@ -117,6 +118,7 @@ class ExploreState {
this.isLoading = false,
this.error,
this.greeting,
this.providerId,
this.sections = const [],
this.lastFetched,
});
@@ -127,6 +129,8 @@ class ExploreState {
bool? isLoading,
String? error,
String? greeting,
String? providerId,
bool clearProviderId = false,
List<ExploreSection>? sections,
DateTime? lastFetched,
}) {
@@ -134,6 +138,7 @@ class ExploreState {
isLoading: isLoading ?? this.isLoading,
error: error,
greeting: greeting ?? this.greeting,
providerId: clearProviderId ? null : (providerId ?? this.providerId),
sections: sections ?? this.sections,
lastFetched: lastFetched ?? this.lastFetched,
);
@@ -189,14 +194,54 @@ List<Map<String, Object?>> _normalizeExploreSectionsPayload(
return sections;
}
List<Map<String, Object?>> _decodeExploreCacheSections(String rawCache) {
final decoded = jsonDecode(rawCache);
if (decoded is! Map) return const [];
return _normalizeExploreSectionsPayload(decoded['sections']);
List<Map<String, Object?>> _withDefaultExploreProviderId(
List<Map<String, Object?>> normalizedSections,
String providerId,
) {
final normalizedProviderId = providerId.trim();
if (normalizedProviderId.isEmpty) return normalizedSections;
return normalizedSections
.map((section) {
final rawItems = section['items'];
if (rawItems is! List) return section;
return <String, Object?>{
...section,
'items': rawItems
.map((rawItem) {
if (rawItem is! Map) return rawItem;
final item = Map<String, Object?>.from(rawItem);
final itemProviderId =
item['provider_id']?.toString().trim() ?? '';
if (itemProviderId.isEmpty) {
item['provider_id'] = normalizedProviderId;
}
return item;
})
.toList(growable: false),
};
})
.toList(growable: false);
}
String _encodeExploreCacheSections(List<Map<String, Object?>> sections) {
return jsonEncode({'sections': sections});
Map<String, Object?> _decodeExploreCache(String rawCache) {
final decoded = jsonDecode(rawCache);
if (decoded is! Map) {
return const {'provider_id': null, 'sections': <Map<String, Object?>>[]};
}
final providerId = decoded['provider_id']?.toString().trim();
var sections = _normalizeExploreSectionsPayload(decoded['sections']);
if (providerId != null && providerId.isNotEmpty) {
sections = _withDefaultExploreProviderId(sections, providerId);
}
return {'provider_id': providerId, 'sections': sections};
}
String _encodeExploreCache(Map<String, Object?> cachePayload) {
return jsonEncode(cachePayload);
}
List<ExploreSection> _buildExploreSectionsFromNormalizedPayload(
@@ -234,10 +279,24 @@ class ExploreNotifier extends Notifier<ExploreState> {
final cachedTs = prefs.getInt(_cacheTsKey);
if (cached == null || cached.isEmpty) return;
final normalizedSections = await compute(
_decodeExploreCacheSections,
cached,
);
final cachePayload = await compute(_decodeExploreCache, cached);
final providerId = cachePayload['provider_id']?.toString().trim();
final rawSections = cachePayload['sections'];
var normalizedSections = rawSections is List
? rawSections
.whereType<Map<Object?, Object?>>()
.map((section) => Map<String, Object?>.from(section))
.toList(growable: false)
: const <Map<String, Object?>>[];
final resolvedProviderId = providerId?.isNotEmpty == true
? providerId
: _resolveHomeFeedExtension()?.id;
if (resolvedProviderId != null && resolvedProviderId.isNotEmpty) {
normalizedSections = _withDefaultExploreProviderId(
normalizedSections,
resolvedProviderId,
);
}
final sections = _buildExploreSectionsFromNormalizedPayload(
normalizedSections,
);
@@ -251,23 +310,51 @@ class ExploreNotifier extends Notifier<ExploreState> {
_log.i('Restored ${sections.length} cached explore sections');
state = ExploreState(
greeting: _getLocalGreeting(),
providerId: resolvedProviderId,
sections: sections,
lastFetched: lastFetched,
);
} catch (e) {
_log.w('Failed to restore explore cache: $e');
try {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_cacheKey);
await prefs.remove(_cacheTsKey);
_log.d('Removed invalid explore cache');
} catch (clearError) {
_log.w('Failed to remove invalid explore cache: $clearError');
}
}
}
Extension? _resolveHomeFeedExtension() {
final settings = ref.read(settingsProvider);
final preferredId = settings.homeFeedProvider;
final enabledHomeFeedExtensions = ref
.read(extensionProvider)
.extensions
.where((extension) => extension.enabled && extension.hasHomeFeed)
.toList(growable: false);
if (preferredId != null && preferredId.isNotEmpty) {
return enabledHomeFeedExtensions
.where((extension) => extension.id == preferredId)
.firstOrNull;
}
return enabledHomeFeedExtensions.firstOrNull;
}
Future<void> _saveToCache(
List<Map<String, Object?>> normalizedSections,
String providerId,
) async {
try {
final prefs = await SharedPreferences.getInstance();
final encoded = await compute(
_encodeExploreCacheSections,
normalizedSections,
);
final encoded = await compute(_encodeExploreCache, {
'provider_id': providerId,
'sections': normalizedSections,
});
await prefs.setString(_cacheKey, encoded);
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
_log.d('Saved ${normalizedSections.length} explore sections to cache');
@@ -313,24 +400,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId',
);
Extension? targetExt;
for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) {
continue;
}
if (preferredId != null &&
preferredId.isNotEmpty &&
extension.id == preferredId) {
targetExt = extension;
break;
}
if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension;
if (preferredId == null && extension.id == 'spotify-web') {
break;
}
}
}
final targetExt = _resolveHomeFeedExtension();
if (targetExt == null) {
_log.w('No extension with homeFeed capability found');
@@ -367,10 +437,14 @@ class ExploreNotifier extends Notifier<ExploreState> {
final greeting = result['greeting'] as String?;
final sectionsData = result['sections'] as List<dynamic>? ?? [];
final normalizedSections = await compute(
final normalizedSectionsWithoutProvider = await compute(
_normalizeExploreSectionsPayload,
sectionsData,
);
final normalizedSections = _withDefaultExploreProviderId(
normalizedSectionsWithoutProvider,
targetExt.id,
);
if (requestId != _homeFeedRequestId) return;
final sections = _buildExploreSectionsFromNormalizedPayload(
normalizedSections,
@@ -391,11 +465,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
state = ExploreState(
isLoading: false,
greeting: localGreeting,
providerId: targetExt.id,
sections: sections,
lastFetched: DateTime.now(),
);
_saveToCache(normalizedSections);
_saveToCache(normalizedSections, targetExt.id);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
if (requestId != _homeFeedRequestId) return;
+68 -16
View File
@@ -24,6 +24,30 @@ bool _stringListEquals(List<String> a, List<String> b) {
return true;
}
List<String>? _tryDecodeStringListPreference(String rawJson, String key) {
try {
final decoded = jsonDecode(rawJson);
if (decoded is! List) {
throw const FormatException('expected a JSON list');
}
final values = <String>[];
for (final item in decoded) {
if (item is! String) {
throw const FormatException('expected string entries');
}
final trimmed = item.trim();
if (trimmed.isNotEmpty) {
values.add(trimmed);
}
}
return values;
} catch (e) {
_log.w('Ignoring invalid $key preference: $e');
return null;
}
}
class BuiltInProviderSpec {
final String id;
final String displayName;
@@ -1630,15 +1654,27 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
List<String> priority;
if (savedJson != null) {
final saved = jsonDecode(savedJson) as List<dynamic>;
priority = saved.map((e) => e as String).toList();
priority = _sanitizeDownloadProviderPriority(priority);
_log.d('Loaded provider priority from prefs: $priority');
await prefs.setString(_providerPriorityKey, jsonEncode(priority));
await PlatformBridge.setProviderPriority(priority);
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');
}
@@ -1691,18 +1727,34 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
List<String> priority;
if (savedJson != null) {
final saved = jsonDecode(savedJson) as List<dynamic>;
priority = _sanitizeMetadataProviderPriority(
_replaceRetiredBuiltInMetadataProviders(
saved.map((e) => e as String).toList(),
),
);
_log.d('Loaded metadata provider priority from prefs: $priority');
await prefs.setString(
final saved = _tryDecodeStringListPreference(
savedJson,
_metadataProviderPriorityKey,
jsonEncode(priority),
);
await PlatformBridge.setMetadataProviderPriority(priority);
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();
+50 -33
View File
@@ -11,6 +11,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _settingsCorruptBackupKey = 'app_settings_corrupt_backup';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 11;
const _spotifyClientSecretKey = 'spotify_client_secret';
@@ -41,40 +42,56 @@ class SettingsNotifier extends Notifier<AppSettings> {
Future<void> _loadSettings() async {
final prefs = await _prefs;
final json = prefs.getString(_settingsKey);
if (json != null) {
final loaded = AppSettings.fromJson(
Map<String, dynamic>.from(jsonDecode(json) as Map),
);
final sanitizedDownloadFallbackExtensionIds =
_sanitizeDownloadFallbackExtensionIds(
loaded.downloadFallbackExtensionIds,
);
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
loaded.defaultSearchTab,
);
final sanitizedDefaultService = _sanitizeRetiredBuiltInProviderId(
loaded.defaultService,
);
final sanitizedSearchProvider = _sanitizeRetiredBuiltInProviderId(
loaded.searchProvider,
);
state = loaded.copyWith(
useExtensionProviders: true,
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
clearDownloadFallbackExtensionIds:
loaded.downloadFallbackExtensionIds != null &&
sanitizedDownloadFallbackExtensionIds == null,
defaultSearchTab: sanitizedDefaultSearchTab,
defaultService: sanitizedDefaultService ?? '',
searchProvider: sanitizedSearchProvider,
clearSearchProvider:
loaded.searchProvider != null && sanitizedSearchProvider == null,
);
final rawSettings = prefs.getString(_settingsKey);
if (rawSettings != null) {
AppSettings? loaded;
try {
final decoded = jsonDecode(rawSettings);
if (decoded is! Map) {
throw const FormatException('settings root must be a JSON object');
}
loaded = AppSettings.fromJson(Map<String, dynamic>.from(decoded));
} catch (e, stack) {
_log.e('Failed to load settings, resetting to defaults: $e', e, stack);
try {
await prefs.setString(_settingsCorruptBackupKey, rawSettings);
await prefs.remove(_settingsKey);
} catch (backupError) {
_log.w('Failed to backup corrupt settings: $backupError');
}
}
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
if (loaded != null) {
final sanitizedDownloadFallbackExtensionIds =
_sanitizeDownloadFallbackExtensionIds(
loaded.downloadFallbackExtensionIds,
);
final sanitizedDefaultSearchTab = _normalizeDefaultSearchTab(
loaded.defaultSearchTab,
);
final sanitizedDefaultService = _sanitizeRetiredBuiltInProviderId(
loaded.defaultService,
);
final sanitizedSearchProvider = _sanitizeRetiredBuiltInProviderId(
loaded.searchProvider,
);
state = loaded.copyWith(
useExtensionProviders: true,
downloadFallbackExtensionIds: sanitizedDownloadFallbackExtensionIds,
clearDownloadFallbackExtensionIds:
loaded.downloadFallbackExtensionIds != null &&
sanitizedDownloadFallbackExtensionIds == null,
defaultSearchTab: sanitizedDefaultSearchTab,
defaultService: sanitizedDefaultService ?? '',
searchProvider: sanitizedSearchProvider,
clearSearchProvider:
loaded.searchProvider != null && sanitizedSearchProvider == null,
);
await _runMigrations(prefs);
await _normalizeIosDownloadDirectoryIfNeeded();
await _normalizeSongLinkRegionIfNeeded();
}
}
await _cleanupRetiredSpotifySettings();
+8 -4
View File
@@ -449,7 +449,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumName: albumInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
_preWarmCacheForTracks(tracks, service: providerId);
return;
case 'playlist':
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
@@ -469,7 +469,7 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: playlistName,
coverUrl: coverUrl,
);
_preWarmCacheForTracks(tracks);
_preWarmCacheForTracks(tracks, service: providerId);
return;
case 'artist':
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
@@ -1054,7 +1054,7 @@ class TrackNotifier extends Notifier<TrackState> {
);
}
void _preWarmCacheForTracks(List<Track> tracks) {
void _preWarmCacheForTracks(List<Track> tracks, {String? service}) {
if (tracks.isEmpty) return;
final cacheRequests = <Map<String, String>>[];
for (final track in tracks) {
@@ -1062,12 +1062,16 @@ class TrackNotifier extends Notifier<TrackState> {
if (isrc == null || isrc.isEmpty) {
continue;
}
final effectiveService =
(track.source?.trim().isNotEmpty == true ? track.source : service)
?.trim();
cacheRequests.add({
'isrc': isrc,
'track_name': track.name,
'artist_name': track.artistName,
'spotify_id': track.id,
'service': 'tidal',
if (effectiveService != null && effectiveService.isNotEmpty)
'service': effectiveService,
});
if (cacheRequests.length >= _maxPreWarmTracksPerRequest) {
break;