mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
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:
parent
c91154ea3e
commit
3a73aee1b7
16 changed files with 252 additions and 95 deletions
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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?,
|
||||||
|
|
|
||||||
|
|
@ -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)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue