feat(track): add Open on... platform links sheet

Track options gain an Open on... entry listing every streaming platform
song.link resolves for the track, served by a new generic
GetTrackPlatformLinks with its own memory cache beside the availability
cache (same request budget). The sheet is data-driven from the platform
map so the app core stays service-agnostic. Completes the bridge
wrappers already landed in 901fa34d; also carries the en strings for
the duplicate review sheet landing next.
This commit is contained in:
zarzet
2026-07-26 18:42:41 +07:00
parent 901fa34d80
commit d4f0b84615
4 changed files with 368 additions and 5 deletions
+132
View File
@@ -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
+49 -4
View File
@@ -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"
+13 -1
View File
@@ -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<TrackMetadataScreen>
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(
+174
View File
@@ -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<void> show(
BuildContext context, {
String spotifyId = '',
String isrc = '',
}) {
final colorScheme = Theme.of(context).colorScheme;
return showModalBottomSheet<void>(
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<String, String> _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<void> _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<Map<String, String>>(
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 <String, String>{};
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),
],
),
);
},
),
),
],
),
),
);
}
}