diff --git a/.gitignore b/.gitignore index 3c57112b..699e248c 100644 --- a/.gitignore +++ b/.gitignore @@ -67,6 +67,7 @@ AGENTS.md # Temp/misc nul +network_requests.txt # Log files *.log diff --git a/README.md b/README.md index b52224b2..9ccd9abc 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@
-[![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) diff --git a/go_backend/exports.go b/go_backend/exports.go index 2e833c01..5a8a7840 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -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 } diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 13d759af..1866543f 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -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 } diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 4761a22a..df46e084 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -2373,11 +2373,25 @@ class DownloadQueueNotifier extends Notifier { 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 { 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, diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 8c1dd6a8..f0552eda 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -933,8 +933,10 @@ class TrackNotifier extends Notifier { Track _parseTrack(Map 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? ?? '', diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 31ba546e..3efc2d88 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -38,12 +38,14 @@ class _ArtistCache { static void set( String artistId, { required List albums, + List? releases, List? 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 albums; + final List? releases; final List? 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 { bool _isLoadingDiscography = false; List? _albums; + List? _releases; List? _topTracks; String? _headerImageUrl; int? _monthlyListeners; @@ -104,6 +109,8 @@ class _ArtistScreenState extends ConsumerState { bool _showTitleInAppBar = false; final ScrollController _scrollController = ScrollController(); + final PageController _popularPageController = PageController(); + int _popularCurrentPage = 0; bool _isSelectionMode = false; final Set _selectedAlbumIds = {}; @@ -174,6 +181,11 @@ class _ArtistScreenState extends ConsumerState { _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 { } } 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 { void dispose() { _scrollController.removeListener(_onScroll); _scrollController.dispose(); + _popularPageController.dispose(); super.dispose(); } @@ -221,6 +235,7 @@ class _ArtistScreenState extends ConsumerState { setState(() => _isLoadingDiscography = true); try { List albums; + List? releases; List? topTracks; String? headerImage; int? listeners; @@ -259,6 +274,42 @@ class _ArtistScreenState extends ConsumerState { .toList(); final artistInfo = metadata['artist_info'] as Map?; 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? ?? []; + albums = albumsList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + + final releasesList = artistData['releases'] as List? ?? []; + if (releasesList.isNotEmpty) { + releases = releasesList + .map((a) => _parseArtistAlbum(a as Map)) + .toList(); + } + + final topTracksList = + artistData['top_tracks'] as List? ?? []; + if (topTracksList.isNotEmpty) { + topTracks = topTracksList + .map((t) => _parseTrack(t as Map)) + .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 { _ArtistCache.set( widget.artistId, albums: albums, + releases: releases, topTracks: topTracks, headerImageUrl: finalHeaderImage, monthlyListeners: finalListeners, @@ -307,6 +359,7 @@ class _ArtistScreenState extends ConsumerState { if (mounted) { setState(() { _albums = albums; + _releases = releases; _topTracks = topTracks; _headerImageUrl = finalHeaderImage; _monthlyListeners = finalListeners; @@ -332,8 +385,11 @@ class _ArtistScreenState extends ConsumerState { 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 { 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 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 { final colorScheme = Theme.of(context).colorScheme; final albums = _albums ?? []; _ensureAlbumBuckets(albums); + final releases = _releases ?? const []; final albumsOnly = _albumsOnlyBucket; final singles = _singlesBucket; final compilations = _compilationsBucket; @@ -433,6 +498,14 @@ class _ArtistScreenState extends ConsumerState { 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 { 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 { ).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), + ), + ); + }), + ), + ), + ), ], ); } diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index d23d41e1..cd37ee6a 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -298,11 +298,23 @@ class _TrackMetadataScreenState extends ConsumerState { 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 { if ((resolvedBitDepth != null || resolvedSampleRate != null || + needsAlbum || + needsDuration || isPlaceholderQualityLabel(_quality)) && mounted) { setState(() { @@ -317,6 +331,8 @@ class _TrackMetadataScreenState extends ConsumerState { ...?_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 { _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 { 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 { 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 { 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 { 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 { 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)