mirror of
https://github.com/spotiflacapp/SpotiFLAC-Mobile.git
synced 2026-06-01 03:15:17 +07:00
feat: auto-enrich metadata for extension downloads, fix artist/playlist parsing, and improve metadata screen
- Add metadata provider search (Deezer/Tidal/Qobuz) in download pipeline for extension tracks with missing album/date/ISRC, using the same mechanism as ReEnrichFile - Always pass enriched metadata (album, release_date, ISRC, cover_url, track/disc number) back in DownloadResponse so Flutter can embed them - Add Deezer ISRC lookup for genre/label during download enrichment - Extend _buildTrackForMetadataEmbedding to use ISRC, cover_url, album_artist from backend response - Add Releases section support in artist page (Go + Flutter) - Fix Track ID parsing to prefer non-empty native ID over empty spotify_id - Paginate popular tracks (5 per page with swipe + dot indicators) - Fix metadata screen: duration getter checks _editedMetadata, read album/duration from file tags - Make metadata screen ID labels and Open-in buttons source-aware (Amazon/Tidal/Qobuz/Deezer/Spotify) - Copy enrichment fields (AlbumName, DurationMS, CoverURL, AlbumArtist, ID) back to download request - Update README badge, add network_requests.txt to gitignore
This commit is contained in:
parent
aa9854fc0a
commit
134bf4375f
8 changed files with 408 additions and 39 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -67,6 +67,7 @@ AGENTS.md
|
|||
|
||||
# Temp/misc
|
||||
nul
|
||||
network_requests.txt
|
||||
|
||||
# Log files
|
||||
*.log
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
|
||||
<div align="center">
|
||||
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
|
||||
[](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
|
||||
[](https://crowdin.com/project/spotiflac-mobile)
|
||||
|
||||
|
|
|
|||
|
|
@ -2602,6 +2602,28 @@ func HandleURLWithExtensionJSON(url string) (string, error) {
|
|||
artistResponse["albums"] = albums
|
||||
}
|
||||
|
||||
if len(result.Artist.Releases) > 0 {
|
||||
releases := make([]map[string]interface{}, len(result.Artist.Releases))
|
||||
for i, release := range result.Artist.Releases {
|
||||
releaseType := release.AlbumType
|
||||
if releaseType == "" {
|
||||
releaseType = "album"
|
||||
}
|
||||
releases[i] = map[string]interface{}{
|
||||
"id": release.ID,
|
||||
"name": release.Name,
|
||||
"artists": release.Artists,
|
||||
"images": release.CoverURL,
|
||||
"cover_url": release.CoverURL,
|
||||
"release_date": release.ReleaseDate,
|
||||
"total_tracks": release.TotalTracks,
|
||||
"album_type": releaseType,
|
||||
"provider_id": release.ProviderID,
|
||||
}
|
||||
}
|
||||
artistResponse["releases"] = releases
|
||||
}
|
||||
|
||||
if len(result.Artist.TopTracks) > 0 {
|
||||
topTracks := make([]map[string]interface{}, len(result.Artist.TopTracks))
|
||||
for i, track := range result.Artist.TopTracks {
|
||||
|
|
@ -2851,6 +2873,27 @@ func GetArtistWithExtensionJSON(extensionID, artistID string) (string, error) {
|
|||
"provider_id": artist.ProviderID,
|
||||
}
|
||||
|
||||
if len(artist.Releases) > 0 {
|
||||
releases := make([]map[string]interface{}, len(artist.Releases))
|
||||
for i, release := range artist.Releases {
|
||||
releaseType := release.AlbumType
|
||||
if releaseType == "" {
|
||||
releaseType = "album"
|
||||
}
|
||||
releases[i] = map[string]interface{}{
|
||||
"id": release.ID,
|
||||
"name": release.Name,
|
||||
"artists": release.Artists,
|
||||
"cover_url": release.CoverURL,
|
||||
"release_date": release.ReleaseDate,
|
||||
"total_tracks": release.TotalTracks,
|
||||
"album_type": releaseType,
|
||||
"provider_id": release.ProviderID,
|
||||
}
|
||||
}
|
||||
response["releases"] = releases
|
||||
}
|
||||
|
||||
if artist.HeaderImage != "" {
|
||||
response["header_image"] = artist.HeaderImage
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ type ExtArtistMetadata struct {
|
|||
HeaderImage string `json:"header_image,omitempty"`
|
||||
Listeners int `json:"listeners,omitempty"`
|
||||
Albums []ExtAlbumMetadata `json:"albums,omitempty"`
|
||||
Releases []ExtAlbumMetadata `json:"releases,omitempty"`
|
||||
TopTracks []ExtTrackMetadata `json:"top_tracks,omitempty"`
|
||||
ProviderID string `json:"provider_id"`
|
||||
}
|
||||
|
|
@ -327,6 +328,12 @@ func (p *ExtensionProviderWrapper) GetArtist(artistID string) (*ExtArtistMetadat
|
|||
}
|
||||
|
||||
artist.ProviderID = p.extension.ID
|
||||
for i := range artist.Releases {
|
||||
artist.Releases[i].ProviderID = p.extension.ID
|
||||
for j := range artist.Releases[i].Tracks {
|
||||
artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
return &artist, nil
|
||||
}
|
||||
|
||||
|
|
@ -970,6 +977,24 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||
if enrichedTrack.Artists != "" {
|
||||
req.ArtistName = enrichedTrack.Artists
|
||||
}
|
||||
if enrichedTrack.AlbumName != "" && req.AlbumName == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] AlbumName from enrichment: %s\n", enrichedTrack.AlbumName)
|
||||
req.AlbumName = enrichedTrack.AlbumName
|
||||
}
|
||||
if enrichedTrack.AlbumArtist != "" && req.AlbumArtist == "" {
|
||||
req.AlbumArtist = enrichedTrack.AlbumArtist
|
||||
}
|
||||
if enrichedTrack.DurationMS > 0 && req.DurationMS == 0 {
|
||||
GoLog("[DownloadWithExtensionFallback] DurationMS from enrichment: %d\n", enrichedTrack.DurationMS)
|
||||
req.DurationMS = enrichedTrack.DurationMS
|
||||
}
|
||||
if enrichedTrack.CoverURL != "" && req.CoverURL == "" {
|
||||
req.CoverURL = enrichedTrack.CoverURL
|
||||
}
|
||||
if enrichedTrack.ID != "" && req.SpotifyID == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Track ID from enrichment: %s\n", enrichedTrack.ID)
|
||||
req.SpotifyID = enrichedTrack.ID
|
||||
}
|
||||
if enrichedTrack.Label != "" && req.Label == "" {
|
||||
GoLog("[DownloadWithExtensionFallback] Label from enrichment: %s\n", enrichedTrack.Label)
|
||||
req.Label = enrichedTrack.Label
|
||||
|
|
@ -990,6 +1015,73 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||
}
|
||||
}
|
||||
|
||||
// If key metadata is still missing after extension enrichment, search
|
||||
// configured metadata providers (Spotify/Deezer/Tidal/Qobuz) — same
|
||||
// logic that ReEnrichFile uses.
|
||||
if req.Source != "" && !isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||
req.TrackName != "" && req.ArtistName != "" &&
|
||||
(req.AlbumName == "" || req.ReleaseDate == "" || req.ISRC == "") {
|
||||
|
||||
searchQuery := req.TrackName + " " + req.ArtistName
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata incomplete, searching providers for: %s\n", searchQuery)
|
||||
|
||||
tracks, searchErr := extManager.SearchTracksWithMetadataProviders(searchQuery, 5, true)
|
||||
if searchErr == nil && len(tracks) > 0 {
|
||||
track := tracks[0]
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata match (%s): %s - %s (album: %s, date: %s, isrc: %s)\n",
|
||||
track.ProviderID, track.Name, track.Artists, track.AlbumName, track.ReleaseDate, track.ISRC)
|
||||
|
||||
if track.AlbumName != "" && req.AlbumName == "" {
|
||||
req.AlbumName = track.AlbumName
|
||||
}
|
||||
if track.AlbumArtist != "" && req.AlbumArtist == "" {
|
||||
req.AlbumArtist = track.AlbumArtist
|
||||
}
|
||||
if track.ReleaseDate != "" && req.ReleaseDate == "" {
|
||||
req.ReleaseDate = track.ReleaseDate
|
||||
}
|
||||
if track.ISRC != "" && req.ISRC == "" {
|
||||
req.ISRC = track.ISRC
|
||||
}
|
||||
if track.TrackNumber > 0 && req.TrackNumber == 0 {
|
||||
req.TrackNumber = track.TrackNumber
|
||||
}
|
||||
if track.DiscNumber > 0 && req.DiscNumber == 0 {
|
||||
req.DiscNumber = track.DiscNumber
|
||||
}
|
||||
if track.CoverURL != "" && req.CoverURL == "" {
|
||||
req.CoverURL = track.CoverURL
|
||||
}
|
||||
if track.Genre != "" && req.Genre == "" {
|
||||
req.Genre = track.Genre
|
||||
}
|
||||
if track.Label != "" && req.Label == "" {
|
||||
req.Label = track.Label
|
||||
}
|
||||
if track.Copyright != "" && req.Copyright == "" {
|
||||
req.Copyright = track.Copyright
|
||||
}
|
||||
} else if searchErr != nil {
|
||||
GoLog("[DownloadWithExtensionFallback] Metadata provider search failed (non-fatal): %v\n", searchErr)
|
||||
}
|
||||
|
||||
// Try Deezer extended metadata for genre/label if we have ISRC
|
||||
if req.ISRC != "" && (req.Genre == "" || req.Label == "") {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
extMeta, err := GetDeezerClient().GetExtendedMetadataByISRC(ctx, req.ISRC)
|
||||
cancel()
|
||||
if err == nil && extMeta != nil {
|
||||
if req.Genre == "" && extMeta.Genre != "" {
|
||||
req.Genre = extMeta.Genre
|
||||
}
|
||||
if req.Label == "" && extMeta.Label != "" {
|
||||
req.Label = extMeta.Label
|
||||
}
|
||||
GoLog("[DownloadWithExtensionFallback] Extended metadata from Deezer: genre=%s, label=%s\n", req.Genre, req.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if req.Source != "" &&
|
||||
!isBuiltInProvider(strings.ToLower(req.Source)) &&
|
||||
(!strictMode || selectedProvider == "" || strings.EqualFold(selectedProvider, req.Source)) {
|
||||
|
|
@ -1083,6 +1175,30 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro
|
|||
}
|
||||
}
|
||||
|
||||
// Always pass enriched metadata from req so Flutter can
|
||||
// embed it — fills gaps from metadata provider search.
|
||||
if req.AlbumName != "" && resp.Album == "" {
|
||||
resp.Album = req.AlbumName
|
||||
}
|
||||
if req.AlbumArtist != "" && resp.AlbumArtist == "" {
|
||||
resp.AlbumArtist = req.AlbumArtist
|
||||
}
|
||||
if req.ReleaseDate != "" && resp.ReleaseDate == "" {
|
||||
resp.ReleaseDate = req.ReleaseDate
|
||||
}
|
||||
if req.ISRC != "" && resp.ISRC == "" {
|
||||
resp.ISRC = req.ISRC
|
||||
}
|
||||
if req.TrackNumber > 0 && resp.TrackNumber == 0 {
|
||||
resp.TrackNumber = req.TrackNumber
|
||||
}
|
||||
if req.DiscNumber > 0 && resp.DiscNumber == 0 {
|
||||
resp.DiscNumber = req.DiscNumber
|
||||
}
|
||||
if req.CoverURL != "" && resp.CoverURL == "" {
|
||||
resp.CoverURL = req.CoverURL
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
|
|
@ -1636,6 +1752,12 @@ func (p *ExtensionProviderWrapper) HandleURL(url string) (*ExtURLHandleResult, e
|
|||
handleResult.Artist.Albums[i].Tracks[j].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
for i := range handleResult.Artist.Releases {
|
||||
handleResult.Artist.Releases[i].ProviderID = p.extension.ID
|
||||
for j := range handleResult.Artist.Releases[i].Tracks {
|
||||
handleResult.Artist.Releases[i].Tracks[j].ProviderID = p.extension.ID
|
||||
}
|
||||
}
|
||||
for i := range handleResult.Artist.TopTracks {
|
||||
handleResult.Artist.TopTracks[i].ProviderID = p.extension.ID
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2373,11 +2373,25 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
final backendAlbum = normalizeOptionalString(
|
||||
backendResult['album'] as String?,
|
||||
);
|
||||
final backendIsrc = normalizeOptionalString(
|
||||
backendResult['isrc'] as String?,
|
||||
);
|
||||
final backendCoverUrl = normalizeOptionalString(
|
||||
backendResult['cover_url'] as String?,
|
||||
);
|
||||
final backendAlbumArtist = normalizeOptionalString(
|
||||
backendResult['album_artist'] as String?,
|
||||
);
|
||||
|
||||
if (backendTrackNum == null &&
|
||||
backendDiscNum == null &&
|
||||
backendYear == null &&
|
||||
backendAlbum == null) {
|
||||
final hasOverrides = backendTrackNum != null ||
|
||||
backendDiscNum != null ||
|
||||
backendYear != null ||
|
||||
backendAlbum != null ||
|
||||
backendIsrc != null ||
|
||||
backendCoverUrl != null ||
|
||||
backendAlbumArtist != null;
|
||||
|
||||
if (!hasOverrides) {
|
||||
return baseTrack;
|
||||
}
|
||||
|
||||
|
|
@ -2386,12 +2400,12 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
|||
name: baseTrack.name,
|
||||
artistName: baseTrack.artistName,
|
||||
albumName: backendAlbum ?? baseTrack.albumName,
|
||||
albumArtist: resolvedAlbumArtist,
|
||||
albumArtist: backendAlbumArtist ?? resolvedAlbumArtist,
|
||||
artistId: baseTrack.artistId,
|
||||
albumId: baseTrack.albumId,
|
||||
coverUrl: baseTrack.coverUrl,
|
||||
coverUrl: backendCoverUrl ?? baseTrack.coverUrl,
|
||||
duration: baseTrack.duration,
|
||||
isrc: baseTrack.isrc,
|
||||
isrc: backendIsrc ?? baseTrack.isrc,
|
||||
trackNumber: backendTrackNum ?? baseTrack.trackNumber,
|
||||
discNumber: backendDiscNum ?? baseTrack.discNumber,
|
||||
releaseDate: backendYear ?? baseTrack.releaseDate,
|
||||
|
|
|
|||
|
|
@ -933,8 +933,10 @@ class TrackNotifier extends Notifier<TrackState> {
|
|||
|
||||
Track _parseTrack(Map<String, dynamic> data) {
|
||||
final durationMs = _extractDurationMs(data);
|
||||
final spotifyId = (data['spotify_id'] ?? '').toString();
|
||||
final nativeId = (data['id'] ?? '').toString();
|
||||
return Track(
|
||||
id: data['spotify_id'] as String? ?? '',
|
||||
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
|
||||
name: data['name'] as String? ?? '',
|
||||
artistName: data['artists'] as String? ?? '',
|
||||
albumName: data['album_name'] as String? ?? '',
|
||||
|
|
|
|||
|
|
@ -38,12 +38,14 @@ class _ArtistCache {
|
|||
static void set(
|
||||
String artistId, {
|
||||
required List<ArtistAlbum> albums,
|
||||
List<ArtistAlbum>? releases,
|
||||
List<Track>? topTracks,
|
||||
String? headerImageUrl,
|
||||
int? monthlyListeners,
|
||||
}) {
|
||||
_cache[artistId] = _CacheEntry(
|
||||
albums: albums,
|
||||
releases: releases,
|
||||
topTracks: topTracks,
|
||||
headerImageUrl: headerImageUrl,
|
||||
monthlyListeners: monthlyListeners,
|
||||
|
|
@ -54,6 +56,7 @@ class _ArtistCache {
|
|||
|
||||
class _CacheEntry {
|
||||
final List<ArtistAlbum> albums;
|
||||
final List<ArtistAlbum>? releases;
|
||||
final List<Track>? topTracks;
|
||||
final String? headerImageUrl;
|
||||
final int? monthlyListeners;
|
||||
|
|
@ -61,6 +64,7 @@ class _CacheEntry {
|
|||
|
||||
_CacheEntry({
|
||||
required this.albums,
|
||||
this.releases,
|
||||
this.topTracks,
|
||||
this.headerImageUrl,
|
||||
this.monthlyListeners,
|
||||
|
|
@ -97,6 +101,7 @@ class ArtistScreen extends ConsumerStatefulWidget {
|
|||
class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
||||
bool _isLoadingDiscography = false;
|
||||
List<ArtistAlbum>? _albums;
|
||||
List<ArtistAlbum>? _releases;
|
||||
List<Track>? _topTracks;
|
||||
String? _headerImageUrl;
|
||||
int? _monthlyListeners;
|
||||
|
|
@ -104,6 +109,8 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
|
||||
bool _showTitleInAppBar = false;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final PageController _popularPageController = PageController();
|
||||
int _popularCurrentPage = 0;
|
||||
|
||||
bool _isSelectionMode = false;
|
||||
final Set<String> _selectedAlbumIds = {};
|
||||
|
|
@ -174,6 +181,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
_topTracks = widget.topTracks;
|
||||
_headerImageUrl = widget.headerImageUrl;
|
||||
_monthlyListeners = widget.monthlyListeners;
|
||||
|
||||
if ((_albums == null || _albums!.isEmpty) ||
|
||||
(_topTracks == null || _topTracks!.isEmpty)) {
|
||||
_fetchDiscography();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -190,6 +202,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
}
|
||||
} else if (cached != null) {
|
||||
_albums = cached.albums;
|
||||
_releases = cached.releases;
|
||||
_topTracks = cached.topTracks;
|
||||
_headerImageUrl = cached.headerImageUrl;
|
||||
_monthlyListeners = cached.monthlyListeners;
|
||||
|
|
@ -214,6 +227,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
_popularPageController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
|
@ -221,6 +235,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
setState(() => _isLoadingDiscography = true);
|
||||
try {
|
||||
List<ArtistAlbum> albums;
|
||||
List<ArtistAlbum>? releases;
|
||||
List<Track>? topTracks;
|
||||
String? headerImage;
|
||||
int? listeners;
|
||||
|
|
@ -259,6 +274,42 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
.toList();
|
||||
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
|
||||
headerImage = artistInfo?['images'] as String?;
|
||||
} else if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
|
||||
final result = await PlatformBridge.getArtistWithExtension(
|
||||
widget.extensionId!,
|
||||
widget.artistId,
|
||||
);
|
||||
|
||||
if (result == null) {
|
||||
throw Exception('Failed to load artist from extension');
|
||||
}
|
||||
|
||||
final artistData = result;
|
||||
final albumsList = artistData['albums'] as List<dynamic>? ?? [];
|
||||
albums = albumsList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
final releasesList = artistData['releases'] as List<dynamic>? ?? [];
|
||||
if (releasesList.isNotEmpty) {
|
||||
releases = releasesList
|
||||
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
final topTracksList =
|
||||
artistData['top_tracks'] as List<dynamic>? ?? [];
|
||||
if (topTracksList.isNotEmpty) {
|
||||
topTracks = topTracksList
|
||||
.map((t) => _parseTrack(t as Map<String, dynamic>))
|
||||
.toList();
|
||||
}
|
||||
|
||||
headerImage =
|
||||
artistData['header_image'] as String? ??
|
||||
artistData['cover_url'] as String? ??
|
||||
artistData['image_url'] as String?;
|
||||
listeners = artistData['listeners'] as int?;
|
||||
} else {
|
||||
final url = 'https://open.spotify.com/artist/${widget.artistId}';
|
||||
final result = await PlatformBridge.handleURLWithExtension(url);
|
||||
|
|
@ -299,6 +350,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
_ArtistCache.set(
|
||||
widget.artistId,
|
||||
albums: albums,
|
||||
releases: releases,
|
||||
topTracks: topTracks,
|
||||
headerImageUrl: finalHeaderImage,
|
||||
monthlyListeners: finalListeners,
|
||||
|
|
@ -307,6 +359,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
if (mounted) {
|
||||
setState(() {
|
||||
_albums = albums;
|
||||
_releases = releases;
|
||||
_topTracks = topTracks;
|
||||
_headerImageUrl = finalHeaderImage;
|
||||
_monthlyListeners = finalListeners;
|
||||
|
|
@ -332,8 +385,11 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
durationMs = durationValue.toInt();
|
||||
}
|
||||
|
||||
final spotifyId = (data['spotify_id'] ?? '').toString();
|
||||
final nativeId = (data['id'] ?? '').toString();
|
||||
|
||||
return Track(
|
||||
id: (data['spotify_id'] ?? data['id'] ?? '').toString(),
|
||||
id: spotifyId.isNotEmpty ? spotifyId : nativeId,
|
||||
name: (data['name'] ?? '').toString(),
|
||||
artistName: (data['artists'] ?? data['artist'] ?? '').toString(),
|
||||
albumName: (data['album_name'] ?? data['album'] ?? album?.name ?? '')
|
||||
|
|
@ -352,20 +408,28 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
releaseDate: data['release_date']?.toString(),
|
||||
albumType: data['album_type']?.toString() ?? album?.albumType,
|
||||
totalTracks: data['total_tracks'] as int? ?? album?.totalTracks,
|
||||
source: data['provider_id']?.toString(),
|
||||
source: data['provider_id']?.toString() ?? widget.extensionId,
|
||||
);
|
||||
}
|
||||
|
||||
ArtistAlbum _parseArtistAlbum(Map<String, dynamic> data) {
|
||||
final totalTracksValue = data['total_tracks'];
|
||||
final totalTracks =
|
||||
totalTracksValue is int
|
||||
? totalTracksValue
|
||||
: int.tryParse(totalTracksValue?.toString() ?? '') ?? 0;
|
||||
|
||||
return ArtistAlbum(
|
||||
id: data['id'] as String? ?? '',
|
||||
name: data['name'] as String? ?? '',
|
||||
releaseDate: data['release_date'] as String? ?? '',
|
||||
totalTracks: data['total_tracks'] as int? ?? 0,
|
||||
coverUrl: (data['cover_url'] ?? data['images'])?.toString(),
|
||||
albumType: data['album_type'] as String? ?? 'album',
|
||||
artists: data['artists'] as String? ?? '',
|
||||
providerId: data['provider_id']?.toString(),
|
||||
name: (data['name'] ?? data['title'] ?? '').toString(),
|
||||
releaseDate: (data['release_date'] ?? '').toString(),
|
||||
totalTracks: totalTracks,
|
||||
coverUrl: (data['cover_url'] ?? data['images'] ?? data['cover_art'])
|
||||
?.toString(),
|
||||
albumType: (data['album_type'] ?? data['type'] ?? 'album').toString(),
|
||||
artists: (data['artists'] ?? data['artist'] ?? widget.artistName)
|
||||
.toString(),
|
||||
providerId: data['provider_id']?.toString() ?? widget.extensionId,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -388,6 +452,7 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final albums = _albums ?? [];
|
||||
_ensureAlbumBuckets(albums);
|
||||
final releases = _releases ?? const <ArtistAlbum>[];
|
||||
final albumsOnly = _albumsOnlyBucket;
|
||||
final singles = _singlesBucket;
|
||||
final compilations = _compilationsBucket;
|
||||
|
|
@ -433,6 +498,14 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
SliverToBoxAdapter(
|
||||
child: _buildPopularSection(colorScheme),
|
||||
),
|
||||
if (releases.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildAlbumSection(
|
||||
'Releases',
|
||||
releases,
|
||||
colorScheme,
|
||||
),
|
||||
),
|
||||
if (albumsOnly.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: _buildAlbumSection(
|
||||
|
|
@ -1258,7 +1331,9 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final tracks = _topTracks!.take(5).toList();
|
||||
final tracks = _topTracks!;
|
||||
const tracksPerPage = 5;
|
||||
final pageCount = (tracks.length / tracksPerPage).ceil();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
|
@ -1272,11 +1347,58 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
|
|||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
...tracks.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final track = entry.value;
|
||||
return _buildPopularTrackItem(index + 1, track, colorScheme);
|
||||
}),
|
||||
SizedBox(
|
||||
height: tracksPerPage * 64.0,
|
||||
child: PageView.builder(
|
||||
controller: _popularPageController,
|
||||
itemCount: pageCount,
|
||||
onPageChanged: (page) {
|
||||
setState(() {
|
||||
_popularCurrentPage = page;
|
||||
});
|
||||
},
|
||||
itemBuilder: (context, pageIndex) {
|
||||
final startIndex = pageIndex * tracksPerPage;
|
||||
final endIndex =
|
||||
(startIndex + tracksPerPage).clamp(0, tracks.length);
|
||||
final pageTracks = tracks.sublist(startIndex, endIndex);
|
||||
|
||||
return Column(
|
||||
children: pageTracks.asMap().entries.map((entry) {
|
||||
final globalIndex = startIndex + entry.key;
|
||||
return _buildPopularTrackItem(
|
||||
globalIndex + 1,
|
||||
entry.value,
|
||||
colorScheme,
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (pageCount > 1)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(pageCount, (index) {
|
||||
final isActive = _popularCurrentPage == index;
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
width: isActive ? 8 : 6,
|
||||
height: isActive ? 8 : 6,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: isActive
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant.withValues(alpha: 0.3),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -298,11 +298,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
|
||||
final resolvedBitDepth = _readPositiveInt(metadata['bit_depth']);
|
||||
final resolvedSampleRate = _readPositiveInt(metadata['sample_rate']);
|
||||
final resolvedDuration = _readPositiveInt(metadata['duration']);
|
||||
final resolvedAlbum = metadata['album']?.toString();
|
||||
final resolvedQuality = buildDisplayAudioQuality(
|
||||
bitDepth: resolvedBitDepth ?? bitDepth,
|
||||
sampleRate: resolvedSampleRate ?? sampleRate,
|
||||
storedQuality: _quality,
|
||||
);
|
||||
|
||||
// Fill in album name from file tags if stored value is empty
|
||||
final needsAlbum = resolvedAlbum != null &&
|
||||
resolvedAlbum.isNotEmpty &&
|
||||
(albumName.isEmpty);
|
||||
// Fill in duration from file if stored value is missing/zero
|
||||
final needsDuration = resolvedDuration != null &&
|
||||
resolvedDuration > 0 &&
|
||||
(duration == null || duration == 0);
|
||||
|
||||
final shouldPersistResolvedAudioMetadata =
|
||||
resolvedBitDepth != null ||
|
||||
resolvedSampleRate != null ||
|
||||
|
|
@ -310,6 +322,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
|
||||
if ((resolvedBitDepth != null ||
|
||||
resolvedSampleRate != null ||
|
||||
needsAlbum ||
|
||||
needsDuration ||
|
||||
isPlaceholderQualityLabel(_quality)) &&
|
||||
mounted) {
|
||||
setState(() {
|
||||
|
|
@ -317,6 +331,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
...?_editedMetadata,
|
||||
if (resolvedBitDepth != null) 'bit_depth': resolvedBitDepth,
|
||||
if (resolvedSampleRate != null) 'sample_rate': resolvedSampleRate,
|
||||
if (needsAlbum) 'album': resolvedAlbum,
|
||||
if (needsDuration) 'duration': resolvedDuration,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -486,7 +502,8 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
_editedMetadata?['copyright']?.toString() ??
|
||||
(_isLocalItem ? null : _downloadItem!.copyright);
|
||||
int? get duration =>
|
||||
_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration;
|
||||
_readPositiveInt(_editedMetadata?['duration']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.duration : _downloadItem!.duration);
|
||||
int? get bitDepth =>
|
||||
_readPositiveInt(_editedMetadata?['bit_depth']) ??
|
||||
(_isLocalItem ? _localLibraryItem!.bitDepth : _downloadItem!.bitDepth);
|
||||
|
|
@ -1035,14 +1052,23 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
Builder(
|
||||
builder: (context) {
|
||||
final isDeezer = _spotifyId!.contains('deezer');
|
||||
final svc = _service.toLowerCase();
|
||||
String buttonLabel;
|
||||
if (isDeezer) {
|
||||
buttonLabel = context.l10n.trackOpenInDeezer;
|
||||
} else if (svc == 'amazon') {
|
||||
buttonLabel = 'Open in Amazon Music';
|
||||
} else if (svc == 'tidal') {
|
||||
buttonLabel = 'Open in Tidal';
|
||||
} else if (svc == 'qobuz') {
|
||||
buttonLabel = 'Open in Qobuz';
|
||||
} else {
|
||||
buttonLabel = context.l10n.trackOpenInSpotify;
|
||||
}
|
||||
return OutlinedButton.icon(
|
||||
onPressed: () => _openServiceUrl(context),
|
||||
icon: const Icon(Icons.open_in_new, size: 18),
|
||||
label: Text(
|
||||
isDeezer
|
||||
? context.l10n.trackOpenInDeezer
|
||||
: context.l10n.trackOpenInSpotify,
|
||||
),
|
||||
label: Text(buttonLabel),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
|
|
@ -1067,14 +1093,33 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
|
||||
final isDeezer = _spotifyId!.contains('deezer');
|
||||
final rawId = _spotifyId!.replaceAll('deezer:', '');
|
||||
final svc = _service.toLowerCase();
|
||||
|
||||
final webUrl = isDeezer
|
||||
? 'https://www.deezer.com/track/$rawId'
|
||||
: 'https://open.spotify.com/track/$rawId';
|
||||
String webUrl;
|
||||
Uri? appUri;
|
||||
String serviceName;
|
||||
|
||||
final appUri = isDeezer
|
||||
? Uri.parse('deezer://www.deezer.com/track/$rawId')
|
||||
: Uri.parse('spotify:track:$rawId');
|
||||
if (isDeezer) {
|
||||
webUrl = 'https://www.deezer.com/track/$rawId';
|
||||
appUri = Uri.parse('deezer://www.deezer.com/track/$rawId');
|
||||
serviceName = 'Deezer';
|
||||
} else if (svc == 'amazon') {
|
||||
webUrl = 'https://music.amazon.com/search/$rawId';
|
||||
appUri = Uri.parse('amznm://search/$rawId');
|
||||
serviceName = 'Amazon Music';
|
||||
} else if (svc == 'tidal') {
|
||||
webUrl = 'https://listen.tidal.com/track/$rawId';
|
||||
appUri = Uri.parse('tidal://track/$rawId');
|
||||
serviceName = 'Tidal';
|
||||
} else if (svc == 'qobuz') {
|
||||
webUrl = 'https://play.qobuz.com/track/$rawId';
|
||||
appUri = Uri.parse('qobuz://track/$rawId');
|
||||
serviceName = 'Qobuz';
|
||||
} else {
|
||||
webUrl = 'https://open.spotify.com/track/$rawId';
|
||||
appUri = Uri.parse('spotify:track:$rawId');
|
||||
serviceName = 'Spotify';
|
||||
}
|
||||
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
|
|
@ -1100,7 +1145,7 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.l10n.snackbarUrlCopied(isDeezer ? 'Deezer' : 'Spotify'),
|
||||
context.l10n.snackbarUrlCopied(serviceName),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
@ -1140,7 +1185,22 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
if (!_isLocalItem && _spotifyId != null && _spotifyId!.isNotEmpty) {
|
||||
final isDeezer = _spotifyId!.contains('deezer');
|
||||
final cleanId = _spotifyId!.replaceAll('deezer:', '');
|
||||
items.add(_MetadataItem(isDeezer ? 'Deezer ID' : 'Spotify ID', cleanId));
|
||||
String idLabel;
|
||||
if (isDeezer) {
|
||||
idLabel = 'Deezer ID';
|
||||
} else {
|
||||
switch (_service.toLowerCase()) {
|
||||
case 'amazon':
|
||||
idLabel = 'Amazon ASIN';
|
||||
case 'tidal':
|
||||
idLabel = 'Tidal ID';
|
||||
case 'qobuz':
|
||||
idLabel = 'Qobuz ID';
|
||||
default:
|
||||
idLabel = 'Spotify ID';
|
||||
}
|
||||
}
|
||||
items.add(_MetadataItem(idLabel, cleanId));
|
||||
}
|
||||
|
||||
items.add(
|
||||
|
|
@ -1153,7 +1213,12 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
|||
return Column(
|
||||
children: items.map((metadata) {
|
||||
final isCopyable =
|
||||
metadata.label == 'ISRC' || metadata.label == 'Spotify ID';
|
||||
metadata.label == 'ISRC' ||
|
||||
metadata.label == 'Spotify ID' ||
|
||||
metadata.label == 'Deezer ID' ||
|
||||
metadata.label == 'Amazon ASIN' ||
|
||||
metadata.label == 'Tidal ID' ||
|
||||
metadata.label == 'Qobuz ID';
|
||||
return InkWell(
|
||||
onTap: isCopyable
|
||||
? () => _copyToClipboard(context, metadata.value)
|
||||
|
|
|
|||
Loading…
Reference in a new issue