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:
zarzet 2026-03-14 21:47:57 +07:00
parent aa9854fc0a
commit 134bf4375f
8 changed files with 408 additions and 39 deletions

1
.gitignore vendored
View file

@ -67,6 +67,7 @@ AGENTS.md
# Temp/misc
nul
network_requests.txt
# Log files
*.log

View file

@ -25,7 +25,7 @@
<div align="center">
[![GitHub All Releases](https://img.shields.io/github/downloads/zarzet/SpotiFLAC-Mobile/total?style=for-the-badge&refresh=1)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![GitHub Release](https://img.shields.io/github/v/release/zarzet/SpotiFLAC-Mobile?style=for-the-badge&logo=github)](https://github.com/zarzet/SpotiFLAC-Mobile/releases)
[![VirusTotal](https://img.shields.io/badge/VirusTotal-Safe-brightgreen?style=for-the-badge&logo=virustotal)](https://www.virustotal.com/gui/file/0a2bd2a033551983fc9fcd83f82fd912c83914fd1094cd8d1c7c6a68eb23233f)
[![Crowdin](https://img.shields.io/badge/HELP%20TRANSLATE%20ON-CROWDIN-%2321252b?style=for-the-badge&logo=crowdin)](https://crowdin.com/project/spotiflac-mobile)

View file

@ -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
}

View file

@ -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
}

View file

@ -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,

View file

@ -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? ?? '',

View file

@ -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),
),
);
}),
),
),
),
],
);
}

View file

@ -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)