feat: add support for Deezer, Tidal, and YT Music links

This commit is contained in:
zarzet
2026-02-04 11:09:07 +07:00
parent 11eb0aa12a
commit b33ae905a2
11 changed files with 417 additions and 13 deletions
+126 -2
View File
@@ -193,6 +193,7 @@ class TrackNotifier extends Notifier<TrackState> {
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
try {
// Step 1: Check for extension URL handlers first (handles YT Music, etc.)
final extensionHandler = await PlatformBridge.findURLHandler(url);
if (extensionHandler != null) {
_log.i('Found extension URL handler: $extensionHandler for URL: $url');
@@ -251,8 +252,131 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
// Step 2: Try Deezer URL parsing
if (url.contains('deezer.com') || url.contains('deezer.page.link')) {
_log.i('Detected Deezer URL, parsing...');
final parsed = await PlatformBridge.parseDeezerUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
final id = parsed['id'] as String;
final metadata = await PlatformBridge.getDeezerMetadata(type, id);
if (!_isRequestValid(requestId)) return;
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
} else if (type == 'album') {
final albumInfo = metadata['album_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
state = TrackState(
tracks: tracks,
isLoading: false,
albumId: id,
albumName: albumInfo['name'] as String?,
coverUrl: albumInfo['images'] as String?,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'playlist') {
final playlistInfo = metadata['playlist_info'] as Map<String, dynamic>;
final trackList = metadata['track_list'] as List<dynamic>;
final tracks = trackList.map((t) => _parseTrack(t as Map<String, dynamic>)).toList();
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistInfo['name'] as String?,
coverUrl: playlistInfo['images'] as String?,
);
_preWarmCacheForTracks(tracks);
} else if (type == 'artist') {
final artistInfo = metadata['artist_info'] as Map<String, dynamic>;
final albumsList = metadata['albums'] as List<dynamic>;
final albums = albumsList.map((a) => _parseArtistAlbum(a as Map<String, dynamic>)).toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: artistInfo['images'] as String?,
artistAlbums: albums,
);
}
return;
}
// Step 3: Try Tidal URL parsing
if (url.contains('tidal.com')) {
_log.i('Detected Tidal URL, parsing...');
final parsed = await PlatformBridge.parseTidalUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
final id = parsed['id'] as String;
_log.i('Tidal URL parsed: type=$type, id=$id');
// For track URLs, convert to Spotify/Deezer and fetch metadata from there
if (type == 'track') {
try {
_log.i('Converting Tidal track to Spotify/Deezer via SongLink...');
final conversion = await PlatformBridge.convertTidalToSpotifyDeezer(url);
if (!_isRequestValid(requestId)) return;
final spotifyUrl = conversion['spotify_url'] as String?;
final deezerUrl = conversion['deezer_url'] as String?;
if (spotifyUrl != null && spotifyUrl.isNotEmpty) {
_log.i('Found Spotify URL: $spotifyUrl, fetching metadata...');
final metadata = await PlatformBridge.getSpotifyMetadataWithFallback(spotifyUrl);
if (!_isRequestValid(requestId)) return;
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
return;
} else if (deezerUrl != null && deezerUrl.isNotEmpty) {
_log.i('Found Deezer URL: $deezerUrl, fetching metadata...');
final deezerParsed = await PlatformBridge.parseDeezerUrl(deezerUrl);
final metadata = await PlatformBridge.getDeezerMetadata('track', deezerParsed['id'] as String);
if (!_isRequestValid(requestId)) return;
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
return;
}
} catch (e) {
_log.w('Failed to convert Tidal URL via SongLink: $e');
}
}
// For album/artist/playlist, not yet supported
state = TrackState(
isLoading: false,
error: 'Tidal $type links are not fully supported yet. Only track links work via SongLink conversion.',
hasSearchText: state.hasSearchText,
);
return;
}
// Step 4: Fall back to Spotify parsing
final parsed = await PlatformBridge.parseSpotifyUrl(url);
if (!_isRequestValid(requestId)) return; // Request cancelled
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
@@ -264,7 +388,7 @@ class TrackNotifier extends Notifier<TrackState> {
rethrow;
}
if (!_isRequestValid(requestId)) return; // Request cancelled
if (!_isRequestValid(requestId)) return;
if (type == 'track') {
final trackData = metadata['track'] as Map<String, dynamic>;
+10
View File
@@ -368,6 +368,16 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> parseTidalUrl(String url) async {
final result = await _channel.invokeMethod('parseTidalUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> convertTidalToSpotifyDeezer(String tidalUrl) async {
final result = await _channel.invokeMethod('convertTidalToSpotifyDeezer', {'url': tidalUrl});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> searchDeezerByISRC(String isrc) async {
final result = await _channel.invokeMethod('searchDeezerByISRC', {'isrc': isrc});
return jsonDecode(result as String) as Map<String, dynamic>;
+45 -10
View File
@@ -9,16 +9,35 @@ class ShareIntentService {
factory ShareIntentService() => _instance;
ShareIntentService._internal();
// Spotify patterns
static final RegExp _spotifyUriPattern =
RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+');
static final RegExp _spotifyUrlPattern = RegExp(
r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?',
);
// Deezer patterns
static final RegExp _deezerUrlPattern = RegExp(
r'https?://(www\.)?deezer\.com/(track|album|playlist|artist)/\d+(\?[^\s]*)?',
);
static final RegExp _deezerShortLinkPattern = RegExp(
r'https?://deezer\.page\.link/[a-zA-Z0-9]+',
);
// Tidal patterns
static final RegExp _tidalUrlPattern = RegExp(
r'https?://(listen\.)?tidal\.com/(track|album|playlist|artist)/[a-zA-Z0-9-]+(\?[^\s]*)?',
);
// YouTube Music patterns
static final RegExp _ytMusicUrlPattern = RegExp(
r'https?://music\.youtube\.com/(watch\?v=|playlist\?list=|channel/)[a-zA-Z0-9_-]+(\&[^\s]*)?',
);
final _sharedUrlController = StreamController<String>.broadcast();
StreamSubscription<List<SharedMediaFile>>? _mediaSubscription;
bool _initialized = false;
String? _pendingUrl; // Store URL received before listener is ready
String? _pendingUrl;
Stream<String> get sharedUrlStream => _sharedUrlController.stream;
@@ -48,31 +67,47 @@ class ShareIntentService {
for (final file in files) {
final textToCheck = file.path;
final url = _extractSpotifyUrl(textToCheck);
final url = _extractMusicUrl(textToCheck);
if (url != null) {
_log.i('Received Spotify URL: $url (initial: $isInitial)');
_log.i('Received music URL: $url (initial: $isInitial)');
if (isInitial) {
_pendingUrl = url;
}
_sharedUrlController.add(url);
return; // Only process first valid URL
return;
}
}
}
String? _extractSpotifyUrl(String text) {
String? _extractMusicUrl(String text) {
if (text.isEmpty) return null;
// Try Spotify URI first
final uriMatch = _spotifyUriPattern.firstMatch(text);
if (uriMatch != null) {
return uriMatch.group(0);
}
final urlMatch = _spotifyUrlPattern.firstMatch(text);
if (urlMatch != null) {
final fullUrl = urlMatch.group(0)!;
final queryIndex = fullUrl.indexOf('?');
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
// Try all URL patterns
final patterns = [
_spotifyUrlPattern,
_deezerUrlPattern,
_deezerShortLinkPattern,
_tidalUrlPattern,
_ytMusicUrlPattern,
];
for (final pattern in patterns) {
final match = pattern.firstMatch(text);
if (match != null) {
final fullUrl = match.group(0)!;
// Remove query params for cleaner URL (except for YT Music which needs them)
if (pattern == _ytMusicUrlPattern) {
return fullUrl;
}
final queryIndex = fullUrl.indexOf('?');
return queryIndex > 0 ? fullUrl.substring(0, queryIndex) : fullUrl;
}
}
return null;