mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat: Add Deezer as alternative metadata source with auto-fallback
- Add Deezer API client (no auth required, rate limit per user IP) - Add search source selector in Settings (Deezer/Spotify) - Default to Deezer for better reliability - Auto-fallback to Deezer when Spotify API is rate limited (429) - Support fallback for tracks and albums via SongLink API - Update README with metadata source documentation - Version 2.1.5-preview (build 42)
This commit is contained in:
17 files changed
+1211
-33
No files matched your search
@@ -1,8 +1,8 @@
|
||||
/// App version and info constants
|
||||
/// Update version here only - all other files will reference this
|
||||
class AppInfo {
|
||||
static const String version = '2.1.0';
|
||||
static const String buildNumber = '41';
|
||||
static const String version = '2.1.5-preview';
|
||||
static const String buildNumber = '42';
|
||||
static const String fullVersion = '$version+$buildNumber';
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ class AppSettings {
|
||||
final String spotifyClientId; // Custom Spotify client ID (empty = use default)
|
||||
final String spotifyClientSecret; // Custom Spotify client secret (empty = use default)
|
||||
final bool useCustomSpotifyCredentials; // Whether to use custom credentials (if set)
|
||||
final String metadataSource; // spotify, deezer - source for search and metadata
|
||||
|
||||
const AppSettings({
|
||||
this.defaultService = 'tidal',
|
||||
@@ -42,6 +43,7 @@ class AppSettings {
|
||||
this.spotifyClientId = '', // Default: use built-in credentials
|
||||
this.spotifyClientSecret = '', // Default: use built-in credentials
|
||||
this.useCustomSpotifyCredentials = true, // Default: use custom if set
|
||||
this.metadataSource = 'deezer', // Default: Deezer (no rate limit)
|
||||
});
|
||||
|
||||
AppSettings copyWith({
|
||||
@@ -63,6 +65,7 @@ class AppSettings {
|
||||
String? spotifyClientId,
|
||||
String? spotifyClientSecret,
|
||||
bool? useCustomSpotifyCredentials,
|
||||
String? metadataSource,
|
||||
}) {
|
||||
return AppSettings(
|
||||
defaultService: defaultService ?? this.defaultService,
|
||||
@@ -83,6 +86,7 @@ class AppSettings {
|
||||
spotifyClientId: spotifyClientId ?? this.spotifyClientId,
|
||||
spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret,
|
||||
useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials,
|
||||
metadataSource: metadataSource ?? this.metadataSource,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
|
||||
useCustomSpotifyCredentials:
|
||||
json['useCustomSpotifyCredentials'] as bool? ?? true,
|
||||
metadataSource: json['metadataSource'] as String? ?? 'deezer',
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
@@ -48,4 +49,5 @@ Map<String, dynamic> _$AppSettingsToJson(AppSettings instance) =>
|
||||
'spotifyClientId': instance.spotifyClientId,
|
||||
'spotifyClientSecret': instance.spotifyClientSecret,
|
||||
'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials,
|
||||
'metadataSource': instance.metadataSource,
|
||||
};
|
||||
@@ -156,6 +156,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
_applySpotifyCredentials();
|
||||
}
|
||||
|
||||
void setMetadataSource(String source) {
|
||||
state = state.copyWith(metadataSource: source);
|
||||
_saveSettings();
|
||||
}
|
||||
}
|
||||
|
||||
final settingsProvider = NotifierProvider<SettingsNotifier, AppSettings>(
|
||||
|
||||
@@ -114,7 +114,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
/// Check if request is still valid (not cancelled by newer request)
|
||||
bool _isRequestValid(int requestId) => requestId == _currentRequestId;
|
||||
|
||||
Future<void> fetchFromUrl(String url) async {
|
||||
Future<void> fetchFromUrl(String url, {bool useDeezerFallback = true}) async {
|
||||
// Increment request ID to cancel any pending requests
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
@@ -127,7 +127,22 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
|
||||
final type = parsed['type'] as String;
|
||||
|
||||
final metadata = await PlatformBridge.getSpotifyMetadata(url);
|
||||
// Use the new fallback-enabled method
|
||||
Map<String, dynamic> metadata;
|
||||
|
||||
try {
|
||||
// ignore: avoid_print
|
||||
print('[FetchURL] Fetching $type with Deezer fallback enabled...');
|
||||
metadata = await PlatformBridge.getSpotifyMetadataWithFallback(url);
|
||||
// ignore: avoid_print
|
||||
print('[FetchURL] Metadata fetch success');
|
||||
} catch (e) {
|
||||
// If fallback also fails, show error
|
||||
// ignore: avoid_print
|
||||
print('[FetchURL] Metadata fetch failed: $e');
|
||||
rethrow;
|
||||
}
|
||||
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
|
||||
if (type == 'track') {
|
||||
@@ -184,7 +199,7 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> search(String query) async {
|
||||
Future<void> search(String query, {String? metadataSource}) async {
|
||||
// Increment request ID to cancel any pending requests
|
||||
final requestId = ++_currentRequestId;
|
||||
|
||||
@@ -192,7 +207,24 @@ class TrackNotifier extends Notifier<TrackState> {
|
||||
state = TrackState(isLoading: true, hasSearchText: state.hasSearchText);
|
||||
|
||||
try {
|
||||
final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||
// Use Deezer or Spotify based on settings
|
||||
final source = metadataSource ?? 'deezer';
|
||||
|
||||
// Debug log to show which source is being used
|
||||
// ignore: avoid_print
|
||||
print('[Search] Using metadata source: $source for query: "$query"');
|
||||
|
||||
Map<String, dynamic> results;
|
||||
if (source == 'deezer') {
|
||||
results = await PlatformBridge.searchDeezerAll(query, trackLimit: 20, artistLimit: 5);
|
||||
// ignore: avoid_print
|
||||
print('[Search] Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
||||
} else {
|
||||
results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5);
|
||||
// ignore: avoid_print
|
||||
print('[Search] Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks');
|
||||
}
|
||||
|
||||
if (!_isRequestValid(requestId)) return; // Request cancelled
|
||||
|
||||
final trackList = results['tracks'] as List<dynamic>? ?? [];
|
||||
|
||||
@@ -38,7 +38,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
||||
if (url.startsWith('http') || url.startsWith('spotify:')) {
|
||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||
} else {
|
||||
await ref.read(trackProvider.notifier).search(url);
|
||||
final settings = ref.read(settingsProvider);
|
||||
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
if (_lastSearchQuery == query) return;
|
||||
_lastSearchQuery = query;
|
||||
|
||||
await ref.read(trackProvider.notifier).search(query);
|
||||
final settings = ref.read(settingsProvider);
|
||||
await ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
}
|
||||
|
||||
@@ -112,7 +113,8 @@ class _HomeTabState extends ConsumerState<HomeTab> with AutomaticKeepAliveClient
|
||||
await ref.read(trackProvider.notifier).fetchFromUrl(url);
|
||||
_navigateToDetailIfNeeded();
|
||||
} else {
|
||||
await ref.read(trackProvider.notifier).search(url);
|
||||
final settings = ref.read(settingsProvider);
|
||||
await ref.read(trackProvider.notifier).search(url, metadataSource: settings.metadataSource);
|
||||
}
|
||||
ref.read(settingsProvider.notifier).setHasSearchedBefore();
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
_searchController = TextEditingController(text: widget.query);
|
||||
if (widget.query.isNotEmpty) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(trackProvider.notifier).search(widget.query);
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(trackProvider.notifier).search(widget.query, metadataSource: settings.metadataSource);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -37,7 +38,8 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
|
||||
void _search() {
|
||||
final query = _searchController.text.trim();
|
||||
if (query.isNotEmpty) {
|
||||
ref.read(trackProvider.notifier).search(query);
|
||||
final settings = ref.read(settingsProvider);
|
||||
ref.read(trackProvider.notifier).search(query, metadataSource: settings.metadataSource);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,29 +119,35 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
SliverToBoxAdapter(
|
||||
child: SettingsGroup(
|
||||
children: [
|
||||
SettingsItem(
|
||||
icon: Icons.key,
|
||||
title: 'Custom Credentials',
|
||||
subtitle: settings.spotifyClientId.isNotEmpty
|
||||
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||
: 'Not configured',
|
||||
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
|
||||
trailing: settings.spotifyClientId.isNotEmpty
|
||||
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
|
||||
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
|
||||
showDivider: settings.spotifyClientId.isNotEmpty,
|
||||
_MetadataSourceSelector(
|
||||
currentSource: settings.metadataSource,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setMetadataSource(v),
|
||||
),
|
||||
if (settings.spotifyClientId.isNotEmpty)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.toggle_on,
|
||||
title: 'Use Custom Credentials',
|
||||
subtitle: settings.useCustomSpotifyCredentials
|
||||
? 'Using your credentials'
|
||||
: 'Using default credentials',
|
||||
value: settings.useCustomSpotifyCredentials,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
|
||||
showDivider: false,
|
||||
if (settings.metadataSource == 'spotify') ...[
|
||||
SettingsItem(
|
||||
icon: Icons.key,
|
||||
title: 'Custom Credentials',
|
||||
subtitle: settings.spotifyClientId.isNotEmpty
|
||||
? 'Client ID: ${settings.spotifyClientId.length > 8 ? '${settings.spotifyClientId.substring(0, 8)}...' : settings.spotifyClientId}'
|
||||
: 'Not configured',
|
||||
onTap: () => _showSpotifyCredentialsDialog(context, ref, settings),
|
||||
trailing: settings.spotifyClientId.isNotEmpty
|
||||
? Icon(Icons.edit, color: Theme.of(context).colorScheme.onSurfaceVariant, size: 20)
|
||||
: Icon(Icons.add, color: Theme.of(context).colorScheme.primary, size: 20),
|
||||
showDivider: settings.spotifyClientId.isNotEmpty,
|
||||
),
|
||||
if (settings.spotifyClientId.isNotEmpty)
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.toggle_on,
|
||||
title: 'Use Custom Credentials',
|
||||
subtitle: settings.useCustomSpotifyCredentials
|
||||
? 'Using your credentials'
|
||||
: 'Using default credentials',
|
||||
value: settings.useCustomSpotifyCredentials,
|
||||
onChanged: (v) => ref.read(settingsProvider.notifier).setUseCustomSpotifyCredentials(v),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -469,3 +475,76 @@ class _ChannelChip extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetadataSourceSelector extends StatelessWidget {
|
||||
final String currentSource;
|
||||
final ValueChanged<String> onChanged;
|
||||
const _MetadataSourceSelector({required this.currentSource, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(Icons.search, color: colorScheme.onSurfaceVariant, size: 24),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('Search Source', style: Theme.of(context).textTheme.bodyLarge),
|
||||
const SizedBox(height: 2),
|
||||
Text(currentSource == 'deezer' ? 'Deezer (no need developer account)' : 'Spotify (may hit rate limit)',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)),
|
||||
])),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
_SourceChip(label: 'Deezer', isSelected: currentSource == 'deezer', onTap: () => onChanged('deezer')),
|
||||
const SizedBox(width: 8),
|
||||
_SourceChip(label: 'Spotify', isSelected: currentSource == 'spotify', onTap: () => onChanged('spotify')),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Icon(Icons.info_outline, size: 16, color: colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text('Spotify URLs are always supported regardless of this setting',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant))),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SourceChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
const _SourceChip({required this.label, required this.isSelected, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final unselectedColor = isDark
|
||||
? Color.alphaBlend(Colors.white.withValues(alpha: 0.05), colorScheme.surface)
|
||||
: colorScheme.surfaceContainerHigh;
|
||||
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: isSelected ? colorScheme.primaryContainer : unselectedColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(child: Text(label, style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isSelected ? colorScheme.onPrimaryContainer : colorScheme.onSurfaceVariant))),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -316,4 +316,52 @@ class PlatformBridge {
|
||||
static Future<void> clearTrackCache() async {
|
||||
await _channel.invokeMethod('clearTrackCache');
|
||||
}
|
||||
|
||||
// ==================== DEEZER API ====================
|
||||
|
||||
/// Search Deezer for tracks and artists (no API key required)
|
||||
static Future<Map<String, dynamic>> searchDeezerAll(String query, {int trackLimit = 15, int artistLimit = 3}) async {
|
||||
final result = await _channel.invokeMethod('searchDeezerAll', {
|
||||
'query': query,
|
||||
'track_limit': trackLimit,
|
||||
'artist_limit': artistLimit,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get Deezer metadata by type and ID
|
||||
static Future<Map<String, dynamic>> getDeezerMetadata(String resourceType, String resourceId) async {
|
||||
final result = await _channel.invokeMethod('getDeezerMetadata', {
|
||||
'resource_type': resourceType,
|
||||
'resource_id': resourceId,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Parse Deezer URL and return type and ID
|
||||
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>;
|
||||
}
|
||||
|
||||
/// Search Deezer by ISRC
|
||||
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>;
|
||||
}
|
||||
|
||||
/// Convert Spotify track to Deezer and get metadata (for rate limit fallback)
|
||||
static Future<Map<String, dynamic>> convertSpotifyToDeezer(String resourceType, String spotifyId) async {
|
||||
final result = await _channel.invokeMethod('convertSpotifyToDeezer', {
|
||||
'resource_type': resourceType,
|
||||
'spotify_id': spotifyId,
|
||||
});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
|
||||
/// Get Spotify metadata with automatic Deezer fallback on rate limit
|
||||
static Future<Map<String, dynamic>> getSpotifyMetadataWithFallback(String url) async {
|
||||
final result = await _channel.invokeMethod('getSpotifyMetadataWithFallback', {'url': url});
|
||||
return jsonDecode(result as String) as Map<String, dynamic>;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user