mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-14 23:20:25 +02:00
fix provider fallbacks and public branding
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user