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 2026-03-25 15:46:22 +07:00
parent c91154ea3e
commit 3a73aee1b7
16 changed files with 252 additions and 95 deletions

View file

@ -128,6 +128,7 @@ type DownloadResult struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
CoverURL string
Genre string Genre string
Label string Label string
Copyright string Copyright string
@ -214,6 +215,11 @@ func buildDownloadSuccessResponse(
copyright = req.Copyright copyright = req.Copyright
} }
coverURL := strings.TrimSpace(result.CoverURL)
if coverURL == "" {
coverURL = strings.TrimSpace(req.CoverURL)
}
return DownloadResponse{ return DownloadResponse{
Success: true, Success: true,
Message: message, Message: message,
@ -230,7 +236,7 @@ func buildDownloadSuccessResponse(
TrackNumber: trackNumber, TrackNumber: trackNumber,
DiscNumber: discNumber, DiscNumber: discNumber,
ISRC: isrc, ISRC: isrc,
CoverURL: req.CoverURL, CoverURL: coverURL,
Genre: genre, Genre: genre,
Label: label, Label: label,
Copyright: copyright, Copyright: copyright,
@ -378,6 +384,7 @@ func DownloadTrack(requestJSON string) (string, error) {
TrackNumber: qobuzResult.TrackNumber, TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber, DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC, ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
LyricsLRC: qobuzResult.LyricsLRC, LyricsLRC: qobuzResult.LyricsLRC,
} }
} }
@ -586,6 +593,7 @@ func DownloadWithFallback(requestJSON string) (string, error) {
TrackNumber: qobuzResult.TrackNumber, TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber, DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC, ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
LyricsLRC: qobuzResult.LyricsLRC, LyricsLRC: qobuzResult.LyricsLRC,
} }
} else if !errors.Is(qobuzErr, ErrDownloadCancelled) { } else if !errors.Is(qobuzErr, ErrDownloadCancelled) {

View file

@ -84,3 +84,32 @@ func TestPreferredReleaseMetadataPrefersRequestValues(t *testing.T) {
t.Fatalf("disc number = %d", discNumber) t.Fatalf("disc number = %d", discNumber)
} }
} }
func TestBuildDownloadSuccessResponsePrefersProviderCoverURL(t *testing.T) {
req := DownloadRequest{
TrackName: "Track",
ArtistName: "Artist",
AlbumName: "Album",
AlbumArtist: "Artist",
}
result := DownloadResult{
Title: "Track",
Artist: "Artist",
Album: "Album",
CoverURL: "https://cdn.qobuz.test/cover.jpg",
}
resp := buildDownloadSuccessResponse(
req,
result,
"qobuz",
"ok",
"/tmp/test.flac",
false,
)
if resp.CoverURL != result.CoverURL {
t.Fatalf("cover url = %q, want %q", resp.CoverURL, result.CoverURL)
}
}

View file

@ -1480,6 +1480,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: qobuzResult.TrackNumber, TrackNumber: qobuzResult.TrackNumber,
DiscNumber: qobuzResult.DiscNumber, DiscNumber: qobuzResult.DiscNumber,
ISRC: qobuzResult.ISRC, ISRC: qobuzResult.ISRC,
CoverURL: qobuzResult.CoverURL,
} }
} }
err = qobuzErr err = qobuzErr
@ -1522,6 +1523,7 @@ func tryBuiltInProvider(providerID string, req DownloadRequest) (*DownloadRespon
TrackNumber: result.TrackNumber, TrackNumber: result.TrackNumber,
DiscNumber: result.DiscNumber, DiscNumber: result.DiscNumber,
ISRC: result.ISRC, ISRC: result.ISRC,
CoverURL: result.CoverURL,
Genre: req.Genre, Genre: req.Genre,
Label: req.Label, Label: req.Label,
Copyright: req.Copyright, Copyright: req.Copyright,

View file

@ -2067,6 +2067,7 @@ type QobuzDownloadResult struct {
TrackNumber int TrackNumber int
DiscNumber int DiscNumber int
ISRC string ISRC string
CoverURL string
LyricsLRC string LyricsLRC string
} }
@ -2260,7 +2261,10 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
parallelDone := make(chan struct{}) parallelDone := make(chan struct{})
go func() { go func() {
defer close(parallelDone) defer close(parallelDone)
coverURL := req.CoverURL coverURL := strings.TrimSpace(req.CoverURL)
if coverURL == "" {
coverURL = strings.TrimSpace(qobuzTrackAlbumImage(track))
}
embedLyrics := req.EmbedLyrics embedLyrics := req.EmbedLyrics
if !req.EmbedMetadata { if !req.EmbedMetadata {
coverURL = "" coverURL = ""
@ -2393,6 +2397,7 @@ func downloadFromQobuz(req DownloadRequest) (QobuzDownloadResult, error) {
TrackNumber: resultTrackNumber, TrackNumber: resultTrackNumber,
DiscNumber: resultDiscNumber, DiscNumber: resultDiscNumber,
ISRC: track.ISRC, ISRC: track.ISRC,
CoverURL: strings.TrimSpace(qobuzTrackAlbumImage(track)),
LyricsLRC: lyricsLRC, LyricsLRC: lyricsLRC,
}, nil }, nil
} }

View file

@ -34,6 +34,7 @@ class AppSettings {
final bool enableLogging; final bool enableLogging;
final bool useExtensionProviders; final bool useExtensionProviders;
final String? searchProvider; final String? searchProvider;
final String? homeFeedProvider;
final bool separateSingles; final bool separateSingles;
final String albumFolderStructure; final String albumFolderStructure;
final bool showExtensionStore; final bool showExtensionStore;
@ -113,6 +114,7 @@ class AppSettings {
this.enableLogging = false, this.enableLogging = false,
this.useExtensionProviders = true, this.useExtensionProviders = true,
this.searchProvider, this.searchProvider,
this.homeFeedProvider,
this.separateSingles = false, this.separateSingles = false,
this.albumFolderStructure = 'artist_album', this.albumFolderStructure = 'artist_album',
this.showExtensionStore = true, this.showExtensionStore = true,
@ -179,6 +181,8 @@ class AppSettings {
bool? useExtensionProviders, bool? useExtensionProviders,
String? searchProvider, String? searchProvider,
bool clearSearchProvider = false, bool clearSearchProvider = false,
String? homeFeedProvider,
bool clearHomeFeedProvider = false,
bool? separateSingles, bool? separateSingles,
String? albumFolderStructure, String? albumFolderStructure,
bool? showExtensionStore, bool? showExtensionStore,
@ -244,6 +248,9 @@ class AppSettings {
searchProvider: clearSearchProvider searchProvider: clearSearchProvider
? null ? null
: (searchProvider ?? this.searchProvider), : (searchProvider ?? this.searchProvider),
homeFeedProvider: clearHomeFeedProvider
? null
: (homeFeedProvider ?? this.homeFeedProvider),
separateSingles: separateSingles ?? this.separateSingles, separateSingles: separateSingles ?? this.separateSingles,
albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure, albumFolderStructure: albumFolderStructure ?? this.albumFolderStructure,
showExtensionStore: showExtensionStore ?? this.showExtensionStore, showExtensionStore: showExtensionStore ?? this.showExtensionStore,

View file

@ -39,6 +39,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
enableLogging: json['enableLogging'] as bool? ?? false, enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true, useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
searchProvider: json['searchProvider'] as String?, searchProvider: json['searchProvider'] as String?,
homeFeedProvider: json['homeFeedProvider'] as String?,
separateSingles: json['separateSingles'] as bool? ?? false, separateSingles: json['separateSingles'] as bool? ?? false,
albumFolderStructure: albumFolderStructure:
json['albumFolderStructure'] as String? ?? 'artist_album', json['albumFolderStructure'] as String? ?? 'artist_album',
@ -117,6 +118,7 @@ Map<String, dynamic> _$AppSettingsToJson(
'enableLogging': instance.enableLogging, 'enableLogging': instance.enableLogging,
'useExtensionProviders': instance.useExtensionProviders, 'useExtensionProviders': instance.useExtensionProviders,
'searchProvider': instance.searchProvider, 'searchProvider': instance.searchProvider,
'homeFeedProvider': instance.homeFeedProvider,
'separateSingles': instance.separateSingles, 'separateSingles': instance.separateSingles,
'albumFolderStructure': instance.albumFolderStructure, 'albumFolderStructure': instance.albumFolderStructure,
'showExtensionStore': instance.showExtensionStore, 'showExtensionStore': instance.showExtensionStore,

View file

@ -122,7 +122,7 @@ class DownloadHistoryItem {
artistName: json['artistName'] as String, artistName: json['artistName'] as String,
albumName: json['albumName'] as String, albumName: json['albumName'] as String,
albumArtist: normalizeOptionalString(json['albumArtist'] as String?), albumArtist: normalizeOptionalString(json['albumArtist'] as String?),
coverUrl: json['coverUrl'] as String?, coverUrl: normalizeCoverReference(json['coverUrl']?.toString()),
filePath: json['filePath'] as String, filePath: json['filePath'] as String,
storageMode: json['storageMode'] as String?, storageMode: json['storageMode'] as String?,
downloadTreeUri: json['downloadTreeUri'] as String?, downloadTreeUri: json['downloadTreeUri'] as String?,
@ -176,7 +176,7 @@ class DownloadHistoryItem {
artistName: artistName ?? this.artistName, artistName: artistName ?? this.artistName,
albumName: albumName ?? this.albumName, albumName: albumName ?? this.albumName,
albumArtist: albumArtist ?? this.albumArtist, albumArtist: albumArtist ?? this.albumArtist,
coverUrl: coverUrl ?? this.coverUrl, coverUrl: normalizeCoverReference(coverUrl ?? this.coverUrl),
filePath: filePath ?? this.filePath, filePath: filePath ?? this.filePath,
storageMode: storageMode ?? this.storageMode, storageMode: storageMode ?? this.storageMode,
downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri, downloadTreeUri: downloadTreeUri ?? this.downloadTreeUri,
@ -2534,8 +2534,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final backendIsrc = normalizeOptionalString( final backendIsrc = normalizeOptionalString(
backendResult['isrc'] as String?, backendResult['isrc'] as String?,
); );
final backendCoverUrl = normalizeOptionalString( final backendCoverUrl = normalizeCoverReference(
backendResult['cover_url'] as String?, backendResult['cover_url']?.toString(),
); );
final backendAlbumArtist = normalizeOptionalString( final backendAlbumArtist = normalizeOptionalString(
backendResult['album_artist'] as String?, backendResult['album_artist'] as String?,
@ -2591,7 +2591,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
String? coverPath; String? coverPath;
var coverUrl = track.coverUrl; var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
if (coverUrl != null && coverUrl.isNotEmpty) { if (coverUrl != null && coverUrl.isNotEmpty) {
try { try {
if (settings.maxQualityCover) { if (settings.maxQualityCover) {
@ -2777,7 +2777,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
String? coverPath; String? coverPath;
var coverUrl = track.coverUrl; var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
if (coverUrl != null && coverUrl.isNotEmpty) { if (coverUrl != null && coverUrl.isNotEmpty) {
try { try {
if (settings.maxQualityCover) { if (settings.maxQualityCover) {
@ -2945,7 +2945,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
} }
String? coverPath; String? coverPath;
var coverUrl = track.coverUrl; var coverUrl = normalizeRemoteHttpUrl(track.coverUrl);
if (coverUrl != null && coverUrl.isNotEmpty) { if (coverUrl != null && coverUrl.isNotEmpty) {
try { try {
if (settings.maxQualityCover) { if (settings.maxQualityCover) {
@ -3751,7 +3751,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
albumArtist: trackToDownload.albumArtist, albumArtist: trackToDownload.albumArtist,
artistId: trackToDownload.artistId, artistId: trackToDownload.artistId,
albumId: trackToDownload.albumId, albumId: trackToDownload.albumId,
coverUrl: trackToDownload.coverUrl, coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
duration: trackToDownload.duration, duration: trackToDownload.duration,
isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc)) isrc: (deezerIsrc != null && _isValidISRC(deezerIsrc))
? deezerIsrc ? deezerIsrc
@ -4041,6 +4041,14 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
item.service.toLowerCase(); item.service.toLowerCase();
final decryptionKey = final decryptionKey =
(result['decryption_key'] as String?)?.trim() ?? ''; (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) { if (!wasExisting && decryptionKey.isNotEmpty && filePath != null) {
_log.i('Encrypted stream detected, decrypting via FFmpeg...'); _log.i('Encrypted stream detected, decrypting via FFmpeg...');
@ -4959,7 +4967,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
? backendAlbum ? backendAlbum
: trackToDownload.albumName, : trackToDownload.albumName,
albumArtist: historyAlbumArtist, albumArtist: historyAlbumArtist,
coverUrl: trackToDownload.coverUrl, coverUrl: normalizeCoverReference(trackToDownload.coverUrl),
filePath: filePath, filePath: filePath,
storageMode: effectiveSafMode ? 'saf' : 'app', storageMode: effectiveSafMode ? 'saf' : 'app',
downloadTreeUri: effectiveSafMode downloadTreeUri: effectiveSafMode

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/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
final _log = AppLogger('ExploreProvider'); final _log = AppLogger('ExploreProvider');
@ -202,9 +203,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
Future<void> _saveToCache(List<ExploreSection> sections) async { Future<void> _saveToCache(List<ExploreSection> sections) async {
try { try {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final data = { final data = {'sections': sections.map((s) => s.toJson()).toList()};
'sections': sections.map((s) => s.toJson()).toList(),
};
await prefs.setString(_cacheKey, jsonEncode(data)); await prefs.setString(_cacheKey, jsonEncode(data));
await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch); await prefs.setInt(_cacheTsKey, DateTime.now().millisecondsSinceEpoch);
_log.d('Saved ${sections.length} explore sections to cache'); _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 /// Fetch home feed from spotify-web extension
Future<void> fetchHomeFeed({bool forceRefresh = false}) async { Future<void> fetchHomeFeed({bool forceRefresh = false}) async {
_log.i('fetchHomeFeed called, forceRefresh=$forceRefresh'); _log.i('fetchHomeFeed called, forceRefresh=$forceRefresh');
// If we have cached content and it's fresh enough, skip network fetch // If we have cached content and it's fresh enough, skip network fetch
if (!forceRefresh && if (!forceRefresh &&
state.hasContent && state.hasContent &&
state.lastFetched != null && state.lastFetched != null &&
DateTime.now().difference(state.lastFetched!).inMinutes < 5) { DateTime.now().difference(state.lastFetched!).inMinutes < 5) {
_log.d('Using cached home feed (fresh enough)'); _log.d('Using cached home feed (fresh enough)');
return; return;
} }
if (state.isLoading) { if (state.isLoading) {
_log.d('Home feed fetch already in progress'); _log.d('Home feed fetch already in progress');
return; return;
@ -237,21 +236,33 @@ class ExploreNotifier extends Notifier<ExploreState> {
try { try {
final extState = ref.read(extensionProvider); 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; Extension? targetExt;
for (final extension in extState.extensions) { for (final extension in extState.extensions) {
if (!extension.enabled || !extension.hasHomeFeed) { if (!extension.enabled || !extension.hasHomeFeed) {
continue; 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') { if (targetExt == null || extension.id == 'spotify-web') {
targetExt = extension; targetExt = extension;
if (extension.id == 'spotify-web') { if (preferredId == null && extension.id == 'spotify-web') {
break; break;
} }
} }
} }
if (targetExt == null) { if (targetExt == null) {
_log.w('No extension with homeFeed capability found'); _log.w('No extension with homeFeed capability found');
state = state.copyWith( state = state.copyWith(
@ -260,7 +271,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
); );
return; return;
} }
_log.i('Fetching home feed from ${targetExt.id}...'); _log.i('Fetching home feed from ${targetExt.id}...');
final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id); final result = await PlatformBridge.getExtensionHomeFeed(targetExt.id);
@ -276,10 +287,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
_log.d('getExtensionHomeFeed success=$success'); _log.d('getExtensionHomeFeed success=$success');
if (!success) { if (!success) {
final error = result['error'] as String? ?? 'Unknown error'; final error = result['error'] as String? ?? 'Unknown error';
state = state.copyWith( state = state.copyWith(isLoading: false, error: error);
isLoading: false,
error: error,
);
return; return;
} }
@ -291,10 +299,12 @@ class ExploreNotifier extends Notifier<ExploreState> {
.toList(); .toList();
_log.i('Fetched ${sections.length} sections'); _log.i('Fetched ${sections.length} sections');
if (sections.isNotEmpty && sections.first.items.isNotEmpty) { if (sections.isNotEmpty && sections.first.items.isNotEmpty) {
final firstItem = sections.first.items.first; 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(); final localGreeting = _getLocalGreeting();
@ -311,10 +321,7 @@ class ExploreNotifier extends Notifier<ExploreState> {
_saveToCache(sections); _saveToCache(sections);
} catch (e, stack) { } catch (e, stack) {
_log.e('Error fetching home feed: $e', e, stack); _log.e('Error fetching home feed: $e', e, stack);
state = state.copyWith( state = state.copyWith(isLoading: false, error: e.toString());
isLoading: false,
error: e.toString(),
);
} }
} }
@ -325,7 +332,6 @@ class ExploreNotifier extends Notifier<ExploreState> {
Future<void> refresh() => fetchHomeFeed(forceRefresh: true); Future<void> refresh() => fetchHomeFeed(forceRefresh: true);
} }
final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() { final exploreProvider = NotifierProvider<ExploreNotifier, ExploreState>(() {
return ExploreNotifier(); return ExploreNotifier();
}); });

View file

@ -424,6 +424,15 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings(); _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) { void setEnableLogging(bool enabled) {
state = state.copyWith(enableLogging: enabled); state = state.copyWith(enableLogging: enabled);
_saveSettings(); _saveSettings();

View file

@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/models/track.dart'; import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/logger.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/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart'; import 'package:spotiflac_android/providers/extension_provider.dart';
@ -286,7 +287,9 @@ class TrackNotifier extends Notifier<TrackState> {
playlistName: type == 'playlist' playlistName: type == 'playlist'
? result['name'] as String? ? result['name'] as String?
: null, : null,
coverUrl: result['cover_url'] as String?, coverUrl: normalizeCoverReference(
result['cover_url']?.toString(),
),
searchExtensionId: extensionId, searchExtensionId: extensionId,
); );
return; return;
@ -313,10 +316,12 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
artistId: artistData['id'] as String?, artistId: artistData['id'] as String?,
artistName: artistData['name'] as String?, artistName: artistData['name'] as String?,
coverUrl: coverUrl: normalizeRemoteHttpUrl(
artistData['image_url'] as String? ?? (artistData['image_url'] ?? artistData['images'])?.toString(),
artistData['images'] as String?, ),
headerImageUrl: artistData['header_image'] as String?, headerImageUrl: normalizeRemoteHttpUrl(
artistData['header_image']?.toString(),
),
monthlyListeners: artistData['listeners'] as int?, monthlyListeners: artistData['listeners'] as int?,
artistAlbums: albums, artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null, artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
@ -357,7 +362,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
albumId: id, albumId: id,
albumName: albumInfo['name'] as String?, albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
); );
_preWarmCacheForTracks(tracks); _preWarmCacheForTracks(tracks);
} else if (type == 'playlist') { } else if (type == 'playlist') {
@ -371,7 +376,9 @@ class TrackNotifier extends Notifier<TrackState> {
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
playlistName: playlistInfo['name'] as String?, playlistName: playlistInfo['name'] as String?,
coverUrl: playlistInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(
playlistInfo['images']?.toString(),
),
); );
_preWarmCacheForTracks(tracks); _preWarmCacheForTracks(tracks);
} else if (type == 'artist') { } else if (type == 'artist') {
@ -385,7 +392,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
artistId: artistInfo['id'] as String?, artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?, artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums, artistAlbums: albums,
); );
} }
@ -422,7 +429,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
albumId: 'qobuz:$id', albumId: 'qobuz:$id',
albumName: albumInfo['name'] as String?, albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
); );
_preWarmCacheForTracks(tracks); _preWarmCacheForTracks(tracks);
} else if (type == 'playlist') { } else if (type == 'playlist') {
@ -435,8 +442,9 @@ class TrackNotifier extends Notifier<TrackState> {
final owner = playlistInfo['owner'] as Map<String, dynamic>?; final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName = final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?; (playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images']) as String?; (playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
@ -455,7 +463,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
artistId: artistInfo['id'] as String?, artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?, artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums, artistAlbums: albums,
); );
} }
@ -492,7 +500,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
albumId: 'tidal:$id', albumId: 'tidal:$id',
albumName: albumInfo['name'] as String?, albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
); );
_preWarmCacheForTracks(tracks); _preWarmCacheForTracks(tracks);
} else if (type == 'playlist') { } else if (type == 'playlist') {
@ -505,8 +513,9 @@ class TrackNotifier extends Notifier<TrackState> {
final owner = playlistInfo['owner'] as Map<String, dynamic>?; final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName = final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?; (playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images']) as String?; (playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
@ -525,7 +534,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
artistId: artistInfo['id'] as String?, artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?, artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums, artistAlbums: albums,
); );
} }
@ -580,7 +589,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
albumId: parsed['id'] as String?, albumId: parsed['id'] as String?,
albumName: albumInfo['name'] as String?, albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
); );
_preWarmCacheForTracks(tracks); _preWarmCacheForTracks(tracks);
} else if (type == 'playlist') { } else if (type == 'playlist') {
@ -592,8 +601,9 @@ class TrackNotifier extends Notifier<TrackState> {
final owner = playlistInfo['owner'] as Map<String, dynamic>?; final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName = final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?; (playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images']) as String?; (playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState( state = TrackState(
tracks: tracks, tracks: tracks,
isLoading: false, isLoading: false,
@ -612,7 +622,7 @@ class TrackNotifier extends Notifier<TrackState> {
isLoading: false, isLoading: false,
artistId: artistInfo['id'] as String?, artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?, artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?, coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums, artistAlbums: albums,
); );
} }
@ -986,7 +996,7 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist'] as String?, albumArtist: data['album_artist'] as String?,
artistId: (data['artist_id'] ?? data['artistId'])?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.toString(), albumId: data['album_id']?.toString(),
coverUrl: data['images'] as String?, coverUrl: normalizeCoverReference(data['images']?.toString()),
isrc: data['isrc'] as String?, isrc: data['isrc'] as String?,
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
@ -1017,7 +1027,9 @@ class TrackNotifier extends Notifier<TrackState> {
albumArtist: data['album_artist']?.toString(), albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.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(), isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
@ -1062,7 +1074,9 @@ class TrackNotifier extends Notifier<TrackState> {
name: data['name'] as String? ?? '', name: data['name'] as String? ?? '',
releaseDate: data['release_date'] as String? ?? '', releaseDate: data['release_date'] as String? ?? '',
totalTracks: data['total_tracks'] as int? ?? 0, 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', albumType: data['album_type'] as String? ?? 'album',
artists: data['artists'] as String? ?? '', artists: data['artists'] as String? ?? '',
providerId: data['provider_id']?.toString(), providerId: data['provider_id']?.toString(),
@ -1073,7 +1087,7 @@ class TrackNotifier extends Notifier<TrackState> {
return SearchArtist( return SearchArtist(
id: data['id'] as String? ?? '', id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '', name: data['name'] as String? ?? '',
imageUrl: data['images'] as String?, imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
followers: data['followers'] as int? ?? 0, followers: data['followers'] as int? ?? 0,
popularity: data['popularity'] as int? ?? 0, popularity: data['popularity'] as int? ?? 0,
); );
@ -1084,7 +1098,7 @@ class TrackNotifier extends Notifier<TrackState> {
id: data['id'] as String? ?? '', id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '', name: data['name'] as String? ?? '',
artists: data['artists'] as String? ?? '', artists: data['artists'] as String? ?? '',
imageUrl: data['images'] as String?, imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
releaseDate: data['release_date'] as String?, releaseDate: data['release_date'] as String?,
totalTracks: data['total_tracks'] as int? ?? 0, totalTracks: data['total_tracks'] as int? ?? 0,
albumType: data['album_type'] as String? ?? 'album', albumType: data['album_type'] as String? ?? 'album',
@ -1096,7 +1110,7 @@ class TrackNotifier extends Notifier<TrackState> {
id: data['id'] as String? ?? '', id: data['id'] as String? ?? '',
name: data['name'] as String? ?? '', name: data['name'] as String? ?? '',
owner: data['owner'] as String? ?? '', owner: data['owner'] as String? ?? '',
imageUrl: data['images'] as String?, imageUrl: normalizeRemoteHttpUrl(data['images']?.toString()),
totalTracks: data['total_tracks'] as int? ?? 0, totalTracks: data['total_tracks'] as int? ?? 0,
); );
} }

View file

@ -11,6 +11,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart'; import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart';
@ -94,7 +95,10 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
.recordAlbumAccess( .recordAlbumAccess(
id: widget.albumId, id: widget.albumId,
name: widget.albumName, name: widget.albumName,
artistName: widget.artistName ?? widget.tracks?.firstOrNull?.albumArtist ?? widget.tracks?.firstOrNull?.artistName, artistName:
widget.artistName ??
widget.tracks?.firstOrNull?.albumArtist ??
widget.tracks?.firstOrNull?.artistName,
imageUrl: widget.coverUrl, imageUrl: widget.coverUrl,
providerId: providerId, providerId: providerId,
); );
@ -226,7 +230,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
artistId: artistId:
(data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId, (data['artist_id'] ?? data['artistId'])?.toString() ?? _artistId,
albumId: data['album_id']?.toString() ?? widget.albumId, albumId: data['album_id']?.toString() ?? widget.albumId,
coverUrl: data['images'] as String?, coverUrl: normalizeCoverReference(data['images']?.toString()),
isrc: data['isrc'] as String?, isrc: data['isrc'] as String?,
duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(), duration: ((data['duration_ms'] as int? ?? 0) / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
@ -280,7 +284,8 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
) { ) {
final expandedHeight = _calculateExpandedHeight(context); final expandedHeight = _calculateExpandedHeight(context);
final tracks = _tracks ?? []; final tracks = _tracks ?? [];
final artistName = widget.artistName ?? final artistName =
widget.artistName ??
(tracks.isNotEmpty (tracks.isNotEmpty
? (tracks.first.albumArtist ?? tracks.first.artistName) ? (tracks.first.albumArtist ?? tracks.first.artistName)
: null); : null);
@ -574,17 +579,21 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
// Skip already-downloaded tracks // Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider); final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider) ? ref.read(localLibraryProvider)
: null; : null;
final tracksToQueue = <Track>[]; final tracksToQueue = <Track>[];
int skippedCount = 0; int skippedCount = 0;
for (final track in tracks) { for (final track in tracks) {
final isInHistory = historyState.isDownloaded(track.id) || final isInHistory =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) != null; historyState.findByTrackAndArtist(track.name, track.artistName) !=
final isInLocal = localLibState?.existsInLibrary( null;
final isInLocal =
localLibState?.existsInLibrary(
isrc: track.isrc, isrc: track.isrc,
trackName: track.name, trackName: track.name,
artistName: track.artistName, artistName: track.artistName,
@ -617,7 +626,11 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
onSelect: (quality, service) { onSelect: (quality, service) {
ref ref
.read(downloadQueueProvider.notifier) .read(downloadQueueProvider.notifier)
.addMultipleToQueue(tracksToQueue, service, qualityOverride: quality); .addMultipleToQueue(
tracksToQueue,
service,
qualityOverride: quality,
);
_showQueuedSnackbar(context, tracksToQueue.length, skippedCount); _showQueuedSnackbar(context, tracksToQueue.length, skippedCount);
}, },
); );
@ -633,9 +646,9 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
final message = skipped > 0 final message = skipped > 0
? context.l10n.discographySkippedDownloaded(added, skipped) ? context.l10n.discographySkippedDownloaded(added, skipped)
: context.l10n.snackbarAddedTracksToQueue(added); : context.l10n.snackbarAddedTracksToQueue(added);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(message)), context,
); ).showSnackBar(SnackBar(content: Text(message)));
} }
Widget _buildLoveAllButton() { Widget _buildLoveAllButton() {

View file

@ -14,6 +14,7 @@ import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/screens/album_screen.dart'; import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart' import 'package:spotiflac_android/screens/home_tab.dart'
show ExtensionAlbumScreen; show ExtensionAlbumScreen;
@ -297,8 +298,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.toList(); .toList();
} }
final topTracksList = final topTracksList = artistData['top_tracks'] as List<dynamic>? ?? [];
artistData['top_tracks'] as List<dynamic>? ?? [];
if (topTracksList.isNotEmpty) { if (topTracksList.isNotEmpty) {
topTracks = topTracksList topTracks = topTracksList
.map((t) => _parseTrack(t as Map<String, dynamic>)) .map((t) => _parseTrack(t as Map<String, dynamic>))
@ -399,8 +399,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
(data['artist_id'] ?? data['artistId'])?.toString() ?? (data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId, widget.artistId,
albumId: data['album_id']?.toString() ?? album?.id, albumId: data['album_id']?.toString() ?? album?.id,
coverUrl: (data['cover_url'] ?? data['images'] ?? album?.coverUrl) coverUrl: normalizeCoverReference(
?.toString(), (data['cover_url'] ?? data['images'] ?? album?.coverUrl)?.toString(),
),
isrc: data['isrc']?.toString(), isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
@ -414,18 +415,18 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) { ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
final totalTracksValue = data['total_tracks']; final totalTracksValue = data['total_tracks'];
final totalTracks = final totalTracks = totalTracksValue is int
totalTracksValue is int ? totalTracksValue
? totalTracksValue : int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
return ArtistAlbum( return ArtistAlbum(
id: data['id'] as String? ?? '', id: data['id'] as String? ?? '',
name: (data['name'] ?? data['title'] ?? '').toString(), name: (data['name'] ?? data['title'] ?? '').toString(),
releaseDate: (data['release_date'] ?? '').toString(), releaseDate: (data['release_date'] ?? '').toString(),
totalTracks: totalTracks, totalTracks: totalTracks,
coverUrl: (data['cover_url'] ?? data['images'] ?? data['cover_art']) coverUrl: normalizeCoverReference(
?.toString(), (data['cover_url'] ?? data['images'] ?? data['cover_art'])?.toString(),
),
albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(), albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(),
artists: (data['artists'] ?? data['artist'] ?? widget.artistName) artists: (data['artists'] ?? data['artist'] ?? widget.artistName)
.toString(), .toString(),
@ -1359,8 +1360,10 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}, },
itemBuilder: (context, pageIndex) { itemBuilder: (context, pageIndex) {
final startIndex = pageIndex * tracksPerPage; final startIndex = pageIndex * tracksPerPage;
final endIndex = final endIndex = (startIndex + tracksPerPage).clamp(
(startIndex + tracksPerPage).clamp(0, tracks.length); 0,
tracks.length,
);
final pageTracks = tracks.sublist(startIndex, endIndex); final pageTracks = tracks.sublist(startIndex, endIndex);
return Column( return Column(

View file

@ -23,6 +23,7 @@ import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.da
import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart'; import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/screens/playlist_screen.dart'; import 'package:spotiflac_android/screens/playlist_screen.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/widgets/download_service_picker.dart'; import 'package:spotiflac_android/widgets/download_service_picker.dart';
@ -4306,7 +4307,7 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
artists: (data['artists'] ?? '').toString(), artists: (data['artists'] ?? '').toString(),
releaseDate: (data['release_date'] ?? '').toString(), releaseDate: (data['release_date'] ?? '').toString(),
totalTracks: data['total_tracks'] as int? ?? 0, totalTracks: data['total_tracks'] as int? ?? 0,
coverUrl: data['cover_url']?.toString(), coverUrl: normalizeCoverReference(data['cover_url']?.toString()),
albumType: (data['album_type'] ?? 'album').toString(), albumType: (data['album_type'] ?? 'album').toString(),
providerId: (data['provider_id'] ?? widget.extensionId).toString(), providerId: (data['provider_id'] ?? widget.extensionId).toString(),
); );
@ -4331,7 +4332,9 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
(data['artist_id'] ?? data['artistId'])?.toString() ?? (data['artist_id'] ?? data['artistId'])?.toString() ??
widget.artistId, widget.artistId,
albumId: data['album_id']?.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(), isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,

View file

@ -8,6 +8,7 @@ import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart'; import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/library_collections_provider.dart'; import 'package:spotiflac_android/providers/library_collections_provider.dart';
import 'package:spotiflac_android/utils/file_access.dart'; import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart';
@ -128,7 +129,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
albumArtist: data['album_artist']?.toString(), albumArtist: data['album_artist']?.toString(),
artistId: (data['artist_id'] ?? data['artistId'])?.toString(), artistId: (data['artist_id'] ?? data['artistId'])?.toString(),
albumId: data['album_id']?.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(), isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(), duration: (durationMs / 1000).round(),
trackNumber: data['track_number'] as int?, trackNumber: data['track_number'] as int?,
@ -532,7 +535,12 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
tooltip: context.l10n.tooltipAddToPlaylist, tooltip: context.l10n.tooltipAddToPlaylist,
onPressed: _tracks.isEmpty onPressed: _tracks.isEmpty
? null ? null
: () => showAddTracksToPlaylistSheet(context, ref, _tracks, playlistNamePrefill: widget.playlistName), : () => showAddTracksToPlaylistSheet(
context,
ref,
_tracks,
playlistNamePrefill: widget.playlistName,
),
); );
} }
@ -611,17 +619,21 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
// Skip already-downloaded tracks // Skip already-downloaded tracks
final historyState = ref.read(downloadHistoryProvider); final historyState = ref.read(downloadHistoryProvider);
final settings = ref.read(settingsProvider); final settings = ref.read(settingsProvider);
final localLibState = (settings.localLibraryEnabled && settings.localLibraryShowDuplicates) final localLibState =
(settings.localLibraryEnabled && settings.localLibraryShowDuplicates)
? ref.read(localLibraryProvider) ? ref.read(localLibraryProvider)
: null; : null;
final tracksToQueue = <Track>[]; final tracksToQueue = <Track>[];
int skippedCount = 0; int skippedCount = 0;
for (final track in tracks) { for (final track in tracks) {
final isInHistory = historyState.isDownloaded(track.id) || final isInHistory =
historyState.isDownloaded(track.id) ||
(track.isrc != null && historyState.getByIsrc(track.isrc!) != null) || (track.isrc != null && historyState.getByIsrc(track.isrc!) != null) ||
historyState.findByTrackAndArtist(track.name, track.artistName) != null; historyState.findByTrackAndArtist(track.name, track.artistName) !=
final isInLocal = localLibState?.existsInLibrary( null;
final isInLocal =
localLibState?.existsInLibrary(
isrc: track.isrc, isrc: track.isrc,
trackName: track.name, trackName: track.name,
artistName: track.artistName, artistName: track.artistName,
@ -679,9 +691,9 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
final message = skipped > 0 final message = skipped > 0
? context.l10n.discographySkippedDownloaded(added, skipped) ? context.l10n.discographySkippedDownloaded(added, skipped)
: context.l10n.snackbarAddedTracksToQueue(added); : context.l10n.snackbarAddedTracksToQueue(added);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text(message)), context,
); ).showSnackBar(SnackBar(content: Text(message)));
} }
} }

View file

@ -519,7 +519,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
String get _filePath => String get _filePath =>
_isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath; _isLocalItem ? _localLibraryItem!.filePath : _downloadItem!.filePath;
String? get _coverUrl => _isLocalItem ? null : _downloadItem!.coverUrl; String? get _coverUrl =>
_isLocalItem ? null : normalizeRemoteHttpUrl(_downloadItem!.coverUrl);
String? get _localCoverPath => String? get _localCoverPath =>
_isLocalItem ? _localLibraryItem!.coverPath : null; _isLocalItem ? _localLibraryItem!.coverPath : null;
String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId; String? get _spotifyId => _isLocalItem ? null : _downloadItem!.spotifyId;

View file

@ -6,6 +6,41 @@ String? normalizeOptionalString(String? value) {
return trimmed; return trimmed;
} }
final RegExp _windowsAbsolutePathPattern = RegExp(r'^[A-Za-z]:[\\/]');
bool _looksLikeLocalReference(String value) {
return value.startsWith('/') ||
value.startsWith('content://') ||
value.startsWith('file://') ||
_windowsAbsolutePathPattern.hasMatch(value);
}
String? normalizeCoverReference(String? value) {
final normalized = normalizeOptionalString(value);
if (normalized == null) return null;
if (normalized.startsWith('//')) {
return 'https:$normalized';
}
if (normalized.startsWith('http://') ||
normalized.startsWith('https://') ||
_looksLikeLocalReference(normalized)) {
return normalized;
}
return null;
}
String? normalizeRemoteHttpUrl(String? value) {
final normalized = normalizeCoverReference(value);
if (normalized == null) return null;
if (normalized.startsWith('http://') || normalized.startsWith('https://')) {
return normalized;
}
return null;
}
String formatSampleRateKHz(int sampleRate) { String formatSampleRateKHz(int sampleRate) {
final khz = sampleRate / 1000; final khz = sampleRate / 1000;
final precision = sampleRate % 1000 == 0 ? 0 : 1; final precision = sampleRate % 1000 == 0 ? 0 : 1;