diff --git a/go_backend/songlink.go b/go_backend/songlink.go index 9543b080..30114f0a 100644 --- a/go_backend/songlink.go +++ b/go_backend/songlink.go @@ -355,6 +355,138 @@ func (s *SongLinkClient) CheckTrackAvailability(spotifyTrackID string, isrc stri return cloneTrackAvailability(availability), nil } +const trackPlatformLinksCacheMax = 200 + +type trackPlatformLinksCacheEntry struct { + links map[string]string + err bool + expiresAt time.Time +} + +var ( + trackPlatformLinksCacheMu sync.Mutex + trackPlatformLinksCache = map[string]trackPlatformLinksCacheEntry{} +) + +// GetTrackPlatformLinks returns every streaming-platform URL song.link knows +// for a track, keyed by song.link platform ID. Cached in memory like +// CheckTrackAvailability to spare the same request budget. +func (s *SongLinkClient) GetTrackPlatformLinks(spotifyTrackID string, isrc string) (map[string]string, error) { + spotifyTrackID = strings.TrimSpace(spotifyTrackID) + isrc = strings.ToUpper(strings.TrimSpace(isrc)) + + var idKey string + switch { + case spotifyTrackID != "": + idKey = "spotify:" + spotifyTrackID + case isrc != "": + idKey = "isrc:" + isrc + default: + return nil, fmt.Errorf("spotify track ID and ISRC are empty") + } + key := GetSongLinkRegion() + "|" + idKey + + if links, hit, cachedErr := trackPlatformLinksCacheLookup(key); hit { + if cachedErr { + return nil, fmt.Errorf("track platform links unavailable (cached)") + } + return links, nil + } + + links, err := s.fetchTrackPlatformLinks(spotifyTrackID, isrc) + trackPlatformLinksCacheStore(key, links, err) + if err != nil { + return nil, err + } + return links, nil +} + +func (s *SongLinkClient) fetchTrackPlatformLinks(spotifyTrackID string, isrc string) (map[string]string, error) { + var raw map[string]songLinkPlatformLink + var err error + if spotifyTrackID != "" { + raw, err = s.resolveTrackPlatforms( + fmt.Sprintf("https://open.spotify.com/track/%s", spotifyTrackID), + ) + } else { + ctx, cancel := context.WithTimeout(context.Background(), SongLinkTimeout) + defer cancel() + track, isrcErr := songLinkSearchByISRC(ctx, isrc) + if isrcErr != nil { + return nil, fmt.Errorf("failed to resolve Deezer track from ISRC %s: %w", isrc, isrcErr) + } + deezerTrackID := songLinkExtractDeezerTrackID(track) + if deezerTrackID == "" { + return nil, fmt.Errorf("failed to resolve Deezer track ID from ISRC %s", isrc) + } + raw, err = s.resolveTrackPlatformsByPlatform("deezer", "song", deezerTrackID) + } + if err != nil { + return nil, err + } + + links := make(map[string]string, len(raw)) + for platform, link := range raw { + if url := normalizeShareURL(link.URL); url != "" { + links[platform] = url + } + } + if len(links) == 0 { + return nil, fmt.Errorf("no platform links found") + } + return links, nil +} + +func trackPlatformLinksCacheLookup(key string) (map[string]string, bool, bool) { + trackPlatformLinksCacheMu.Lock() + defer trackPlatformLinksCacheMu.Unlock() + e, ok := trackPlatformLinksCache[key] + if !ok { + return nil, false, false + } + if time.Now().After(e.expiresAt) { + delete(trackPlatformLinksCache, key) + return nil, false, false + } + return cloneStringMap(e.links), true, e.err +} + +func trackPlatformLinksCacheStore(key string, links map[string]string, err error) { + ttl := trackAvailabilityCacheTTL + if err != nil { + ttl = trackAvailabilityNegCacheTTL + } + trackPlatformLinksCacheMu.Lock() + defer trackPlatformLinksCacheMu.Unlock() + if _, exists := trackPlatformLinksCache[key]; !exists && len(trackPlatformLinksCache) >= trackPlatformLinksCacheMax { + var oldestKey string + var oldest time.Time + first := true + for k, e := range trackPlatformLinksCache { + if first || e.expiresAt.Before(oldest) { + oldest, oldestKey, first = e.expiresAt, k, false + } + } + delete(trackPlatformLinksCache, oldestKey) + } + trackPlatformLinksCache[key] = trackPlatformLinksCacheEntry{ + links: cloneStringMap(links), + err: err != nil, + expiresAt: time.Now().Add(ttl), + } +} + +func cloneStringMap(m map[string]string) map[string]string { + if m == nil { + return nil + } + c := make(map[string]string, len(m)) + for k, v := range m { + c[k] = v + } + return c +} + func cloneTrackAvailability(a *TrackAvailability) *TrackAvailability { if a == nil { return nil diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 997184bb..f35beea4 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -826,6 +826,55 @@ "@collectionExportM3uFailed": { "description": "Snackbar when writing or sharing the M3U8 export fails" }, + "trackOpenOn": "Open on...", + "@trackOpenOn": { + "description": "Track option and sheet title listing streaming platforms where the track can be opened" + }, + "trackOpenOnNoLinks": "No platform links found for this track.", + "@trackOpenOnNoLinks": { + "description": "Shown in the Open on sheet when song.link returns no links" + }, + "libraryReviewDuplicates": "Review duplicates", + "@libraryReviewDuplicates": { + "description": "Library settings row opening the duplicate review sheet" + }, + "libraryReviewDuplicatesSubtitle": "Find tracks stored more than once", + "@libraryReviewDuplicatesSubtitle": { + "description": "Subtitle for the duplicate review settings row" + }, + "duplicatesTitle": "Duplicates", + "@duplicatesTitle": { + "description": "Title of the duplicate review sheet" + }, + "duplicatesEmpty": "No duplicate tracks found.", + "@duplicatesEmpty": { + "description": "Shown when the duplicate review sheet finds nothing" + }, + "duplicatesKeepBest": "Keep best", + "@duplicatesKeepBest": { + "description": "Button that deletes all but the highest-quality copy in a duplicate group" + }, + "duplicatesKeepBestMessage": "Delete {count} lower-quality copies of \"{trackName}\"?", + "@duplicatesKeepBestMessage": { + "description": "Confirmation message for the keep-best action", + "placeholders": { + "count": { + "type": "int" + }, + "trackName": { + "type": "String" + } + } + }, + "duplicatesDeleteCopyMessage": "Delete this copy of \"{trackName}\"?", + "@duplicatesDeleteCopyMessage": { + "description": "Confirmation message for deleting one duplicate copy", + "placeholders": { + "trackName": { + "type": "String" + } + } + }, "@dialogImportPlaylistMessage": { "description": "Dialog message - import playlist confirmation", "placeholders": { @@ -5393,10 +5442,6 @@ "@downloadNativeWorkerSubtitle": { "description": "Setting subtitle for Android native download worker" }, - "badgeBeta": "BETA", - "@badgeBeta": { - "description": "Badge label for beta features" - }, "extensionServiceStatus": "Service Status", "@extensionServiceStatus": { "description": "Extension detail section header for service status" diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index da8e36a1..fae98086 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -36,6 +36,7 @@ import 'package:spotiflac_android/widgets/album_detail_header.dart' import 'package:spotiflac_android/widgets/audio_analysis_widget.dart'; import 'package:spotiflac_android/widgets/batch_convert_sheet.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; +import 'package:spotiflac_android/widgets/open_on_platform_sheet.dart'; import 'package:spotiflac_android/widgets/settings_group.dart'; import 'package:spotiflac_android/constants/music_services.dart'; import 'package:spotiflac_android/screens/collapsing_header_scroll_mixin.dart'; @@ -1356,10 +1357,21 @@ class _TrackMetadataScreenState extends ConsumerState label: l10n.cueSplitTitle, onTap: () => _showCueSplitSheet(screenContext), ), + if (_spotifyId != null || (isrc?.isNotEmpty ?? false)) + _MetadataOption( + icon: Icons.open_in_new, + label: l10n.trackOpenOn, + dividerAbove: true, + onTap: () => OpenOnPlatformSheet.show( + screenContext, + spotifyId: _spotifyId ?? '', + isrc: isrc ?? '', + ), + ), _MetadataOption( icon: Icons.share_outlined, label: l10n.trackMetadataShare, - dividerAbove: true, + dividerAbove: _spotifyId == null && !(isrc?.isNotEmpty ?? false), onTap: () => _shareFile(screenContext), ), _MetadataOption( diff --git a/lib/widgets/open_on_platform_sheet.dart b/lib/widgets/open_on_platform_sheet.dart new file mode 100644 index 00000000..e354a159 --- /dev/null +++ b/lib/widgets/open_on_platform_sheet.dart @@ -0,0 +1,174 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; +import 'package:spotiflac_android/widgets/settings_group.dart'; +import 'package:url_launcher/url_launcher.dart'; + +/// Bottom sheet listing every streaming platform song.link resolves for a +/// track. Data-driven from the generic platform-link map so the app core +/// stays service-agnostic; tapping a row opens the link externally. +class OpenOnPlatformSheet extends StatelessWidget { + final String spotifyId; + final String isrc; + + const OpenOnPlatformSheet({super.key, this.spotifyId = '', this.isrc = ''}); + + static Future show( + BuildContext context, { + String spotifyId = '', + String isrc = '', + }) { + final colorScheme = Theme.of(context).colorScheme; + return showModalBottomSheet( + context: context, + useRootNavigator: true, + backgroundColor: colorScheme.surfaceContainerHigh, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + builder: (_) => OpenOnPlatformSheet(spotifyId: spotifyId, isrc: isrc), + ); + } + + static const Map _platformNames = { + 'spotify': 'Spotify', + 'appleMusic': 'Apple Music', + 'itunes': 'iTunes', + 'youtube': 'YouTube', + 'youtubeMusic': 'YouTube Music', + 'deezer': 'Deezer', + 'tidal': 'TIDAL', + 'amazonMusic': 'Amazon Music', + 'amazonStore': 'Amazon Store', + 'qobuz': 'Qobuz', + 'soundcloud': 'SoundCloud', + 'pandora': 'Pandora', + 'napster': 'Napster', + 'yandex': 'Yandex Music', + 'audiomack': 'Audiomack', + 'anghami': 'Anghami', + 'boomplay': 'Boomplay', + }; + + static String _displayName(String platformId) { + final known = _platformNames[platformId]; + if (known != null) return known; + // Humanize unknown camelCase IDs ("someNewService" -> "Some New Service"). + final spaced = platformId.replaceAllMapped( + RegExp(r'([a-z])([A-Z])'), + (m) => '${m[1]} ${m[2]}', + ); + return spaced.isEmpty + ? platformId + : spaced[0].toUpperCase() + spaced.substring(1); + } + + Future _openLink(BuildContext context, String url) async { + HapticFeedback.selectionClick(); + final uri = Uri.tryParse(url); + if (uri == null) return; + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(context.l10n.snackbarCannotOpenFile(url))), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return SafeArea( + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.7, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 8), + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + context.l10n.trackOpenOn, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + ), + Flexible( + child: FutureBuilder>( + future: PlatformBridge.getTrackPlatformLinks( + spotifyId: spotifyId, + isrc: isrc, + ), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(32), + child: Center(child: CircularProgressIndicator()), + ); + } + final links = snapshot.data ?? const {}; + if (links.isEmpty) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 8, 24, 32), + child: Text( + context.l10n.trackOpenOnNoLinks, + style: Theme.of(context).textTheme.bodyMedium + ?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ); + } + final entries = links.entries.toList() + ..sort( + (a, b) => + _displayName(a.key).compareTo(_displayName(b.key)), + ); + return SingleChildScrollView( + child: Column( + children: [ + SettingsGroup( + children: [ + for (final entry in entries) + ListTile( + title: Text(_displayName(entry.key)), + trailing: Icon( + Icons.open_in_new, + size: 18, + color: colorScheme.onSurfaceVariant, + ), + onTap: () => _openLink(context, entry.value), + ), + ], + ), + const SizedBox(height: 16), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +}