refactor: abstract built-in providers into generic registry and unify platform bridge API

Replace hardcoded Tidal/Qobuz switch/case with builtInProviderSpec registry
pattern. Unify searchTidalAll/searchQobuzAll into searchProviderAll,
getDeezerMetadata/getTidalMetadata/getQobuzMetadata into getProviderMetadata,
and parseDeezerUrl/parseQobuzUrl/parseTidalUrl into parseProviderUrl. Remove
extension-specific getAlbum/Playlist/ArtistWithExtension in favor of generic
getProviderMetadata routing. Extract provider UI helpers into
provider_ui_utils.dart. Preserve track_number fallback for zero-value
TrackNumber in album/playlist track lists.
This commit is contained in:
zarzet
2026-04-17 03:59:02 +07:00
parent e87f7a1177
commit 6895e45f2c
21 changed files with 1180 additions and 1365 deletions
+7 -4
View File
@@ -2575,9 +2575,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
final providerTrackId = track.id.substring(colonIdx + 1);
_log.d('No ISRC, fetching from $provider API: $providerTrackId');
final providerData = provider == 'tidal'
? await PlatformBridge.getTidalMetadata('track', providerTrackId)
: await PlatformBridge.getQobuzMetadata('track', providerTrackId);
final providerData = await PlatformBridge.getProviderMetadata(
provider,
'track',
providerTrackId,
);
final trackData = providerData['track'] as Map<String, dynamic>?;
if (trackData == null) {
@@ -4448,7 +4450,8 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
);
final rawId = trackToDownload.id.split(':')[1];
_log.d('Fetching full metadata for Deezer ID: $rawId');
final fullData = await PlatformBridge.getDeezerMetadata(
final fullData = await PlatformBridge.getProviderMetadata(
'deezer',
'track',
rawId,
);
+149 -9
View File
@@ -15,6 +15,44 @@ const _metadataProviderPriorityKey = 'metadata_provider_priority';
const _providerPriorityKey = 'provider_priority';
const _spotifyWebExtensionId = 'spotify-web';
class BuiltInProviderSpec {
final String id;
final String displayName;
final bool supportsMetadata;
final bool supportsDownload;
final bool supportsSearch;
const BuiltInProviderSpec({
required this.id,
required this.displayName,
this.supportsMetadata = false,
this.supportsDownload = false,
this.supportsSearch = false,
});
factory BuiltInProviderSpec.fromJson(Map<String, dynamic> json) {
return BuiltInProviderSpec(
id: json['id'] as String? ?? '',
displayName:
json['display_name'] as String? ??
json['displayName'] as String? ??
'',
supportsMetadata: json['supports_metadata'] as bool? ?? false,
supportsDownload: json['supports_download'] as bool? ?? false,
supportsSearch: json['supports_search'] as bool? ?? false,
);
}
}
List<BuiltInProviderSpec> _builtInProviderRegistry = const [];
List<BuiltInProviderSpec> get builtInProviderSpecs =>
List<BuiltInProviderSpec>.unmodifiable(_builtInProviderRegistry);
void _replaceBuiltInProviderRegistry(List<BuiltInProviderSpec> providers) {
_builtInProviderRegistry = List<BuiltInProviderSpec>.unmodifiable(providers);
}
class Extension {
final String id;
final String name;
@@ -195,6 +233,83 @@ class Extension {
}
}
BuiltInProviderSpec? builtInProviderSpecForId(String? providerId) {
if (providerId == null) return null;
for (final provider in builtInProviderSpecs) {
if (provider.id == providerId) {
return provider;
}
}
return null;
}
List<BuiltInProviderSpec> _builtInProvidersWhere(
bool Function(BuiltInProviderSpec provider) predicate,
) {
return List<BuiltInProviderSpec>.unmodifiable(
builtInProviderSpecs.where(predicate),
);
}
List<BuiltInProviderSpec> get builtInSearchProviderSpecs =>
_builtInProvidersWhere((provider) => provider.supportsSearch);
List<BuiltInProviderSpec> get builtInMetadataProviderSpecs =>
_builtInProvidersWhere((provider) => provider.supportsMetadata);
List<BuiltInProviderSpec> get builtInDownloadProviderSpecs =>
_builtInProvidersWhere((provider) => provider.supportsDownload);
List<String> get builtInSearchProviderIds => List<String>.unmodifiable(
builtInSearchProviderSpecs.map((provider) => provider.id),
);
List<String> get builtInMetadataProviderIds => List<String>.unmodifiable(
builtInMetadataProviderSpecs.map((provider) => provider.id),
);
List<String> get builtInDownloadProviderIds => List<String>.unmodifiable(
builtInDownloadProviderSpecs.map((provider) => provider.id),
);
bool isBuiltInSearchProvider(String? providerId) =>
builtInProviderSpecForId(providerId)?.supportsSearch ?? false;
bool isBuiltInMetadataProvider(String? providerId) =>
builtInProviderSpecForId(providerId)?.supportsMetadata ?? false;
bool isBuiltInDownloadProvider(String? providerId) =>
builtInProviderSpecForId(providerId)?.supportsDownload ?? false;
String? get defaultBuiltInSearchProviderId => builtInSearchProviderSpecs.isEmpty
? null
: builtInSearchProviderSpecs.first.id;
String? get defaultBuiltInSearchProviderDisplayName =>
builtInSearchProviderSpecs.isEmpty
? null
: builtInSearchProviderSpecs.first.displayName;
String resolveProviderDisplayName(
String providerId, {
Iterable<Extension> extensions = const [],
}) {
final builtIn = builtInProviderSpecForId(providerId);
if (builtIn != null) {
return builtIn.displayName;
}
for (final extension in extensions) {
if (extension.id == providerId) {
return extension.displayName;
}
}
return providerId;
}
class SearchFilter {
final String id;
final String? label;
@@ -460,6 +575,7 @@ class ExtensionSetting {
class ExtensionState {
final List<Extension> extensions;
final List<BuiltInProviderSpec> builtInProviders;
final List<String> providerPriority;
final List<String> metadataProviderPriority;
final bool isLoading;
@@ -468,6 +584,7 @@ class ExtensionState {
const ExtensionState({
this.extensions = const [],
this.builtInProviders = const [],
this.providerPriority = const [],
this.metadataProviderPriority = const [],
this.isLoading = false,
@@ -477,6 +594,7 @@ class ExtensionState {
ExtensionState copyWith({
List<Extension>? extensions,
List<BuiltInProviderSpec>? builtInProviders,
List<String>? providerPriority,
List<String>? metadataProviderPriority,
bool? isLoading,
@@ -485,6 +603,7 @@ class ExtensionState {
}) {
return ExtensionState(
extensions: extensions ?? this.extensions,
builtInProviders: builtInProviders ?? this.builtInProviders,
providerPriority: providerPriority ?? this.providerPriority,
metadataProviderPriority:
metadataProviderPriority ?? this.metadataProviderPriority,
@@ -496,7 +615,6 @@ class ExtensionState {
}
class ExtensionNotifier extends Notifier<ExtensionState> {
static const _builtInMetadataProviders = ['qobuz', 'tidal'];
AppLifecycleListener? _appLifecycleListener;
bool _cleanupInFlight = false;
Completer<void>? _initializationCompleter;
@@ -547,6 +665,12 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
state = state.copyWith(isLoading: true, error: null);
try {
await refreshBuiltInProviders();
} catch (e) {
_log.w('Failed to refresh built-in providers before init: $e');
}
if (!PlatformBridge.supportsExtensionSystem) {
state = state.copyWith(
isInitialized: true,
@@ -634,6 +758,16 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
}
Future<void> refreshBuiltInProviders() async {
final list = await PlatformBridge.getBuiltInProviders();
final providers = list
.map((e) => BuiltInProviderSpec.fromJson(e))
.where((provider) => provider.id.isNotEmpty)
.toList();
_replaceBuiltInProviderRegistry(providers);
state = state.copyWith(builtInProviders: providers);
}
void clearError() {
state = state.copyWith(error: null);
}
@@ -727,10 +861,16 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
if (ext.hasDownloadProvider && settings.defaultService == extensionId) {
ref.read(settingsProvider.notifier).setDefaultService('tidal');
_log.d(
'Reset default service to Tidal because extension $extensionId was disabled',
);
final availableProviders = getAllDownloadProviders();
if (availableProviders.isNotEmpty) {
final fallbackService = availableProviders.first;
ref
.read(settingsProvider.notifier)
.setDefaultService(fallbackService);
_log.d(
'Reset default service to $fallbackService because extension $extensionId was disabled',
);
}
}
}
} catch (e) {
@@ -973,7 +1113,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllDownloadProviders() {
final providers = ['tidal', 'qobuz'];
final providers = List<String>.from(builtInDownloadProviderIds);
for (final ext in state.extensions) {
if (ext.enabled && ext.hasDownloadProvider) {
providers.add(ext.id);
@@ -995,7 +1135,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return [
...primarySearchMetadataExtensions,
..._builtInMetadataProviders,
...builtInMetadataProviderIds,
...otherMetadataExtensions,
];
}
@@ -1012,10 +1152,10 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
final hasPreferredExtension = preferredOrder.any(
(provider) => !_builtInMetadataProviders.contains(provider),
(provider) => !isBuiltInMetadataProvider(provider),
);
final hasSavedExtension = result.any(
(provider) => !_builtInMetadataProviders.contains(provider),
(provider) => !isBuiltInMetadataProvider(provider),
);
if (!hasSavedExtension && hasPreferredExtension) {
+178 -259
View File
@@ -353,212 +353,12 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
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: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_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: normalizeRemoteHttpUrl(
playlistInfo['images']?.toString(),
),
);
_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: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
return;
}
if (url.contains('qobuz.com') || url.startsWith('qobuzapp://')) {
_log.i('Detected Qobuz URL, parsing...');
final parsed = await PlatformBridge.parseQobuzUrl(url);
if (!_isRequestValid(requestId)) return;
final type = parsed['type'] as String;
final id = parsed['id'] as String;
final metadata = await PlatformBridge.getQobuzMetadata(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: 'qobuz:$id',
albumName: albumInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_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();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistName,
coverUrl: coverUrl,
);
_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: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
return;
}
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;
final metadata = await PlatformBridge.getTidalMetadata(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: 'tidal:$id',
albumName: albumInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_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();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistName,
coverUrl: coverUrl,
);
_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: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
artistAlbums: albums,
);
}
final handledBuiltInUrl = await _tryResolveBuiltInProviderUrl(
url,
requestId,
);
if (!_isRequestValid(requestId)) return;
if (handledBuiltInUrl) {
return;
}
@@ -577,6 +377,134 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
Future<bool> _tryResolveBuiltInProviderUrl(String url, int requestId) async {
Map<String, dynamic> parsed;
try {
parsed = await PlatformBridge.parseProviderUrl(url);
} catch (_) {
return false;
}
if (!_isRequestValid(requestId)) return true;
final providerId = parsed['provider_id']?.toString();
final type = parsed['type']?.toString();
final id = parsed['id']?.toString();
if (providerId == null ||
providerId.isEmpty ||
type == null ||
type.isEmpty ||
id == null ||
id.isEmpty) {
return false;
}
_log.i('Detected built-in provider URL: $providerId:$type:$id');
final metadata = await _getResolvedProviderMetadata(providerId, type, id);
if (!_isRequestValid(requestId)) return true;
_applyResolvedProviderMetadata(providerId, type, id, metadata);
return true;
}
Future<Map<String, dynamic>> _getResolvedProviderMetadata(
String providerId,
String resourceType,
String resourceId,
) async {
return PlatformBridge.getProviderMetadata(
providerId,
resourceType,
resourceId,
);
}
void _applyResolvedProviderMetadata(
String providerId,
String resourceType,
String resourceId,
Map<String, dynamic> metadata,
) {
switch (resourceType) {
case 'track':
final trackData = metadata['track'] as Map<String, dynamic>;
final track = _parseTrack(trackData);
state = TrackState(
tracks: [track],
isLoading: false,
coverUrl: track.coverUrl,
);
return;
case '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: _buildResolvedAlbumId(providerId, resourceId),
albumName: albumInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(albumInfo['images']?.toString()),
);
_preWarmCacheForTracks(tracks);
return;
case '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();
final owner = playlistInfo['owner'] as Map<String, dynamic>?;
final playlistName =
(playlistInfo['name'] ?? owner?['name']) as String?;
final coverUrl = normalizeRemoteHttpUrl(
(playlistInfo['images'] ?? owner?['images'])?.toString(),
);
state = TrackState(
tracks: tracks,
isLoading: false,
playlistName: playlistName,
coverUrl: coverUrl,
);
_preWarmCacheForTracks(tracks);
return;
case '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();
final topTracksList = metadata['top_tracks'] as List<dynamic>? ?? [];
final topTracks = topTracksList
.map((t) => _parseTrack(t as Map<String, dynamic>))
.toList();
state = TrackState(
tracks: [],
isLoading: false,
artistId: artistInfo['id'] as String?,
artistName: artistInfo['name'] as String?,
coverUrl: normalizeRemoteHttpUrl(artistInfo['images']?.toString()),
headerImageUrl: normalizeRemoteHttpUrl(
(artistInfo['header_image'] ?? artistInfo['cover_url'])?.toString(),
),
monthlyListeners: artistInfo['listeners'] as int?,
artistAlbums: albums,
artistTopTracks: topTracks.isNotEmpty ? topTracks : null,
);
return;
}
}
String _buildResolvedAlbumId(String providerId, String resourceId) {
if (providerId == 'deezer') {
return resourceId;
}
return '$providerId:$resourceId';
}
Future<void> search(
String query, {
String? filterOverride,
@@ -609,19 +537,15 @@ class TrackNotifier extends Notifier<TrackState> {
.map((ext) => ext.id)
.firstOrNull;
}
resolvedProvider ??= 'tidal';
resolvedProvider ??= defaultBuiltInSearchProviderId;
}
final isEnabledExtensionProvider =
if (resolvedProvider != null &&
resolvedProvider.isNotEmpty &&
extensionState.extensions.any(
!isBuiltInSearchProvider(resolvedProvider) &&
!extensionState.extensions.any(
(ext) => ext.enabled && ext.id == resolvedProvider,
);
if (resolvedProvider.isNotEmpty &&
resolvedProvider != 'tidal' &&
resolvedProvider != 'qobuz' &&
!isEnabledExtensionProvider &&
) &&
settings.searchProvider?.trim() == resolvedProvider) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
resolvedProvider =
@@ -638,15 +562,21 @@ class TrackNotifier extends Notifier<TrackState> {
.where((ext) => ext.enabled && ext.hasCustomSearch)
.map((ext) => ext.id)
.firstOrNull;
resolvedProvider ??= 'tidal';
resolvedProvider ??= defaultBuiltInSearchProviderId;
}
if (resolvedProvider.isNotEmpty &&
resolvedProvider != 'tidal' &&
resolvedProvider != 'qobuz' &&
final isEnabledExtensionProvider =
resolvedProvider != null &&
resolvedProvider.isNotEmpty &&
extensionState.extensions.any(
(ext) => ext.enabled && ext.id == resolvedProvider,
)) {
);
final isBuiltInProvider = isBuiltInSearchProvider(resolvedProvider);
if (resolvedProvider != null &&
resolvedProvider.isNotEmpty &&
!isBuiltInProvider &&
isEnabledExtensionProvider) {
final resolvedFilter = requestFilter ?? 'track';
Map<String, dynamic>? options;
options = {'filter': resolvedFilter};
@@ -659,12 +589,12 @@ class TrackNotifier extends Notifier<TrackState> {
return;
}
final effectiveBuiltInProvider =
resolvedProvider == 'tidal' || resolvedProvider == 'qobuz'
final fallbackBuiltInProvider = builtInSearchProvider?.isNotEmpty == true
? builtInSearchProvider
: defaultBuiltInSearchProviderId;
final effectiveBuiltInProvider = isBuiltInProvider
? resolvedProvider
: (builtInSearchProvider?.isNotEmpty == true
? builtInSearchProvider
: 'tidal');
: fallbackBuiltInProvider;
if (effectiveBuiltInProvider == null || effectiveBuiltInProvider.isEmpty) {
state = TrackState(
@@ -700,40 +630,29 @@ class TrackNotifier extends Notifier<TrackState> {
Map<String, dynamic> results;
List<Map<String, dynamic>> metadataTrackResults = [];
switch (effectiveProvider) {
case 'tidal':
_log.d('Calling Tidal search API...');
results = await PlatformBridge.searchTidalAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: requestFilter,
);
break;
case 'qobuz':
_log.d('Calling Qobuz search API...');
results = await PlatformBridge.searchQobuzAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: requestFilter,
);
break;
default:
_log.d('Calling metadata provider track search API...');
metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
results = const <String, List<dynamic>>{
'tracks': <dynamic>[],
'artists': <dynamic>[],
'albums': <dynamic>[],
'playlists': <dynamic>[],
};
break;
if (isBuiltInSearchProvider(effectiveProvider)) {
_log.d('Calling built-in search API for $effectiveProvider...');
results = await PlatformBridge.searchProviderAll(
effectiveProvider,
query,
trackLimit: 20,
artistLimit: 2,
filter: requestFilter,
);
} else {
_log.d('Calling metadata provider track search API...');
metadataTrackResults =
await PlatformBridge.searchTracksWithMetadataProviders(
query,
limit: 20,
includeExtensions: includeExtensions,
);
results = const <String, List<dynamic>>{
'tracks': <dynamic>[],
'artists': <dynamic>[],
'albums': <dynamic>[],
'playlists': <dynamic>[],
};
}
_log.i(
'$effectiveProvider returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
+24 -82
View File
@@ -176,83 +176,12 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
Future<void> _fetchTracks() async {
setState(() => _isLoading = true);
try {
if (widget.albumId.startsWith('deezer:')) {
final deezerAlbumId = widget.albumId.replaceFirst('deezer:', '');
final metadata = await PlatformBridge.getDeezerMetadata(
final directProviderId = _directMetadataProviderId();
if (directProviderId != null) {
final metadata = await PlatformBridge.getProviderMetadata(
directProviderId,
'album',
deezerAlbumId,
);
final trackList = metadata['track_list'] as List<dynamic>;
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false;
});
}
return;
} else if (widget.albumId.startsWith('qobuz:')) {
final qobuzAlbumId = widget.albumId.replaceFirst('qobuz:', '');
final metadata = await PlatformBridge.getQobuzMetadata(
'album',
qobuzAlbumId,
);
final trackList = metadata['track_list'] as List<dynamic>;
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
final artistId = (albumInfo?['artist_id'] ?? albumInfo?['artistId'])
?.toString();
final albumType = normalizeOptionalString(
albumInfo?['album_type']?.toString(),
);
final totalTracks = albumInfo?['total_tracks'] as int?;
final tracks = trackList
.map(
(t) => _parseTrack(
t as Map<String, dynamic>,
albumTypeFallback: albumType,
totalTracksFallback: totalTracks,
),
)
.toList();
_AlbumCache.set(widget.albumId, tracks);
if (mounted) {
setState(() {
_tracks = tracks;
_artistId = artistId;
_albumType = albumType;
_albumTotalTracks = totalTracks;
_isLoading = false;
});
}
return;
} else if (widget.albumId.startsWith('tidal:')) {
final tidalAlbumId = widget.albumId.replaceFirst('tidal:', '');
final metadata = await PlatformBridge.getTidalMetadata(
'album',
tidalAlbumId,
_metadataResourceId(directProviderId),
);
final trackList = metadata['track_list'] as List<dynamic>;
final albumInfo = metadata['album_info'] as Map<String, dynamic>?;
@@ -332,6 +261,24 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
}
String? _directMetadataProviderId() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
return widget.extensionId;
}
if (widget.albumId.startsWith('deezer:')) return 'deezer';
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
if (widget.albumId.startsWith('tidal:')) return 'tidal';
return null;
}
String _metadataResourceId(String providerId) {
final prefixed = '$providerId:';
if (widget.albumId.startsWith(prefixed)) {
return widget.albumId.substring(prefixed.length);
}
return widget.albumId;
}
Track _parseTrack(
Map<String, dynamic> data, {
String? albumTypeFallback,
@@ -366,12 +313,7 @@ class _AlbumScreenState extends ConsumerState<AlbumScreen> {
}
String? _recommendedDownloadService() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
return widget.extensionId;
}
if (widget.albumId.startsWith('tidal:')) return 'tidal';
if (widget.albumId.startsWith('qobuz:')) return 'qobuz';
return null;
return _directMetadataProviderId();
}
@override
+33 -114
View File
@@ -154,14 +154,27 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
String? _recommendedDownloadService() {
return _directMetadataProviderId();
}
String? _directMetadataProviderId() {
if (widget.extensionId != null && widget.extensionId!.isNotEmpty) {
return widget.extensionId;
}
if (widget.artistId.startsWith('tidal:')) return 'tidal';
if (widget.artistId.startsWith('deezer:')) return 'deezer';
if (widget.artistId.startsWith('qobuz:')) return 'qobuz';
if (widget.artistId.startsWith('tidal:')) return 'tidal';
return null;
}
String _metadataResourceId(String providerId) {
final prefixed = '$providerId:';
if (widget.artistId.startsWith(prefixed)) {
return widget.artistId.substring(prefixed.length);
}
return widget.artistId;
}
@override
void initState() {
super.initState();
@@ -250,51 +263,13 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
String? headerImage;
int? listeners;
if (widget.artistId.startsWith('deezer:')) {
final deezerArtistId = widget.artistId.replaceFirst('deezer:', '');
final metadata = await PlatformBridge.getDeezerMetadata(
if (_directMetadataProviderId() != null) {
final providerId = _directMetadataProviderId()!;
final artistData = await PlatformBridge.getProviderMetadata(
providerId,
'artist',
deezerArtistId,
_metadataResourceId(providerId),
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
} else if (widget.artistId.startsWith('qobuz:')) {
final qobuzArtistId = widget.artistId.replaceFirst('qobuz:', '');
final metadata = await PlatformBridge.getQobuzMetadata(
'artist',
qobuzArtistId,
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.toList();
final artistInfo = metadata['artist_info'] as Map<String, dynamic>?;
headerImage = artistInfo?['images'] as String?;
} else if (widget.artistId.startsWith('tidal:')) {
final tidalArtistId = widget.artistId.replaceFirst('tidal:', '');
final metadata = await PlatformBridge.getTidalMetadata(
'artist',
tidalArtistId,
);
final albumsList = metadata['albums'] as List<dynamic>;
albums = albumsList
.map((a) => _parseArtistAlbum(a as Map<String, dynamic>))
.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>))
@@ -314,11 +289,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
.toList();
}
final artistInfo = artistData['artist_info'] as Map<String, dynamic>?;
headerImage =
artistInfo?['images'] as String? ??
artistInfo?['header_image'] as String? ??
artistInfo?['cover_url'] as String? ??
artistData['header_image'] as String? ??
artistData['cover_url'] as String? ??
artistData['image_url'] as String?;
listeners = artistData['listeners'] as int?;
listeners =
artistInfo?['listeners'] as int? ?? artistData['listeners'] as int?;
} else {
final url = 'https://open.spotify.com/artist/${widget.artistId}';
final result = await PlatformBridge.handleURLWithExtension(url);
@@ -1051,42 +1031,16 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
}
Future<List<Track>> _fetchAlbumTracks(ArtistAlbum album) async {
if (album.providerId != null && album.providerId!.isNotEmpty) {
final result = await PlatformBridge.getAlbumWithExtension(
album.providerId!,
album.id,
);
if (result != null && result['tracks'] != null) {
final tracksList = result['tracks'] as List<dynamic>;
final parsedTracks = tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
return parsedTracks;
}
} else if (album.id.startsWith('deezer:')) {
final deezerId = album.id.replaceFirst('deezer:', '');
final metadata = await PlatformBridge.getDeezerMetadata(
final providerId = album.providerId;
if (providerId != null && providerId.isNotEmpty) {
final resourceId = album.id.startsWith('$providerId:')
? album.id.substring(providerId.length + 1)
: album.id;
final metadata = await PlatformBridge.getProviderMetadata(
providerId,
'album',
deezerId,
resourceId,
);
if (metadata['tracks'] != null) {
final tracksList = metadata['tracks'] as List<dynamic>;
return tracksList
.map((t) => _parseTrackFromDeezer(t as Map<String, dynamic>, album))
.toList();
}
} else if (album.id.startsWith('qobuz:')) {
final qobuzId = album.id.replaceFirst('qobuz:', '');
final metadata = await PlatformBridge.getQobuzMetadata('album', qobuzId);
if (metadata['track_list'] != null) {
final tracksList = metadata['track_list'] as List<dynamic>;
return tracksList
.map((t) => _parseTrack(t as Map<String, dynamic>, album: album))
.toList();
}
} else if (album.id.startsWith('tidal:')) {
final tidalId = album.id.replaceFirst('tidal:', '');
final metadata = await PlatformBridge.getTidalMetadata('album', tidalId);
if (metadata['track_list'] != null) {
final tracksList = metadata['track_list'] as List<dynamic>;
return tracksList
@@ -1106,41 +1060,6 @@ class _ArtistScreenState extends ConsumerState<ArtistScreen> {
return [];
}
Track _parseTrackFromDeezer(Map<String, dynamic> data, ArtistAlbum album) {
int durationMs = 0;
final durationValue = data['duration'];
final artistData = data['artist'];
final artistName = artistData is Map<String, dynamic>
? (artistData['name'] as String? ?? widget.artistName)
: (artistData?.toString() ?? widget.artistName);
if (durationValue is int) {
durationMs = durationValue * 1000; // Deezer returns seconds
} else if (durationValue is double) {
durationMs = (durationValue * 1000).toInt();
}
return Track(
id: 'deezer:${data['id']}',
name: (data['title'] ?? data['name'] ?? '').toString(),
artistName: artistName,
albumName: album.name,
albumArtist: null,
artistId: widget.artistId,
albumId: album.id.isNotEmpty ? album.id : null,
coverUrl: album.coverUrl,
isrc: data['isrc']?.toString(),
duration: (durationMs / 1000).round(),
trackNumber:
data['track_position'] as int? ?? data['track_number'] as int?,
discNumber: data['disk_number'] as int? ?? data['disc_number'] as int?,
totalDiscs: data['total_discs'] as int?,
releaseDate: album.releaseDate,
albumType: album.albumType,
totalTracks: album.totalTracks,
composer: data['composer']?.toString(),
);
}
Widget _buildHeader(
BuildContext context,
ColorScheme colorScheme, {
+71 -104
View File
@@ -31,6 +31,7 @@ import 'package:spotiflac_android/widgets/download_service_picker.dart';
import 'package:spotiflac_android/widgets/track_collection_quick_actions.dart';
import 'package:spotiflac_android/widgets/animation_utils.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
class HomeTab extends ConsumerStatefulWidget {
const HomeTab({super.key});
@@ -481,13 +482,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
final explicit = explicitSearchProvider?.trim();
if (explicit != null &&
explicit.isNotEmpty &&
(_builtInSearchProviders.contains(explicit) ||
(isBuiltInSearchProvider(explicit) ||
extensions.any(
(ext) => ext.enabled && ext.hasCustomSearch && ext.id == explicit,
))) {
return explicit;
}
return _defaultSearchExtension(extensions)?.id ?? 'tidal';
return _defaultSearchExtension(extensions)?.id ??
defaultBuiltInSearchProviderId;
}
String? _sanitizeSearchFilterForProvider(
@@ -503,7 +505,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (currentSearchProvider == null ||
currentSearchProvider.isEmpty ||
_builtInSearchProviders.contains(currentSearchProvider)) {
isBuiltInSearchProvider(currentSearchProvider)) {
switch (canonicalFilter) {
case 'track':
case 'artist':
@@ -695,8 +697,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (searchProvider == null || searchProvider.isEmpty) return false;
// Built-in providers (tidal, qobuz) also support live search
if (_builtInSearchProviders.contains(searchProvider)) return true;
if (isBuiltInSearchProvider(searchProvider)) return true;
final extension = extState.extensions
.where((e) => e.id == searchProvider && e.enabled)
@@ -755,8 +756,6 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
}
static const _builtInSearchProviders = {'tidal', 'qobuz'};
Future<void> _performSearch(String query, {String? filterOverride}) async {
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
@@ -795,8 +794,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
_invalidateSearchSortCaches();
final isBuiltInProvider =
searchProvider != null &&
_builtInSearchProviders.contains(searchProvider);
searchProvider != null && isBuiltInSearchProvider(searchProvider);
final isExtensionEnabled =
searchProvider != null &&
@@ -3347,11 +3345,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
if (searchProvider != null && searchProvider.isNotEmpty) {
if (searchProvider == 'tidal') {
return 'Search with Tidal...';
}
if (searchProvider == 'qobuz') {
return 'Search with Qobuz...';
final builtIn = builtInProviderSpecForId(searchProvider);
if (builtIn != null && builtIn.supportsSearch) {
return 'Search with ${builtIn.displayName}...';
}
final ext = extState.extensions
@@ -3566,16 +3562,19 @@ class _SearchProviderDropdown extends ConsumerWidget {
final searchProviders = extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
.toList();
final builtInProviders = builtInSearchProviderSpecs;
final primarySearchExtension = _defaultSearchExtension(searchProviders);
final defaultProviderTarget =
primarySearchExtension?.displayName ?? 'Tidal';
primarySearchExtension?.displayName ??
defaultBuiltInSearchProviderDisplayName ??
context.l10n.extensionDefaultProvider;
final defaultProviderLabel =
'${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)';
final defaultProviderIconPath = primarySearchExtension?.iconPath;
final currentProvider =
rawCurrentProvider != null &&
rawCurrentProvider.isNotEmpty &&
({'tidal', 'qobuz'}.contains(rawCurrentProvider) ||
(isBuiltInSearchProvider(rawCurrentProvider) ||
searchProviders.any((e) => e.id == rawCurrentProvider))
? rawCurrentProvider
: null;
@@ -3587,9 +3586,8 @@ class _SearchProviderDropdown extends ConsumerWidget {
.firstOrNull;
}
const builtInProviders = {'tidal', 'qobuz'};
final isBuiltInProvider =
currentProvider != null && builtInProviders.contains(currentProvider);
currentProvider != null && isBuiltInSearchProvider(currentProvider);
IconData displayIcon = Icons.search;
String? iconPath;
@@ -3612,7 +3610,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
);
}
} else if (isBuiltInProvider) {
displayIcon = Icons.music_note;
displayIcon = resolveProviderIcon(currentProvider);
}
return Padding(
@@ -3679,58 +3677,33 @@ class _SearchProviderDropdown extends ConsumerWidget {
],
),
),
PopupMenuItem<String>(
value: 'tidal',
child: Row(
children: [
Icon(
Icons.music_note,
size: 20,
color: currentProvider == 'tidal'
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Tidal',
style: TextStyle(
fontWeight: currentProvider == 'tidal'
? FontWeight.w600
: FontWeight.normal,
...builtInProviders.map(
(provider) => PopupMenuItem<String>(
value: provider.id,
child: Row(
children: [
Icon(
resolveProviderIcon(provider.id),
size: 20,
color: currentProvider == provider.id
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
provider.displayName,
style: TextStyle(
fontWeight: currentProvider == provider.id
? FontWeight.w600
: FontWeight.normal,
),
),
),
),
if (currentProvider == 'tidal')
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
),
),
PopupMenuItem<String>(
value: 'qobuz',
child: Row(
children: [
Icon(
Icons.music_note,
size: 20,
color: currentProvider == 'qobuz'
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Qobuz',
style: TextStyle(
fontWeight: currentProvider == 'qobuz'
? FontWeight.w600
: FontWeight.normal,
),
),
),
if (currentProvider == 'qobuz')
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
if (currentProvider == provider.id)
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
),
),
),
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
@@ -4627,21 +4600,17 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
});
try {
final result = await PlatformBridge.getAlbumWithExtension(
final result = await PlatformBridge.getProviderMetadata(
widget.extensionId,
'album',
widget.albumId,
);
if (!mounted) return;
if (result == null) {
setState(() {
_error = context.l10n.errorLoadAlbum;
_isLoading = false;
});
return;
}
final trackList = result['tracks'] as List<dynamic>?;
final albumInfo = result['album_info'] as Map<String, dynamic>? ?? result;
final trackList =
result['track_list'] as List<dynamic>? ??
result['tracks'] as List<dynamic>?;
if (trackList == null) {
setState(() {
_error = context.l10n.errorNoTracksFound;
@@ -4650,12 +4619,15 @@ class _ExtensionAlbumScreenState extends ConsumerState<ExtensionAlbumScreen> {
return;
}
final artistId = (result['artist_id'] ?? result['artistId'])?.toString();
final artistName = result['artists'] as String?;
final artistId = (albumInfo['artist_id'] ?? albumInfo['artistId'])
?.toString();
final artistName = (albumInfo['artists'] ?? albumInfo['artist'])
?.toString();
final albumType =
normalizeOptionalString(result['album_type']?.toString()) ??
normalizeOptionalString(albumInfo['album_type']?.toString()) ??
_albumType;
final totalTracks = result['total_tracks'] as int? ?? _albumTotalTracks;
final totalTracks =
albumInfo['total_tracks'] as int? ?? _albumTotalTracks;
final tracks = trackList
.map(
(t) => _parseTrack(
@@ -4818,21 +4790,16 @@ class _ExtensionPlaylistScreenState
});
try {
final result = await PlatformBridge.getPlaylistWithExtension(
final result = await PlatformBridge.getProviderMetadata(
widget.extensionId,
'playlist',
widget.playlistId,
);
if (!mounted) return;
if (result == null) {
setState(() {
_error = context.l10n.errorLoadPlaylist;
_isLoading = false;
});
return;
}
final trackList = result['tracks'] as List<dynamic>?;
final trackList =
result['track_list'] as List<dynamic>? ??
result['tracks'] as List<dynamic>?;
if (trackList == null) {
setState(() {
_error = context.l10n.errorNoTracksFound;
@@ -4975,20 +4942,15 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
});
try {
final result = await PlatformBridge.getArtistWithExtension(
final result = await PlatformBridge.getProviderMetadata(
widget.extensionId,
'artist',
widget.artistId,
);
if (!mounted) return;
if (result == null) {
setState(() {
_error = context.l10n.errorLoadArtist;
_isLoading = false;
});
return;
}
final artistInfo =
result['artist_info'] as Map<String, dynamic>? ?? result;
final albumList = result['albums'] as List<dynamic>?;
final albums =
albumList
@@ -5004,8 +4966,13 @@ class _ExtensionArtistScreenState extends ConsumerState<ExtensionArtistScreen> {
.toList();
}
final headerImage = result['header_image'] as String?;
final listeners = result['listeners'] as int?;
final headerImage =
artistInfo['images'] as String? ??
artistInfo['header_image'] as String? ??
artistInfo['cover_url'] as String? ??
result['header_image'] as String?;
final listeners =
artistInfo['listeners'] as int? ?? result['listeners'] as int?;
setState(() {
_albums = albums;
+27 -10
View File
@@ -51,6 +51,21 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
String get _playlistName => _resolvedPlaylistName ?? widget.playlistName;
String? get _coverUrl => _resolvedCoverUrl ?? widget.coverUrl;
String? _metadataProviderId(String playlistId) {
if (playlistId.startsWith('deezer:')) return 'deezer';
if (playlistId.startsWith('qobuz:')) return 'qobuz';
if (playlistId.startsWith('tidal:')) return 'tidal';
return null;
}
String _metadataResourceId(String providerId, String playlistId) {
final prefixed = '$providerId:';
if (playlistId.startsWith(prefixed)) {
return playlistId.substring(prefixed.length);
}
return playlistId;
}
String? _recommendedDownloadService() {
final explicit = widget.recommendedService;
if (explicit != null && explicit.isNotEmpty) {
@@ -99,17 +114,19 @@ class _PlaylistScreenState extends ConsumerState<PlaylistScreen> {
try {
String playlistId = widget.playlistId!;
late final Map<String, dynamic> result;
if (playlistId.startsWith('deezer:')) {
playlistId = playlistId.substring(7);
result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
} else if (playlistId.startsWith('qobuz:')) {
playlistId = playlistId.substring(6);
result = await PlatformBridge.getQobuzMetadata('playlist', playlistId);
} else if (playlistId.startsWith('tidal:')) {
playlistId = playlistId.substring(6);
result = await PlatformBridge.getTidalMetadata('playlist', playlistId);
final providerId = _metadataProviderId(playlistId);
if (providerId != null) {
result = await PlatformBridge.getProviderMetadata(
providerId,
'playlist',
_metadataResourceId(providerId, playlistId),
);
} else {
result = await PlatformBridge.getDeezerMetadata('playlist', playlistId);
result = await PlatformBridge.getProviderMetadata(
'deezer',
'playlist',
playlistId,
);
}
if (!mounted) return;
@@ -12,6 +12,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
import 'package:spotiflac_android/screens/settings/lyrics_provider_priority_page.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
@@ -24,7 +25,6 @@ class DownloadSettingsPage extends ConsumerStatefulWidget {
}
class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
static const _builtInServices = ['tidal', 'qobuz'];
static const _songLinkRegions = [
'AD',
'AE',
@@ -300,7 +300,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
final colorScheme = Theme.of(context).colorScheme;
final topPadding = normalizedHeaderTopPadding(context);
final isBuiltInService = _builtInServices.contains(settings.defaultService);
final isBuiltInService = isBuiltInDownloadProvider(settings.defaultService);
final isTidalService = settings.defaultService == 'tidal';
return PopScope(
@@ -2053,13 +2053,13 @@ class _ServiceSelector extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final extState = ref.watch(extensionProvider);
final builtInServiceIds = ['tidal', 'qobuz'];
final builtInProviders = builtInDownloadProviderSpecs;
final extensionProviders = extState.extensions
.where((e) => e.enabled && e.hasDownloadProvider)
.toList();
final isExtensionService = !builtInServiceIds.contains(currentService);
final isExtensionService = !isBuiltInDownloadProvider(currentService);
final isCurrentExtensionEnabled = isExtensionService
? extensionProviders.any((e) => e.id == currentService)
: true;
@@ -2070,25 +2070,17 @@ class _ServiceSelector extends ConsumerWidget {
padding: const EdgeInsets.all(12),
child: Column(
children: [
Row(
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Expanded(
child: _ServiceChip(
icon: Icons.music_note,
label: 'Tidal',
isSelected: effectiveService == 'tidal',
onTap: () => onChanged('tidal'),
for (final provider in builtInProviders)
_ServiceChip(
icon: resolveProviderIcon(provider.id),
label: provider.displayName,
isSelected: effectiveService == provider.id,
onTap: () => onChanged(provider.id),
),
),
const SizedBox(width: 8),
Expanded(
child: _ServiceChip(
icon: Icons.album,
label: 'Qobuz',
isSelected: effectiveService == 'qobuz',
onTap: () => onChanged('qobuz'),
),
),
],
),
if (extensionProviders.isNotEmpty) ...[
+15 -11
View File
@@ -660,26 +660,27 @@ class _DownloadFallbackItem extends ConsumerWidget {
class _SearchProviderSelector extends ConsumerWidget {
const _SearchProviderSelector();
static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'};
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
final colorScheme = Theme.of(context).colorScheme;
final builtInProviders = builtInSearchProviderSpecs;
final searchProviders = extState.extensions
.where((e) => e.enabled && e.hasCustomSearch)
.toList();
final hasAnyProvider =
searchProviders.isNotEmpty || _builtInProviders.isNotEmpty;
searchProviders.isNotEmpty || builtInProviders.isNotEmpty;
String currentProviderName = context.l10n.extensionDefaultProvider;
if (settings.searchProvider != null &&
settings.searchProvider!.isNotEmpty) {
if (_builtInProviders.containsKey(settings.searchProvider)) {
currentProviderName = _builtInProviders[settings.searchProvider]!;
if (isBuiltInSearchProvider(settings.searchProvider)) {
currentProviderName = resolveProviderDisplayName(
settings.searchProvider!,
);
} else {
final ext = searchProviders
.where((e) => e.id == settings.searchProvider)
@@ -754,6 +755,7 @@ class _SearchProviderSelector extends ConsumerWidget {
List<Extension> searchProviders,
) {
final colorScheme = Theme.of(context).colorScheme;
final builtInProviders = builtInSearchProviderSpecs;
showModalBottomSheet<void>(
context: context,
@@ -800,18 +802,20 @@ class _SearchProviderSelector extends ConsumerWidget {
Navigator.pop(ctx);
},
),
..._builtInProviders.entries.map(
(entry) => ListTile(
...builtInProviders.map(
(provider) => ListTile(
leading: Icon(Icons.search, color: colorScheme.tertiary),
title: Text(entry.value),
subtitle: Text(ctx.l10n.extensionsSearchWith(entry.value)),
trailing: settings.searchProvider == entry.key
title: Text(provider.displayName),
subtitle: Text(
ctx.l10n.extensionsSearchWith(provider.displayName),
),
trailing: settings.searchProvider == provider.id
? Icon(Icons.check_circle, color: colorScheme.primary)
: Icon(Icons.circle_outlined, color: colorScheme.outline),
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider(entry.key);
.setSearchProvider(provider.id);
Navigator.pop(ctx);
},
),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
import 'package:spotiflac_android/widgets/priority_settings_scaffold.dart';
class MetadataProviderPriorityPage extends ConsumerStatefulWidget {
@@ -220,36 +221,34 @@ class _MetadataProviderItem extends StatelessWidget {
BuildContext context,
String provider,
) {
switch (provider) {
case 'deezer':
return _MetadataProviderInfo(
name: 'Deezer',
icon: Icons.album,
description: context.l10n.providerExtension,
isBuiltIn: false,
);
case 'qobuz':
return _MetadataProviderInfo(
name: 'Qobuz',
icon: Icons.library_music,
description: context.l10n.providerBuiltIn,
isBuiltIn: true,
);
case 'tidal':
return _MetadataProviderInfo(
name: 'Tidal',
icon: Icons.music_note,
description: context.l10n.providerBuiltIn,
isBuiltIn: true,
);
default:
return _MetadataProviderInfo(
name: provider,
icon: Icons.extension,
description: context.l10n.providerExtension,
isBuiltIn: false,
);
final builtIn = builtInProviderSpecForId(provider);
if (builtIn != null) {
return _MetadataProviderInfo(
name: builtIn.displayName,
icon: resolveProviderIcon(
provider,
builtInDefaultIcon: Icons.library_music,
),
description: context.l10n.providerBuiltIn,
isBuiltIn: true,
);
}
if (provider == 'deezer') {
return _MetadataProviderInfo(
name: 'Deezer',
icon: Icons.album,
description: context.l10n.providerExtension,
isBuiltIn: false,
);
}
return _MetadataProviderInfo(
name: provider,
icon: Icons.extension,
description: context.l10n.providerExtension,
isBuiltIn: false,
);
}
}
+29 -41
View File
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/artist_utils.dart';
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
class OptionsSettingsPage extends ConsumerWidget {
@@ -717,8 +718,6 @@ class _ChannelChip extends StatelessWidget {
class _MetadataSourceSelector extends ConsumerWidget {
const _MetadataSourceSelector();
static const _builtInProviders = {'tidal': 'Tidal', 'qobuz': 'Qobuz'};
Extension? _defaultSearchExtension(List<Extension> extensions) {
return extensions
.where(
@@ -738,12 +737,15 @@ class _MetadataSourceSelector extends ConsumerWidget {
final colorScheme = Theme.of(context).colorScheme;
final settings = ref.watch(settingsProvider);
final extState = ref.watch(extensionProvider);
final builtInProviders = builtInSearchProviderSpecs;
final rawSearchProvider = settings.searchProvider?.trim() ?? '';
final isValidBuiltIn = _builtInProviders.containsKey(rawSearchProvider);
final isValidBuiltIn = isBuiltInSearchProvider(rawSearchProvider);
final primarySearchExtension = _defaultSearchExtension(extState.extensions);
final defaultProviderTarget =
primarySearchExtension?.displayName ?? 'Tidal';
primarySearchExtension?.displayName ??
defaultBuiltInSearchProviderDisplayName ??
context.l10n.extensionDefaultProvider;
final defaultProviderLabel =
'${context.l10n.extensionsHomeFeedAuto} ($defaultProviderTarget)';
final searchProvider =
@@ -754,7 +756,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
)
? rawSearchProvider
: '';
final isBuiltIn = _builtInProviders.containsKey(searchProvider);
final isBuiltIn = isBuiltInSearchProvider(searchProvider);
Extension? activeExtension;
if (searchProvider.isNotEmpty && !isBuiltIn) {
@@ -766,7 +768,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
String subtitle;
if (isBuiltIn) {
subtitle = 'Using ${_builtInProviders[searchProvider]}';
subtitle = 'Using ${resolveProviderDisplayName(searchProvider)}';
} else if (activeExtension != null) {
subtitle = context.l10n.optionsUsingExtension(
activeExtension.displayName,
@@ -796,48 +798,34 @@ class _MetadataSourceSelector extends ConsumerWidget {
),
),
const SizedBox(height: 16),
Row(
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Expanded(
child: _SourceChip(
icon: Icons.graphic_eq,
label: defaultProviderLabel,
isSelected: searchProvider.isEmpty,
onTap: () {
if (hasNonDefaultProvider) {
ref
.read(settingsProvider.notifier)
.setSearchProvider(null);
}
},
),
_SourceChip(
icon: Icons.graphic_eq,
label: defaultProviderLabel,
isSelected: searchProvider.isEmpty,
onTap: () {
if (hasNonDefaultProvider) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
},
),
const SizedBox(width: 8),
Expanded(
child: _SourceChip(
icon: Icons.waves,
label: 'Tidal',
isSelected: searchProvider == 'tidal',
for (final provider in builtInProviders)
_SourceChip(
icon: resolveProviderIcon(
provider.id,
tidalIcon: Icons.waves,
),
label: provider.displayName,
isSelected: searchProvider == provider.id,
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider('tidal');
.setSearchProvider(provider.id);
},
),
),
const SizedBox(width: 8),
Expanded(
child: _SourceChip(
icon: Icons.album,
label: 'Qobuz',
isSelected: searchProvider == 'qobuz',
onTap: () {
ref
.read(settingsProvider.notifier)
.setSearchProvider('qobuz');
},
),
),
],
),
if (activeExtension != null) ...[
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/utils/app_bar_layout.dart';
import 'package:spotiflac_android/utils/provider_ui_utils.dart';
class ProviderPriorityPage extends ConsumerStatefulWidget {
const ProviderPriorityPage({super.key});
@@ -325,28 +326,28 @@ class _ProviderItem extends StatelessWidget {
}
_ProviderInfo _getProviderInfo(String provider) {
switch (provider) {
case 'tidal':
return _ProviderInfo(
name: 'Tidal',
icon: Icons.music_note,
isBuiltIn: true,
);
case 'qobuz':
return _ProviderInfo(name: 'Qobuz', icon: Icons.album, isBuiltIn: true);
case 'deezer':
return _ProviderInfo(
name: 'Deezer',
icon: Icons.graphic_eq,
isBuiltIn: true,
);
default:
return _ProviderInfo(
name: provider,
icon: Icons.extension,
isBuiltIn: false,
);
final builtIn = builtInProviderSpecForId(provider);
if (builtIn != null) {
return _ProviderInfo(
name: builtIn.displayName,
icon: resolveProviderIcon(provider),
isBuiltIn: true,
);
}
if (provider == 'deezer') {
return _ProviderInfo(
name: 'Deezer',
icon: Icons.graphic_eq,
isBuiltIn: false,
);
}
return _ProviderInfo(
name: provider,
icon: Icons.extension,
isBuiltIn: false,
);
}
}
+4 -2
View File
@@ -5040,7 +5040,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
final deezerId = _extractRawDeezerTrackIdFromValue(sourceTrackId);
if (deezerId != null) {
final deezerTrack = await PlatformBridge.getDeezerMetadata(
final deezerTrack = await PlatformBridge.getProviderMetadata(
'deezer',
'track',
deezerId,
);
@@ -5291,7 +5292,8 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
(enriched['isrc'] ?? '').trim().isEmpty &&
deezerId != null) {
try {
final deezerMeta = await PlatformBridge.getDeezerMetadata(
final deezerMeta = await PlatformBridge.getProviderMetadata(
'deezer',
'track',
deezerId,
);
+32 -96
View File
@@ -496,28 +496,15 @@ class PlatformBridge {
await _channel.invokeMethod('clearTrackCache');
}
static Future<Map<String, dynamic>> searchTidalAll(
static Future<Map<String, dynamic>> searchProviderAll(
String providerId,
String query, {
int trackLimit = 15,
int artistLimit = 2,
String? filter,
}) async {
final result = await _channel.invokeMethod('searchTidalAll', {
'query': query,
'track_limit': trackLimit,
'artist_limit': artistLimit,
'filter': filter ?? '',
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> searchQobuzAll(
String query, {
int trackLimit = 15,
int artistLimit = 2,
String? filter,
}) async {
final result = await _channel.invokeMethod('searchQobuzAll', {
final result = await _channel.invokeMethod('searchProviderAll', {
'provider_id': providerId,
'query': query,
'track_limit': trackLimit,
'artist_limit': artistLimit,
@@ -537,27 +524,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getDeezerMetadata(
String resourceType,
String resourceId,
) async {
final result = await _channel.invokeMethod('getDeezerMetadata', {
'resource_type': resourceType,
'resource_id': resourceId,
});
if (result == null) {
throw Exception(
'getDeezerMetadata returned null for $resourceType:$resourceId',
);
}
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> parseDeezerUrl(String url) async {
final result = await _channel.invokeMethod('parseDeezerUrl', {'url': url});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getQobuzMetadata(
String resourceType,
String resourceId,
@@ -574,13 +540,10 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> parseQobuzUrl(String url) async {
final result = await _channel.invokeMethod('parseQobuzUrl', {'url': url});
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});
static Future<Map<String, dynamic>> parseProviderUrl(String url) async {
final result = await _channel.invokeMethod('parseProviderUrl', {
'url': url,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
@@ -600,6 +563,24 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getProviderMetadata(
String providerId,
String resourceType,
String resourceId,
) async {
final result = await _channel.invokeMethod('getProviderMetadata', {
'provider_id': providerId,
'resource_type': resourceType,
'resource_id': resourceId,
});
if (result == null) {
throw Exception(
'getProviderMetadata returned null for $providerId:$resourceType:$resourceId',
);
}
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> convertTidalToSpotifyDeezer(
String tidalUrl,
) async {
@@ -969,6 +950,12 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<List<Map<String, dynamic>>> getBuiltInProviders() async {
final result = await _channel.invokeMethod('getBuiltInProviders');
final list = jsonDecode(result as String) as List<dynamic>;
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<Map<String, dynamic>?> handleURLWithExtension(
String url,
) async {
@@ -995,57 +982,6 @@ class PlatformBridge {
return list.map((e) => e as Map<String, dynamic>).toList();
}
static Future<Map<String, dynamic>?> getAlbumWithExtension(
String extensionId,
String albumId,
) async {
try {
final result = await _channel.invokeMethod('getAlbumWithExtension', {
'extension_id': extensionId,
'album_id': albumId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getAlbumWithExtension failed: $e');
return null;
}
}
static Future<Map<String, dynamic>?> getPlaylistWithExtension(
String extensionId,
String playlistId,
) async {
try {
final result = await _channel.invokeMethod('getPlaylistWithExtension', {
'extension_id': extensionId,
'playlist_id': playlistId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getPlaylistWithExtension failed: $e');
return null;
}
}
static Future<Map<String, dynamic>?> getArtistWithExtension(
String extensionId,
String artistId,
) async {
try {
final result = await _channel.invokeMethod('getArtistWithExtension', {
'extension_id': extensionId,
'artist_id': artistId,
});
if (result == null || result == '') return null;
return jsonDecode(result as String) as Map<String, dynamic>;
} catch (e) {
_log.e('getArtistWithExtension failed: $e');
return null;
}
}
static Future<Map<String, dynamic>?> getExtensionHomeFeed(
String extensionId,
) async {
+2 -2
View File
@@ -1,6 +1,7 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
import 'package:spotiflac_android/screens/artist_screen.dart';
import 'package:spotiflac_android/screens/album_screen.dart';
import 'package:spotiflac_android/screens/home_tab.dart'
@@ -216,9 +217,8 @@ void _pushAlbumScreen(
String? coverUrl,
String? extensionId,
}) {
const builtInProviders = {'tidal', 'qobuz'};
final isExtension =
extensionId != null && !builtInProviders.contains(extensionId);
extensionId != null && !isBuiltInMetadataProvider(extensionId);
final resolvedExtensionId = extensionId;
_pushViaPreferredNavigator(
+24
View File
@@ -0,0 +1,24 @@
import 'package:flutter/material.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
IconData resolveProviderIcon(
String providerId, {
IconData tidalIcon = Icons.music_note,
IconData builtInDefaultIcon = Icons.album,
IconData deezerIcon = Icons.graphic_eq,
IconData fallbackIcon = Icons.extension,
}) {
final builtIn = builtInProviderSpecForId(providerId);
if (builtIn != null) {
if (providerId == 'tidal') {
return tidalIcon;
}
return builtInDefaultIcon;
}
if (providerId == 'deezer') {
return deezerIcon;
}
return fallbackIcon;
}