refactor: remove built-in Spotify API provider, use Deezer as sole default

- Remove all Spotify credential management (client ID/secret, secure storage)
- Remove Spotify platform channel handlers from MainActivity
- Remove exported Go functions: GetSpotifyMetadata, SearchSpotify,
  SearchSpotifyAll, GetSpotifyRelatedArtists, SetSpotifyAPICredentials
- Simplify GetSpotifyMetadataWithDeezerFallback to SpotFetch-only path
- Remove Spotify built-in fallback in ReEnrichFile search pipeline
- Always return false from HasSpotifyCredentials; getCredentials always errors
- Default metadataProviderPriority is now ['deezer'] only
- Sanitize provider priority list to strip 'spotify' entries on load/save
- Add migration v5 to clear saved Spotify credentials from existing installs
- Remove Spotify source chip and credentials UI from options settings page
- Remove metadataSource param from search() — always uses Deezer
- spotify-web extension remains supported via the extension provider system
This commit is contained in:
zarzet
2026-03-11 00:58:07 +07:00
parent 76fe8dbc69
commit c2736a61fb
14 changed files with 127 additions and 787 deletions
@@ -1877,38 +1877,6 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
"getSpotifyMetadata" -> {
val url = call.argument<String>("url") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyMetadata(url)
}
result.success(response)
}
"searchSpotify" -> {
val query = call.argument<String>("query") ?: ""
val limit = call.argument<Int>("limit") ?: 10
val response = withContext(Dispatchers.IO) {
Gobackend.searchSpotify(query, limit.toLong())
}
result.success(response)
}
"searchSpotifyAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 3
val response = withContext(Dispatchers.IO) {
Gobackend.searchSpotifyAll(query, trackLimit.toLong(), artistLimit.toLong())
}
result.success(response)
}
"getSpotifyRelatedArtists" -> {
val artistId = call.argument<String>("artist_id") ?: ""
val limit = call.argument<Int>("limit") ?: 12
val response = withContext(Dispatchers.IO) {
Gobackend.getSpotifyRelatedArtists(artistId, limit.toLong())
}
result.success(response)
}
"checkAvailability" -> {
val spotifyId = call.argument<String>("spotify_id") ?: ""
val isrc = call.argument<String>("isrc") ?: ""
@@ -2542,20 +2510,6 @@ class MainActivity: FlutterFragmentActivity() {
"isDownloadServiceRunning" -> {
result.success(DownloadService.isServiceRunning())
}
"setSpotifyCredentials" -> {
val clientId = call.argument<String>("client_id") ?: ""
val clientSecret = call.argument<String>("client_secret") ?: ""
withContext(Dispatchers.IO) {
Gobackend.setSpotifyAPICredentials(clientId, clientSecret)
}
result.success(null)
}
"hasSpotifyCredentials" -> {
val hasCredentials = withContext(Dispatchers.IO) {
Gobackend.checkSpotifyCredentials()
}
result.success(hasCredentials)
}
"preWarmTrackCache" -> {
val tracksJson = call.argument<String>("tracks") ?: "[]"
withContext(Dispatchers.IO) {
+3 -185
View File
@@ -32,126 +32,6 @@ func ParseSpotifyURL(url string) (string, error) {
return string(jsonBytes), nil
}
func SetSpotifyAPICredentials(clientID, clientSecret string) {
SetSpotifyCredentials(clientID, clientSecret)
}
func CheckSpotifyCredentials() bool {
return HasSpotifyCredentials()
}
func GetSpotifyMetadata(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
if shouldTrySpotFetchFallback(err) {
data, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
jsonBytes, marshalErr := json.Marshal(data)
if marshalErr != nil {
return "", marshalErr
}
return string(jsonBytes), nil
}
}
return "", err
}
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err != nil {
if shouldTrySpotFetchFallback(err) {
fallbackData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
jsonBytes, marshalErr := json.Marshal(fallbackData)
if marshalErr != nil {
return "", marshalErr
}
return string(jsonBytes), nil
}
}
return "", err
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchSpotify(query string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
return "", err
}
results, err := client.SearchTracks(ctx, query, limit)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func SearchSpotifyAll(query string, trackLimit, artistLimit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
return "", err
}
results, err := client.SearchAll(ctx, query, trackLimit, artistLimit)
if err != nil {
return "", err
}
jsonBytes, err := json.Marshal(results)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func GetSpotifyRelatedArtists(artistID string, limit int) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
client, err := NewSpotifyMetadataClient()
if err != nil {
return "", err
}
normalizedArtistID := strings.TrimSpace(strings.TrimPrefix(artistID, "spotify:"))
if normalizedArtistID == "" {
return "", fmt.Errorf("invalid Spotify artist ID")
}
artists, err := client.GetRelatedArtists(ctx, normalizedArtistID, limit)
if err != nil {
return "", err
}
resp := map[string]interface{}{
"artists": artists,
}
jsonBytes, err := json.Marshal(resp)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
func CheckAvailability(spotifyID, isrc string) (string, error) {
client := NewSongLinkClient()
availability, err := client.CheckTrackAvailability(spotifyID, isrc)
@@ -1439,28 +1319,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
var spotifyErr error
client, err := NewSpotifyMetadataClient()
if err != nil {
LogWarn("Spotify", "Credentials not configured, falling back to Deezer")
spotifyErr = err
} else {
data, err := client.GetFilteredData(ctx, spotifyURL, false, 0)
if err == nil {
jsonBytes, err := json.Marshal(data)
if err != nil {
return "", err
}
return string(jsonBytes), nil
}
spotifyErr = err
if !shouldTrySpotFetchFallback(err) {
return "", err
}
}
spotFetchData, apiErr := GetSpotifyDataWithAPI(ctx, spotifyURL, DefaultSpotFetchAPIBaseURL)
if apiErr == nil {
GoLog("[Fallback] Spotify metadata fetched via SpotFetch API\n")
@@ -1474,9 +1332,6 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
parsed, parseErr := parseSpotifyURI(spotifyURL)
if parseErr != nil {
if spotifyErr != nil {
return "", fmt.Errorf("spotify failed (%v), SpotFetch fallback failed (%v), and URL parsing failed: %w", spotifyErr, apiErr, parseErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v) and URL parsing failed: %w", apiErr, parseErr)
}
@@ -1487,15 +1342,9 @@ func GetSpotifyMetadataWithDeezerFallback(spotifyURL string) (string, error) {
}
if parsed.Type == "artist" {
if spotifyErr != nil {
return "", fmt.Errorf("spotify metadata unavailable (%v) and SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", spotifyErr, apiErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages require Spotify/SpotFetch API", apiErr)
return "", fmt.Errorf("SpotFetch fallback failed (%v). Artist pages now require SpotFetch or a metadata extension such as spotify-web", apiErr)
}
if spotifyErr != nil {
return "", fmt.Errorf("spotify metadata unavailable (%v), SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", spotifyErr, apiErr)
}
return "", fmt.Errorf("SpotFetch fallback failed (%v), and Deezer conversion is unavailable for playlists", apiErr)
}
@@ -1826,8 +1675,8 @@ func ReEnrichFile(requestJSON string) (string, error) {
GoLog("[ReEnrich] Starting re-enrichment for: %s\n", req.FilePath)
// When search_online is true, search for metadata from internet
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc) 3) Spotify built-in API (last resort, deprecated)
// When search_online is true, search for metadata from internet.
// Priority: 1) Deezer (reliable, no credentials) 2) Extension providers (spotify-web etc)
if req.SearchOnline && req.TrackName != "" && req.ArtistName != "" {
GoLog("[ReEnrich] Searching online metadata for: %s - %s\n", req.TrackName, req.ArtistName)
searchQuery := req.TrackName + " " + req.ArtistName
@@ -1901,37 +1750,6 @@ func ReEnrichFile(requestJSON string) (string, error) {
}
}
// 3) Try Spotify built-in API as last resort (will be deprecated)
if !found {
GoLog("[ReEnrich] Trying Spotify API (fallback)...\n")
spotifyClient, spotifyErr := NewSpotifyMetadataClient()
if spotifyErr == nil {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
results, err := spotifyClient.SearchTracks(ctx, searchQuery, 5)
cancel()
if err == nil && len(results.Tracks) > 0 {
track := results.Tracks[0]
GoLog("[ReEnrich] Spotify match: %s - %s (album: %s)\n", track.Name, track.Artists, track.AlbumName)
req.SpotifyID = track.SpotifyID
req.AlbumName = track.AlbumName
req.AlbumArtist = track.AlbumArtist
req.TrackNumber = track.TrackNumber
req.DiscNumber = track.DiscNumber
req.ReleaseDate = track.ReleaseDate
req.ISRC = track.ISRC
if track.Images != "" {
req.CoverURL = track.Images
}
req.DurationMs = int64(track.DurationMS)
found = true
} else if err != nil {
GoLog("[ReEnrich] Spotify search failed: %v\n", err)
}
} else {
GoLog("[ReEnrich] Spotify client unavailable: %v\n", spotifyErr)
}
}
// Try to get extended metadata (genre, label) from Deezer if not already set
if found && req.ISRC != "" && (req.Genre == "" || req.Label == "") {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+21 -3
View File
@@ -644,8 +644,26 @@ func GetProviderPriority() []string {
func SetMetadataProviderPriority(providerIDs []string) {
metadataProviderPriorityMu.Lock()
defer metadataProviderPriorityMu.Unlock()
metadataProviderPriority = providerIDs
GoLog("[Extension] Metadata provider priority set: %v\n", providerIDs)
sanitized := make([]string, 0, len(providerIDs)+1)
seen := map[string]struct{}{}
for _, providerID := range providerIDs {
providerID = strings.TrimSpace(providerID)
if providerID == "" || providerID == "spotify" {
continue
}
if _, exists := seen[providerID]; exists {
continue
}
seen[providerID] = struct{}{}
sanitized = append(sanitized, providerID)
}
if _, exists := seen["deezer"]; !exists {
sanitized = append([]string{"deezer"}, sanitized...)
}
metadataProviderPriority = sanitized
GoLog("[Extension] Metadata provider priority set: %v\n", sanitized)
}
func GetMetadataProviderPriority() []string {
@@ -653,7 +671,7 @@ func GetMetadataProviderPriority() []string {
defer metadataProviderPriorityMu.RUnlock()
if len(metadataProviderPriority) == 0 {
return []string{"deezer", "spotify"}
return []string{"deezer"}
}
result := make([]string, len(metadataProviderPriority))
+3 -29
View File
@@ -9,7 +9,6 @@ import (
"math/rand"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
@@ -64,45 +63,20 @@ var (
credentialsMu sync.RWMutex
)
var ErrNoSpotifyCredentials = errors.New("Spotify credentials not configured. Please set your own Client ID and Secret in Settings, or use Deezer as metadata source (free, no credentials required)")
var ErrNoSpotifyCredentials = errors.New("built-in Spotify API metadata provider has been removed; use Deezer or the spotify-web extension instead")
func SetSpotifyCredentials(clientID, clientSecret string) {
credentialsMu.Lock()
defer credentialsMu.Unlock()
customClientID = clientID
customClientSecret = clientSecret
customClientID = ""
customClientSecret = ""
}
func HasSpotifyCredentials() bool {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
if customClientID != "" && customClientSecret != "" {
return true
}
if os.Getenv("SPOTIFY_CLIENT_ID") != "" && os.Getenv("SPOTIFY_CLIENT_SECRET") != "" {
return true
}
return false
}
func getCredentials() (string, string, error) {
credentialsMu.RLock()
defer credentialsMu.RUnlock()
if customClientID != "" && customClientSecret != "" {
return customClientID, customClientSecret, nil
}
clientID := os.Getenv("SPOTIFY_CLIENT_ID")
clientSecret := os.Getenv("SPOTIFY_CLIENT_SECRET")
if clientID != "" && clientSecret != "" {
return clientID, clientSecret, nil
}
return "", "", ErrNoSpotifyCredentials
}
+2 -2
View File
@@ -104,7 +104,7 @@ class AppSettings {
this.askQualityBeforeDownload = true,
this.spotifyClientId = '',
this.spotifyClientSecret = '',
this.useCustomSpotifyCredentials = true,
this.useCustomSpotifyCredentials = false,
this.metadataSource = 'deezer',
this.enableLogging = false,
this.useExtensionProviders = true,
@@ -149,7 +149,7 @@ class AppSettings {
String? downloadDirectory,
String? storageMode,
String? downloadTreeUri,
bool? autoFallback,
bool? autoFallback,
bool? embedMetadata,
bool? embedLyrics,
bool? maxQualityCover,
+1 -1
View File
@@ -33,7 +33,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
spotifyClientId: json['spotifyClientId'] as String? ?? '',
spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '',
useCustomSpotifyCredentials:
json['useCustomSpotifyCredentials'] as bool? ?? true,
json['useCustomSpotifyCredentials'] as bool? ?? false,
metadataSource: json['metadataSource'] as String? ?? 'deezer',
enableLogging: json['enableLogging'] as bool? ?? false,
useExtensionProviders: json['useExtensionProviders'] as bool? ?? true,
+36 -7
View File
@@ -823,11 +823,19 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
List<String> priority;
if (savedJson != null) {
final saved = jsonDecode(savedJson) as List<dynamic>;
priority = saved.map((e) => e as String).toList();
priority = _sanitizeMetadataProviderPriority(
saved.map((e) => e as String).toList(),
);
_log.d('Loaded metadata provider priority from prefs: $priority');
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(priority),
);
await PlatformBridge.setMetadataProviderPriority(priority);
} else {
priority = await PlatformBridge.getMetadataProviderPriority();
priority = _sanitizeMetadataProviderPriority(
await PlatformBridge.getMetadataProviderPriority(),
);
_log.d('Using default metadata provider priority: $priority');
}
@@ -840,11 +848,15 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
Future<void> setMetadataProviderPriority(List<String> priority) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_metadataProviderPriorityKey, jsonEncode(priority));
final sanitized = _sanitizeMetadataProviderPriority(priority);
await prefs.setString(
_metadataProviderPriorityKey,
jsonEncode(sanitized),
);
await PlatformBridge.setMetadataProviderPriority(priority);
state = state.copyWith(metadataProviderPriority: priority);
_log.d('Saved metadata provider priority: $priority');
await PlatformBridge.setMetadataProviderPriority(sanitized);
state = state.copyWith(metadataProviderPriority: sanitized);
_log.d('Saved metadata provider priority: $sanitized');
} catch (e) {
_log.e('Failed to set metadata provider priority: $e');
state = state.copyWith(error: e.toString());
@@ -880,7 +892,7 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
List<String> getAllMetadataProviders() {
final providers = ['deezer', 'spotify'];
final providers = ['deezer'];
for (final ext in state.extensions) {
if (ext.enabled && ext.hasMetadataProvider) {
providers.add(ext.id);
@@ -889,6 +901,23 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
return providers;
}
List<String> _sanitizeMetadataProviderPriority(List<String> input) {
final allowed = getAllMetadataProviders().toSet();
final result = <String>[];
for (final provider in input) {
if (allowed.contains(provider) && !result.contains(provider)) {
result.add(provider);
}
}
if (!result.contains('deezer')) {
result.insert(0, 'deezer');
}
return result;
}
List<Extension> get searchProviders {
return state.extensions
.where((ext) => ext.enabled && ext.hasCustomSearch)
+30 -78
View File
@@ -9,7 +9,7 @@ import 'package:spotiflac_android/utils/logger.dart';
const _settingsKey = 'app_settings';
const _migrationVersionKey = 'settings_migration_version';
const _currentMigrationVersion = 4;
const _currentMigrationVersion = 5;
const _spotifyClientSecretKey = 'spotify_client_secret';
final _log = AppLogger('SettingsProvider');
@@ -41,9 +41,7 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _normalizeSongLinkRegionIfNeeded();
}
await _loadSpotifyClientSecret(prefs);
_applySpotifyCredentials();
await _retireBuiltInSpotifyProvider();
LogBuffer.loggingEnabled = state.enableLogging;
@@ -105,6 +103,17 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
state = state.copyWith(lyricsProviders: updatedProviders);
}
if (state.metadataSource != 'deezer' ||
state.spotifyClientId.isNotEmpty ||
state.spotifyClientSecret.isNotEmpty ||
state.useCustomSpotifyCredentials) {
state = state.copyWith(
metadataSource: 'deezer',
spotifyClientId: '',
spotifyClientSecret: '',
useCustomSpotifyCredentials: false,
);
}
state = state.copyWith(lastSeenVersion: AppInfo.version);
await prefs.setInt(_migrationVersionKey, _currentMigrationVersion);
await _saveSettings();
@@ -193,49 +202,28 @@ class SettingsNotifier extends Notifier<AppSettings> {
await _saveSettings();
}
Future<void> _loadSpotifyClientSecret(SharedPreferences prefs) async {
Future<void> _retireBuiltInSpotifyProvider() async {
final storedSecret = await _secureStorage.read(
key: _spotifyClientSecretKey,
);
final prefsSecret = state.spotifyClientSecret;
if ((storedSecret == null || storedSecret.isEmpty) &&
prefsSecret.isNotEmpty) {
await _secureStorage.write(
key: _spotifyClientSecretKey,
value: prefsSecret,
);
}
final effectiveSecret = (storedSecret != null && storedSecret.isNotEmpty)
? storedSecret
: (prefsSecret.isNotEmpty ? prefsSecret : '');
if (effectiveSecret != state.spotifyClientSecret) {
state = state.copyWith(spotifyClientSecret: effectiveSecret);
}
if (prefsSecret.isNotEmpty) {
await _saveSettings();
}
}
Future<void> _storeSpotifyClientSecret(String secret) async {
if (secret.isEmpty) {
if (storedSecret != null && storedSecret.isNotEmpty) {
await _secureStorage.delete(key: _spotifyClientSecretKey);
} else {
await _secureStorage.write(key: _spotifyClientSecretKey, value: secret);
}
}
Future<void> _applySpotifyCredentials() async {
if (state.spotifyClientId.isNotEmpty &&
state.spotifyClientSecret.isNotEmpty) {
await PlatformBridge.setSpotifyCredentials(
state.spotifyClientId,
state.spotifyClientSecret,
);
if (state.metadataSource == 'deezer' &&
state.spotifyClientId.isEmpty &&
state.spotifyClientSecret.isEmpty &&
!state.useCustomSpotifyCredentials) {
return;
}
state = state.copyWith(
metadataSource: 'deezer',
spotifyClientId: '',
spotifyClientSecret: '',
useCustomSpotifyCredentials: false,
);
await _saveSettings();
}
void setDefaultService(String service) {
@@ -396,45 +384,9 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setSpotifyClientId(String clientId) {
state = state.copyWith(spotifyClientId: clientId);
_saveSettings();
}
Future<void> setSpotifyClientSecret(String clientSecret) async {
state = state.copyWith(spotifyClientSecret: clientSecret);
await _storeSpotifyClientSecret(clientSecret);
_saveSettings();
}
Future<void> setSpotifyCredentials(
String clientId,
String clientSecret,
) async {
state = state.copyWith(
spotifyClientId: clientId,
spotifyClientSecret: clientSecret,
);
await _storeSpotifyClientSecret(clientSecret);
_saveSettings();
_applySpotifyCredentials();
}
Future<void> clearSpotifyCredentials() async {
state = state.copyWith(spotifyClientId: '', spotifyClientSecret: '');
await _storeSpotifyClientSecret('');
_saveSettings();
_applySpotifyCredentials();
}
void setUseCustomSpotifyCredentials(bool enabled) {
state = state.copyWith(useCustomSpotifyCredentials: enabled);
_saveSettings();
_applySpotifyCredentials();
}
void setMetadataSource(String source) {
state = state.copyWith(metadataSource: source);
final normalized = source == 'deezer' ? 'deezer' : 'deezer';
state = state.copyWith(metadataSource: normalized);
_saveSettings();
}
+15 -30
View File
@@ -458,7 +458,8 @@ class TrackNotifier extends Notifier<TrackState> {
}
// If URL doesn't match any known service, it's unrecognized
final isSpotifyUrl = url.contains('open.spotify.com') ||
final isSpotifyUrl =
url.contains('open.spotify.com') ||
url.contains('spotify.link') ||
url.startsWith('spotify:');
if (!isSpotifyUrl) {
@@ -546,11 +547,7 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
Future<void> search(
String query, {
String? metadataSource,
String? filterOverride,
}) async {
Future<void> search(String query, {String? filterOverride}) async {
final requestId = ++_currentRequestId;
// Preserve selected filter during loading
@@ -576,7 +573,7 @@ class TrackNotifier extends Notifier<TrackState> {
searchProvider != null &&
searchProvider.isNotEmpty;
final source = metadataSource ?? 'deezer';
const source = 'deezer';
_log.i(
'Search started: source=$source, query="$query", useExtensions=$useExtensions, filter=$currentFilter',
@@ -602,32 +599,20 @@ class TrackNotifier extends Notifier<TrackState> {
}
}
} catch (e) {
_log.w('Extension search failed, falling back to built-in: $e');
_log.w('Extension search failed, falling back to Deezer: $e');
}
}
if (source == 'deezer') {
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
_log.i(
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
);
} else {
_log.d('Calling Spotify search API...');
results = await PlatformBridge.searchSpotifyAll(
query,
trackLimit: 20,
artistLimit: 2,
);
_log.i(
'Spotify returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists',
);
}
_log.d('Calling Deezer search API...');
results = await PlatformBridge.searchDeezerAll(
query,
trackLimit: 20,
artistLimit: 2,
filter: currentFilter,
);
_log.i(
'Deezer returned ${(results['tracks'] as List?)?.length ?? 0} tracks, ${(results['artists'] as List?)?.length ?? 0} artists, ${(results['albums'] as List?)?.length ?? 0} albums',
);
if (!_isRequestValid(requestId)) {
_log.w('Search request cancelled (requestId=$requestId)');
+11 -23
View File
@@ -549,11 +549,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
await ref
.read(trackProvider.notifier)
.search(
query,
metadataSource: settings.metadataSource,
filterOverride: selectedFilter,
);
.search(query, filterOverride: selectedFilter);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
@@ -587,26 +583,24 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (trackState.error != null && mounted) {
final l10n = context.l10n;
final errorMsg = trackState.error!;
final isRateLimit = errorMsg.contains('429') ||
final isRateLimit =
errorMsg.contains('429') ||
errorMsg.toLowerCase().contains('rate limit') ||
errorMsg.toLowerCase().contains('too many requests');
final displayMessage = errorMsg == 'url_not_recognized'
? l10n.errorUrlNotRecognizedMessage
: isRateLimit
? l10n.errorRateLimitedMessage
: l10n.errorUrlFetchFailed;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(displayMessage)),
);
? l10n.errorRateLimitedMessage
: l10n.errorUrlFetchFailed;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(displayMessage)));
ref.read(trackProvider.notifier).clear();
} else {
_navigateToDetailIfNeeded();
}
} else {
final settings = ref.read(settingsProvider);
await ref
.read(trackProvider.notifier)
.search(url, metadataSource: settings.metadataSource);
await ref.read(trackProvider.notifier).search(url);
}
ref.read(settingsProvider.notifier).setHasSearchedBefore();
}
@@ -2315,10 +2309,7 @@ class _HomeTabState extends ConsumerState<HomeTab>
const SizedBox(height: 4),
Text(
l10n.errorUrlNotRecognizedMessage,
style: TextStyle(
color: colorScheme.error,
fontSize: 12,
),
style: TextStyle(color: colorScheme.error, fontSize: 12),
),
],
),
@@ -2971,9 +2962,6 @@ class _SearchProviderDropdown extends ConsumerWidget {
final currentProvider = ref.watch(
settingsProvider.select((s) => s.searchProvider),
);
final metadataSource = ref.watch(
settingsProvider.select((s) => s.metadataSource),
);
final extensions = ref.watch(extensionProvider.select((s) => s.extensions));
final colorScheme = Theme.of(context).colorScheme;
@@ -3051,7 +3039,7 @@ class _SearchProviderDropdown extends ConsumerWidget {
const SizedBox(width: 12),
Expanded(
child: Text(
metadataSource == 'spotify' ? 'Spotify' : 'Deezer',
'Deezer',
style: TextStyle(
fontWeight:
currentProvider == null || currentProvider.isEmpty
+2 -8
View File
@@ -27,10 +27,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
_searchController = TextEditingController(text: widget.query);
if (widget.query.isNotEmpty) {
WidgetsBinding.instance.addPostFrameCallback((_) {
final settings = ref.read(settingsProvider);
ref
.read(trackProvider.notifier)
.search(widget.query, metadataSource: settings.metadataSource);
ref.read(trackProvider.notifier).search(widget.query);
});
}
}
@@ -44,10 +41,7 @@ class _SearchScreenState extends ConsumerState<SearchScreen> {
void _search() {
final query = _searchController.text.trim();
if (query.isNotEmpty) {
final settings = ref.read(settingsProvider);
ref
.read(trackProvider.notifier)
.search(query, metadataSource: settings.metadataSource);
ref.read(trackProvider.notifier).search(query);
}
}
@@ -228,13 +228,6 @@ class _MetadataProviderItem extends StatelessWidget {
description: context.l10n.metadataNoRateLimits,
isBuiltIn: true,
);
case 'spotify':
return _MetadataProviderInfo(
name: 'Spotify',
icon: Icons.music_note,
description: context.l10n.metadataMayRateLimit,
isBuiltIn: true,
);
default:
return _MetadataProviderInfo(
name: provider,
+3 -307
View File
@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/extension_provider.dart';
@@ -73,68 +72,10 @@ class OptionsSettingsPage extends ConsumerWidget {
child: SettingsGroup(
children: [
_MetadataSourceSelector(
currentSource: settings.metadataSource,
onChanged: (v) => ref
.read(settingsProvider.notifier)
.setMetadataSource(v),
),
if (settings.metadataSource == 'spotify') ...[
if (settings.spotifyClientId.isEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: Card(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: Theme.of(
context,
).colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
Expanded(
child: Text(
context.l10n.optionsSpotifyWarning,
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onErrorContainer,
fontSize: 12,
),
),
),
],
),
),
),
),
SettingsItem(
icon: Icons.key,
title: context.l10n.optionsSpotifyCredentials,
subtitle: settings.spotifyClientId.isNotEmpty
? context.l10n.optionsSpotifyCredentialsConfigured(
settings.spotifyClientId.length > 8
? settings.spotifyClientId.substring(0, 8)
: settings.spotifyClientId,
)
: context.l10n.optionsSpotifyCredentialsRequired,
onTap: () =>
_showSpotifyCredentialsDialog(context, ref, settings),
trailing: Icon(
settings.spotifyClientId.isNotEmpty
? Icons.check_circle
: Icons.error_outline,
color: settings.spotifyClientId.isNotEmpty
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.error,
size: 20,
),
showDivider: false,
),
],
],
),
),
@@ -372,214 +313,6 @@ class OptionsSettingsPage extends ConsumerWidget {
}
}
}
void _showSpotifyCredentialsDialog(
BuildContext context,
WidgetRef ref,
AppSettings settings,
) {
final clientIdController = TextEditingController(
text: settings.spotifyClientId,
);
final clientSecretController = TextEditingController(
text: settings.spotifyClientSecret,
);
final colorScheme = Theme.of(context).colorScheme;
showModalBottomSheet(
context: context,
useRootNavigator: true,
isScrollControlled: true,
backgroundColor: colorScheme.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
builder: (context) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: SingleChildScrollView(
child: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Container(
width: 32,
height: 4,
margin: const EdgeInsets.only(bottom: 24),
decoration: BoxDecoration(
color: colorScheme.outlineVariant,
borderRadius: BorderRadius.circular(2),
),
),
),
Text(
context.l10n.credentialsTitle,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
context.l10n.credentialsDescription,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 32),
TextField(
controller: clientIdController,
decoration: InputDecoration(
labelText: context.l10n.credentialsClientId,
hintText: context.l10n.credentialsClientIdHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
prefixIcon: const Icon(Icons.person_outline),
),
),
const SizedBox(height: 16),
TextField(
controller: clientSecretController,
obscureText: true,
decoration: InputDecoration(
labelText: context.l10n.credentialsClientSecret,
hintText: context.l10n.credentialsClientSecretHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest.withValues(
alpha: 0.3,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.outlineVariant,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color: colorScheme.primary,
width: 2,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
prefixIcon: const Icon(Icons.lock_outline),
),
),
const SizedBox(height: 32),
FilledButton(
onPressed: () {
final clientId = clientIdController.text.trim();
final clientSecret = clientSecretController.text.trim();
if (clientId.isNotEmpty && clientSecret.isNotEmpty) {
ref
.read(settingsProvider.notifier)
.setSpotifyCredentials(clientId, clientSecret);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarCredentialsSaved,
),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.snackbarFillAllFields),
),
);
}
},
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
child: Text(
context.l10n.actionSaveCredentials,
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
if (settings.spotifyClientId.isNotEmpty) ...[
const SizedBox(height: 12),
TextButton(
onPressed: () {
ref
.read(settingsProvider.notifier)
.clearSpotifyCredentials();
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.snackbarCredentialsCleared,
),
),
);
},
style: TextButton.styleFrom(
foregroundColor: colorScheme.error,
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: Text(context.l10n.actionRemoveCredentials),
),
],
const SizedBox(height: 16),
],
),
),
),
),
),
);
}
}
class _ConcurrentDownloadsItem extends StatelessWidget {
@@ -875,12 +608,8 @@ class _ChannelChip extends StatelessWidget {
}
class _MetadataSourceSelector extends ConsumerWidget {
final String currentSource;
final ValueChanged<String> onChanged;
const _MetadataSourceSelector({
required this.currentSource,
required this.onChanged,
});
const _MetadataSourceSelector({required this.onChanged});
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -930,7 +659,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
_SourceChip(
icon: Icons.graphic_eq,
label: 'Deezer',
isSelected: currentSource == 'deezer' && !hasExtensionSearch,
isSelected: !hasExtensionSearch,
onTap: () {
if (hasExtensionSearch) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
@@ -938,18 +667,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
onChanged('deezer');
},
),
const SizedBox(width: 12),
_SourceChip(
icon: Icons.music_note,
label: 'Spotify',
isSelected: currentSource == 'spotify' && !hasExtensionSearch,
onTap: () {
if (hasExtensionSearch) {
ref.read(settingsProvider.notifier).setSearchProvider(null);
}
onChanged('spotify');
},
),
],
),
if (hasExtensionSearch) ...[
@@ -964,7 +681,7 @@ class _MetadataSourceSelector extends ConsumerWidget {
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.optionsSwitchBack,
'Tap Deezer to switch back from extension',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
@@ -973,27 +690,6 @@ class _MetadataSourceSelector extends ConsumerWidget {
],
),
],
if (currentSource == 'spotify' && !hasExtensionSearch) ...[
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 16,
color: colorScheme.error,
),
const SizedBox(width: 8),
Expanded(
child: Text(
context.l10n.optionsSpotifyDeprecationWarning,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: colorScheme.error),
),
),
],
),
],
],
),
);
-61
View File
@@ -20,51 +20,6 @@ class PlatformBridge {
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getSpotifyMetadata(String url) async {
_log.d('getSpotifyMetadata: $url');
final result = await _channel.invokeMethod('getSpotifyMetadata', {
'url': url,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> searchSpotify(
String query, {
int limit = 10,
}) async {
_log.d('searchSpotify: "$query" (limit: $limit)');
final result = await _channel.invokeMethod('searchSpotify', {
'query': query,
'limit': limit,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> searchSpotifyAll(
String query, {
int trackLimit = 15,
int artistLimit = 3,
}) async {
_log.d('searchSpotifyAll: "$query"');
final result = await _channel.invokeMethod('searchSpotifyAll', {
'query': query,
'track_limit': trackLimit,
'artist_limit': artistLimit,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getSpotifyRelatedArtists(
String artistId, {
int limit = 12,
}) async {
final result = await _channel.invokeMethod('getSpotifyRelatedArtists', {
'artist_id': artistId,
'limit': limit,
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> checkAvailability(
String spotifyId,
String isrc,
@@ -517,21 +472,6 @@ class PlatformBridge {
return result as bool;
}
static Future<void> setSpotifyCredentials(
String clientId,
String clientSecret,
) async {
await _channel.invokeMethod('setSpotifyCredentials', {
'client_id': clientId,
'client_secret': clientSecret,
});
}
static Future<bool> hasSpotifyCredentials() async {
final result = await _channel.invokeMethod('hasSpotifyCredentials');
return result as bool;
}
static Future<void> preWarmTrackCache(
List<Map<String, String>> tracks,
) async {
@@ -1313,5 +1253,4 @@ class PlatformBridge {
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
}