feat: add home feed provider setting, fix Qobuz cover URL propagation

- Add homeFeedProvider field to AppSettings with picker UI in extensions page
- Update explore_provider to respect user's home feed provider preference
- Add normalizeCoverReference() and normalizeRemoteHttpUrl() to filter
  invalid cover URLs (no scheme, no host, protocol-relative)
- Apply cover URL normalization across all screens and providers to
  prevent 'no host specified in URI' errors from Qobuz
- Propagate CoverURL from QobuzDownloadResult through Go backend so
  cover art is available even when request metadata is incomplete
This commit is contained in:
zarzet committed 2026-03-25 15:46:22 +07:00
1 parent c91154ea3e
commit 3a73aee1b7
16 files changed
+252 -95

No files matched your search

+17 -9
View File
@@ -122,7 +122,7 @@ class DownloadHistoryItem {
artistName: json['artistName'] as String,
albumName: json['albumName'] as String,
albumArtist: normalizeOptionalString(json['albumArtist'] as String?),
coverUrl: json['coverUrl'] as String?,
coverUrl: normalizeCoverReference(json['coverUrl']?.toString()),
filePath: json['filePath'] as String,
storageMode: json['storageMode'] as String?,
downloadTreeUri: json['downloadTreeUri'] as String?,
@@ -176,7 +176,7 @@ class DownloadHistoryItem {
artistName: artistName ?? this.artistName,
albumName: albumName ?? this.albumName,
albumArtist: albumArtist ?? this.albumArtist,
coverUrl: coverUrl ?? this.coverUrl,
coverUrl: normalizeCoverReference(coverUrl ?? this.coverUrl),
filePath: filePath ?? this.filePath,
storageMode: storageMode ?? this.storageMode,
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
@@ -2534,8 +2534,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendIsrc = normalizeOptionalString(
backendResult['isrc'] as String?,
);
final backendCoverUrl = normalizeOptionalString(
backendResult['cover_url'] as String?,
final backendCoverUrl = normalizeCoverReference(
backendResult['cover_url']?.toString(),
);
final backendAlbumArtist = normalizeOptionalString(
backendResult['album_artist'] as String?,
@@ -2591,7 +2591,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
String? coverPath;
var coverUrl = track.coverUrl;
var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
if (settings.maxQualityCover) {
@@ -2777,7 +2777,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
String? coverPath;
var coverUrl = track.coverUrl;
var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
if (settings.maxQualityCover) {
@@ -2945,7 +2945,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
}
String? coverPath;
var coverUrl = track.coverUrl;
var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
if (coverUrl != null && coverUrl.isNotEmpty) {
try {
if (settings.maxQualityCover) {
@@ -3751,7 +3751,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumArtist: trackToDownload.albumArtist,
artistId: trackToDownload.artistId,
albumId: trackToDownload.albumId,
coverUrl: trackToDownload.coverUrl,
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
duration: trackToDownload.duration,
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
? deezerIsrc
@@ -4041,6 +4041,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
item.service.toLowerCase();
final decryptionKey =
(result['decryption_key'] as String?)?.trim() ?? '';
trackToDownload = _buildTrackForMetadataEmbedding(
trackToDownload,
result,
resolvedAlbumArtist,
);
_log.d(
'Track coverUrl after download result: ${trackToDownload.coverUrl}',
);
if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
_log.i('Encrypted stream detected, decrypting via FFmpeg...');
@@ -4959,7 +4967,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? backendAlbum
: trackToDownload.albumName,
albumArtist: historyAlbumArtist,
coverUrl: trackToDownload.coverUrl,
coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
filePath: filePath,
storageMode: effectiveSafMode ? 'saf' : 'app',
downloadTreeUri: effectiveSafMode
+29 -23
View File
@@ -4,6 +4,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
final _log = AppLogger('ExploreProvider');
@@ -202,9 +203,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
Future<void> _saveToCache(List<ExploreSection> sections) async {
try {
final prefs = await SharedPreferences.getInstance();
final data = {
'sections': sections.map((s) => s.toJson()).toList(),
};
final data = {'sections': sections.map((s) => s.toJson()).toList()};
await prefs.setString(_cacheKey, jsonEncode(data));
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
_log.d('Saved ${sections.length} explore sections to cache');
@@ -216,16 +215,16 @@ class ExploreNotifier extends Notifier<ExploreState> {
/// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// If we have cached content and it's fresh enough, skip network fetch
if (!forceRefresh &&
state.hasContent &&
if (!forceRefresh &&
state.hasContent &&
state.lastFetched != null &&
DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
_log.d('Using cached home feed (fresh enough)');
return;
}
if (state.isLoading) {
_log.d('Home feed fetch already in progress');
return;
@@ -237,21 +236,33 @@ class ExploreNotifier extends Notifier<ExploreState> {
try {
final extState = ref.read(extensionProvider);
_log.d('Extensions count: ${extState.extensions.length}');
final settings = ref.read(settingsProvider);
final preferredId = settings.homeFeedProvider;
_log.d(
'Extensions count: ${extState.extensions.length}, preferred home feed: $preferredId',
);
Extension? targetExt;
for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) {
continue;
}
// If user has a preference, use that
if (preferredId != null &&
preferredId.isNotEmpty &&
extension.id == preferredId) {
targetExt = extension;
break;
}
// Otherwise take the first available (fallback to spotify-web if found)
if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension;
if (extension.id == 'spotify-web') {
if (preferredId == null && extension.id == 'spotify-web') {
break;
}
}
}
if (targetExt == null) {
_log.w('No extension with homeFeed capability found');
state = state.copyWith(
@@ -260,7 +271,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
);
return;
}
_log.i('Fetching home feed from ${targetExt.id}...');
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
@@ -276,10 +287,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
_log.d('getExtensionHomeFeed success=$success');
if (!success) {
final error = result['error'] as String? ?? 'Unknown error';
state = state.copyWith(
isLoading: false,
error: error,
);
state = state.copyWith(isLoading: false, error: error);
return;
}
@@ -291,10 +299,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
.toList();
_log.i('Fetched ${sections.length} sections');
if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
final firstItem = sections.first.items.first;
_log.d('First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}');
_log.d(
'First item: name=${firstItem.name}, artists=${firstItem.artists}, type=${firstItem.type}',
);
}
final localGreeting = _getLocalGreeting();
@@ -311,10 +321,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
_saveToCache(sections);
} catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack);
state = state.copyWith(
isLoading: false,
error: e.toString(),
);
state = state.copyWith(isLoading: false, error: e.toString());
}
}
@@ -325,7 +332,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
}
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
return ExploreNotifier();
});
+9
View File
@@ -424,6 +424,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setHomeFeedProvider(String? provider) {
if (provider == null || provider.isEmpty) {
state = state.copyWith(clearHomeFeedProvider: true);
} else {
state = state.copyWith(homeFeedProvider: provider);
}
_saveSettings();
}
void setEnableLogging(bool enabled) {
state = state.copyWith(enableLogging: enabled);
_saveSettings();
+40 -26
View File
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -286,7 +287,9 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: type == 'playlist'
? result['name'] as String?
: null,
coverUrl: result['cover_url'] as String?,
coverUrl: normalizeCoverReference(
result['cover_url']?.toString(),
),
searchExtensionId: extensionId,
);
return;
@@ -313,10 +316,12 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistData['id'] as String?,
artistName: artistData['name'] as String?,
coverUrl:
artistData['image_url'] as String? ??
artistData['images'] as String?,
headerImageUrl: artistData['header_image'] as String?,
coverUrl: normalizeRemoteHttpUrl(
(artistData['image_url'] ?? artistData['images'])?.toString(),
),
headerImageUrl: normalizeRemoteHttpUrl(
artistData['header_image']?.toString(),
),
monthlyListeners: artistData['listeners'] as int?,
artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
@@ -357,7 +362,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
albumId: id,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
@@ -371,7 +376,9 @@ class TrackNotifier extends Notifier<TrackState> {
tracks: tracks,
isLoading: false,
playlistName: playlistInfo['name'] as String?,
coverUrl: playlistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(
playlistInfo['images']?.toString(),
),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
@@ -385,7 +392,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
@@ -422,7 +429,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
albumId: 'qobuz:$id',
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
@@ -435,8 +442,9 @@ class TrackNotifier extends Notifier<TrackState> {
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl =
(playlistInfo['images'] ?? owner?['images']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
@@ -455,7 +463,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
@@ -492,7 +500,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
albumId: 'tidal:$id',
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
@@ -505,8 +513,9 @@ class TrackNotifier extends Notifier<TrackState> {
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl =
(playlistInfo['images'] ?? owner?['images']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
@@ -525,7 +534,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
@@ -580,7 +589,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
albumId: parsed['id'] as String?,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
@@ -592,8 +601,9 @@ class TrackNotifier extends Notifier<TrackState> {
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl =
(playlistInfo['images'] ?? owner?['images']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
@@ -612,7 +622,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
@@ -986,7 +996,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist'] as String?,
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: data['images'] as String?,
coverUrl: normalizeCoverReference(data['images']?.toString()),
isrc: data['isrc'] as String?,
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
@@ -1017,7 +1027,9 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(),
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?,
@@ -1062,7 +1074,9 @@ class TrackNotifier extends Notifier<TrackState> {
name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
coverUrl: normalizeCoverReference(
(data['cover_url'] ?? data['images'])?.toString(),
),
albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(),
@@ -1073,7 +1087,7 @@ class TrackNotifier extends Notifier<TrackState> {
return SearchArtist(
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
imageUrl: data['images'] as String?,
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
followers: data['followers'] as int? ?? 0,
popularity: data['popularity'] as int? ?? 0,
);
@@ -1084,7 +1098,7 @@ class TrackNotifier extends Notifier<TrackState> {
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
artists: data['artists'] as String? ?? '',
imageUrl: data['images'] as String?,
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
releaseDate: data['release_date'] as String?,
totalTracks: data['total_tracks'] as int? ?? 0,
albumType: data['album_type'] as String? ?? 'album',
@@ -1096,7 +1110,7 @@ class TrackNotifier extends Notifier<TrackState> {
id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '',
owner: data['owner'] as String? ?? '',
imageUrl: data['images'] as String?,
imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
totalTracks: data['total_tracks'] as int? ?? 0,
);
}