From 85bb67da473d03ac98748aee8798f5d454524b80 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sat, 3 Jan 2026 23:56:03 +0700 Subject: [PATCH] v2.0.3: Custom Spotify credentials, rate limit UI, search fixes --- CHANGELOG.md | 19 +++ .../kotlin/com/zarz/spotiflac/MainActivity.kt | 8 + go_backend/exports.go | 6 + go_backend/spotify.go | 39 ++++- lib/constants/app_info.dart | 4 +- lib/models/settings.dart | 12 ++ lib/models/settings.g.dart | 7 + lib/providers/download_queue_provider.dart | 39 +++++ lib/providers/settings_provider.dart | 53 ++++++ lib/providers/track_provider.dart | 13 +- lib/screens/album_screen.dart | 67 +++++++- lib/screens/artist_screen.dart | 65 ++++++- lib/screens/home_tab.dart | 122 ++++++++++---- lib/screens/main_shell.dart | 14 +- .../settings/options_settings_page.dart | 159 ++++++++++++++++++ lib/services/platform_bridge.dart | 9 + pubspec.yaml | 2 +- 17 files changed, 594 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 313ce43f..cb016fea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,24 @@ # Changelog +## [2.0.3] - 2026-01-03 + +### Added +- **Custom Spotify API Credentials**: Set your own Spotify Client ID and Secret in Settings > Options to avoid rate limiting + - Toggle to enable/disable custom credentials without deleting them + - Material Expressive 3 bottom sheet UI for entering credentials +- **Keyboard Dismiss on Scroll**: Keyboard now automatically dismisses when scrolling search results +- **Rate Limit Error UI**: Shows friendly error card when API rate limit (429) is hit on Home, Artist, and Album screens + +### Changed +- **Search on Enter Only**: Removed auto-search debounce, now only searches when pressing Enter key (saves API calls) + +### Fixed +- **Download Cancel**: Fixed cancelled downloads still completing in background and appearing in history. Cancelled files are now properly deleted. +- **Search Keyboard Dismiss**: Fixed keyboard randomly dismissing and navigating back when starting to search +- **Back Button During Search**: Back button now properly dismisses keyboard first before clearing search +- **Search Error Navigation**: Fixed pressing Enter during search (when loading or error) navigating back to home instead of staying on search screen +- **Duplicate Search on Enter**: Enter key no longer triggers duplicate search if results already loaded + ## [2.0.2] - 2026-01-03 ### Added diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 6a124c10..c285e53f 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -200,6 +200,14 @@ class MainActivity: FlutterActivity() { "isDownloadServiceRunning" -> { result.success(DownloadService.isServiceRunning()) } + "setSpotifyCredentials" -> { + val clientId = call.argument("client_id") ?: "" + val clientSecret = call.argument("client_secret") ?: "" + withContext(Dispatchers.IO) { + Gobackend.setSpotifyAPICredentials(clientId, clientSecret) + } + result.success(null) + } else -> result.notImplemented() } } catch (e: Exception) { diff --git a/go_backend/exports.go b/go_backend/exports.go index 95073a9d..e504b119 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -30,6 +30,12 @@ func ParseSpotifyURL(url string) (string, error) { return string(jsonBytes), nil } +// SetSpotifyAPICredentials sets custom Spotify API credentials from Flutter +// Pass empty strings to use default credentials +func SetSpotifyAPICredentials(clientID, clientSecret string) { + SetSpotifyCredentials(clientID, clientSecret) +} + // GetSpotifyMetadata fetches metadata from Spotify URL // Returns JSON with track/album/playlist data func GetSpotifyMetadata(spotifyURL string) (string, error) { diff --git a/go_backend/spotify.go b/go_backend/spotify.go index 9c910636..b1c919f8 100644 --- a/go_backend/spotify.go +++ b/go_backend/spotify.go @@ -62,11 +62,32 @@ type SpotifyMetadataClient struct { cacheMu sync.RWMutex } -// NewSpotifyMetadataClient creates a new Spotify client -func NewSpotifyMetadataClient() *SpotifyMetadataClient { - src := rand.NewSource(time.Now().UnixNano()) +// Custom credentials storage (set from Flutter) +var ( + customClientID string + customClientSecret string + credentialsMu sync.RWMutex +) - // Prefer environment variables for credentials (more secure), fall back to built-in +// SetSpotifyCredentials sets custom Spotify API credentials +// Pass empty strings to use default credentials +func SetSpotifyCredentials(clientID, clientSecret string) { + credentialsMu.Lock() + defer credentialsMu.Unlock() + customClientID = clientID + customClientSecret = clientSecret +} + +// getCredentials returns the current credentials (custom or default) +func getCredentials() (string, string) { + credentialsMu.RLock() + defer credentialsMu.RUnlock() + + if customClientID != "" && customClientSecret != "" { + return customClientID, customClientSecret + } + + // Fall back to default credentials clientID := os.Getenv("SPOTIFY_CLIENT_ID") if clientID == "" { if decoded, err := base64.StdEncoding.DecodeString("NWY1NzNjOTYyMDQ5NGJhZTg3ODkwYzBmMDhhNjAyOTM="); err == nil { @@ -80,6 +101,16 @@ func NewSpotifyMetadataClient() *SpotifyMetadataClient { clientSecret = string(decoded) } } + + return clientID, clientSecret +} + +// NewSpotifyMetadataClient creates a new Spotify client +func NewSpotifyMetadataClient() *SpotifyMetadataClient { + src := rand.NewSource(time.Now().UnixNano()) + + // Get credentials (custom or default) + clientID, clientSecret := getCredentials() c := &SpotifyMetadataClient{ httpClient: NewHTTPClientWithTimeout(15 * time.Second), // Use shared transport for connection pooling diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 38f39966..f084280d 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -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.0.2'; - static const String buildNumber = '32'; + static const String version = '2.0.3'; + static const String buildNumber = '33'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 12be2412..0c6ad037 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -18,6 +18,9 @@ class AppSettings { final String folderOrganization; // none, artist, album, artist_album final String historyViewMode; // list, grid final bool askQualityBeforeDownload; // Show quality picker before each download + 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) const AppSettings({ this.defaultService = 'tidal', @@ -34,6 +37,9 @@ class AppSettings { this.folderOrganization = 'none', // Default: no folder organization this.historyViewMode = 'grid', // Default: grid view this.askQualityBeforeDownload = true, // Default: ask quality before download + this.spotifyClientId = '', // Default: use built-in credentials + this.spotifyClientSecret = '', // Default: use built-in credentials + this.useCustomSpotifyCredentials = true, // Default: use custom if set }); AppSettings copyWith({ @@ -51,6 +57,9 @@ class AppSettings { String? folderOrganization, String? historyViewMode, bool? askQualityBeforeDownload, + String? spotifyClientId, + String? spotifyClientSecret, + bool? useCustomSpotifyCredentials, }) { return AppSettings( defaultService: defaultService ?? this.defaultService, @@ -67,6 +76,9 @@ class AppSettings { folderOrganization: folderOrganization ?? this.folderOrganization, historyViewMode: historyViewMode ?? this.historyViewMode, askQualityBeforeDownload: askQualityBeforeDownload ?? this.askQualityBeforeDownload, + spotifyClientId: spotifyClientId ?? this.spotifyClientId, + spotifyClientSecret: spotifyClientSecret ?? this.spotifyClientSecret, + useCustomSpotifyCredentials: useCustomSpotifyCredentials ?? this.useCustomSpotifyCredentials, ); } diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 46aefe93..fda0fd5c 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -21,6 +21,10 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( folderOrganization: json['folderOrganization'] as String? ?? 'none', historyViewMode: json['historyViewMode'] as String? ?? 'grid', askQualityBeforeDownload: json['askQualityBeforeDownload'] as bool? ?? true, + spotifyClientId: json['spotifyClientId'] as String? ?? '', + spotifyClientSecret: json['spotifyClientSecret'] as String? ?? '', + useCustomSpotifyCredentials: + json['useCustomSpotifyCredentials'] as bool? ?? true, ); Map _$AppSettingsToJson(AppSettings instance) => @@ -39,4 +43,7 @@ Map _$AppSettingsToJson(AppSettings instance) => 'folderOrganization': instance.folderOrganization, 'historyViewMode': instance.historyViewMode, 'askQualityBeforeDownload': instance.askQualityBeforeDownload, + 'spotifyClientId': instance.spotifyClientId, + 'spotifyClientSecret': instance.spotifyClientSecret, + 'useCustomSpotifyCredentials': instance.useCustomSpotifyCredentials, }; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index daba2218..67cbb908 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1030,6 +1030,26 @@ class DownloadQueueNotifier extends Notifier { _log.d('Result: $result'); + // Check if item was cancelled while downloading + final currentItem = state.items.firstWhere((i) => i.id == item.id, orElse: () => item); + if (currentItem.status == DownloadStatus.skipped) { + _log.i('Download was cancelled, skipping result processing'); + // Delete the downloaded file if it exists + final filePath = result['file_path'] as String?; + if (filePath != null && result['success'] == true) { + try { + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + _log.d('Deleted cancelled download file: $filePath'); + } + } catch (e) { + _log.w('Failed to delete cancelled file: $e'); + } + } + return; + } + if (result['success'] == true) { var filePath = result['file_path'] as String?; _log.i('Download success, file: $filePath'); @@ -1071,6 +1091,25 @@ class DownloadQueueNotifier extends Notifier { } } + // Check again if cancelled before updating status and adding to history + final itemAfterDownload = state.items.firstWhere((i) => i.id == item.id, orElse: () => item); + if (itemAfterDownload.status == DownloadStatus.skipped) { + _log.i('Download was cancelled during finalization, cleaning up'); + // Delete the downloaded file + if (filePath != null) { + try { + final file = File(filePath); + if (await file.exists()) { + await file.delete(); + _log.d('Deleted cancelled download file: $filePath'); + } + } catch (e) { + _log.w('Failed to delete cancelled file: $e'); + } + } + return; + } + updateItemStatus( item.id, DownloadStatus.completed, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index bc58ba77..86252e75 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/models/settings.dart'; +import 'package:spotiflac_android/services/platform_bridge.dart'; const _settingsKey = 'app_settings'; @@ -17,6 +18,8 @@ class SettingsNotifier extends Notifier { final json = prefs.getString(_settingsKey); if (json != null) { state = AppSettings.fromJson(jsonDecode(json)); + // Apply Spotify credentials to Go backend on load + _applySpotifyCredentials(); } } @@ -25,6 +28,22 @@ class SettingsNotifier extends Notifier { await prefs.setString(_settingsKey, jsonEncode(state.toJson())); } + /// Apply current Spotify credentials to Go backend + Future _applySpotifyCredentials() async { + // Only apply custom credentials if enabled and both fields are set + if (state.useCustomSpotifyCredentials && + state.spotifyClientId.isNotEmpty && + state.spotifyClientSecret.isNotEmpty) { + await PlatformBridge.setSpotifyCredentials( + state.spotifyClientId, + state.spotifyClientSecret, + ); + } else { + // Clear to use default + await PlatformBridge.setSpotifyCredentials('', ''); + } + } + void setDefaultService(String service) { state = state.copyWith(defaultService: service); _saveSettings(); @@ -98,6 +117,40 @@ class SettingsNotifier extends Notifier { state = state.copyWith(askQualityBeforeDownload: enabled); _saveSettings(); } + + void setSpotifyClientId(String clientId) { + state = state.copyWith(spotifyClientId: clientId); + _saveSettings(); + } + + void setSpotifyClientSecret(String clientSecret) { + state = state.copyWith(spotifyClientSecret: clientSecret); + _saveSettings(); + } + + void setSpotifyCredentials(String clientId, String clientSecret) { + state = state.copyWith( + spotifyClientId: clientId, + spotifyClientSecret: clientSecret, + ); + _saveSettings(); + _applySpotifyCredentials(); + } + + void clearSpotifyCredentials() { + state = state.copyWith( + spotifyClientId: '', + spotifyClientSecret: '', + ); + _saveSettings(); + _applySpotifyCredentials(); + } + + void setUseCustomSpotifyCredentials(bool enabled) { + state = state.copyWith(useCustomSpotifyCredentials: enabled); + _saveSettings(); + _applySpotifyCredentials(); + } } final settingsProvider = NotifierProvider( diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 5c4254cd..c8aa9a9d 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -118,7 +118,8 @@ class TrackNotifier extends Notifier { // Increment request ID to cancel any pending requests final requestId = ++_currentRequestId; - state = const TrackState(isLoading: true); + // Preserve hasSearchText during fetch + state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); try { final parsed = await PlatformBridge.parseSpotifyUrl(url); @@ -174,7 +175,8 @@ class TrackNotifier extends Notifier { } } catch (e) { if (!_isRequestValid(requestId)) return; // Request cancelled - state = TrackState(isLoading: false, error: e.toString()); + // Preserve hasSearchText on error so user stays on search screen + state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText); } } @@ -182,7 +184,8 @@ class TrackNotifier extends Notifier { // Increment request ID to cancel any pending requests final requestId = ++_currentRequestId; - state = const TrackState(isLoading: true); + // Preserve hasSearchText during search + state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); try { final results = await PlatformBridge.searchSpotifyAll(query, trackLimit: 20, artistLimit: 5); @@ -198,10 +201,12 @@ class TrackNotifier extends Notifier { tracks: tracks, searchArtists: artists, isLoading: false, + hasSearchText: state.hasSearchText, ); } catch (e) { if (!_isRequestValid(requestId)) return; // Request cancelled - state = TrackState(isLoading: false, error: e.toString()); + // Preserve hasSearchText on error so user stays on search screen + state = TrackState(isLoading: false, error: e.toString(), hasSearchText: state.hasSearchText); } } diff --git a/lib/screens/album_screen.dart b/lib/screens/album_screen.dart index 4ae15a02..ec3ff300 100644 --- a/lib/screens/album_screen.dart +++ b/lib/screens/album_screen.dart @@ -126,10 +126,10 @@ class _AlbumScreenState extends ConsumerState { padding: EdgeInsets.all(32), child: Center(child: CircularProgressIndicator()), )), - if (_error != null) + if (_error != null) SliverToBoxAdapter(child: Padding( padding: const EdgeInsets.all(16), - child: Text(_error!, style: TextStyle(color: colorScheme.error)), + child: _buildErrorWidget(_error!, colorScheme), )), if (!_isLoading && _error == null && tracks.isNotEmpty) ...[ _buildTrackListHeader(context, colorScheme), @@ -369,6 +369,69 @@ class _AlbumScreenState extends ConsumerState { ), ); } + + /// Build error widget with special handling for rate limit (429) + Widget _buildErrorWidget(String error, ColorScheme colorScheme) { + final isRateLimit = error.contains('429') || + error.toLowerCase().contains('rate limit') || + error.toLowerCase().contains('too many requests'); + + if (isRateLimit) { + return Card( + elevation: 0, + color: colorScheme.errorContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.timer_off, color: colorScheme.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Rate Limited', + style: TextStyle( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Too many requests. Please wait a moment and try again.', + style: TextStyle( + color: colorScheme.onErrorContainer, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + // Default error display + return Card( + elevation: 0, + color: colorScheme.errorContainer.withValues(alpha: 0.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))), + ], + ), + ), + ); + } } class _QualityOption extends StatelessWidget { diff --git a/lib/screens/artist_screen.dart b/lib/screens/artist_screen.dart index 40b8de15..94629224 100644 --- a/lib/screens/artist_screen.dart +++ b/lib/screens/artist_screen.dart @@ -128,7 +128,7 @@ class _ArtistScreenState extends ConsumerState { if (_error != null) SliverToBoxAdapter(child: Padding( padding: const EdgeInsets.all(16), - child: Text(_error!, style: TextStyle(color: colorScheme.error)), + child: _buildErrorWidget(_error!, colorScheme), )), if (!_isLoadingDiscography && _error == null) ...[ if (albumsOnly.isNotEmpty) SliverToBoxAdapter(child: _buildAlbumSection('Albums', albumsOnly, colorScheme)), @@ -318,4 +318,67 @@ class _ArtistScreenState extends ConsumerState { ), )); } + + /// Build error widget with special handling for rate limit (429) + Widget _buildErrorWidget(String error, ColorScheme colorScheme) { + final isRateLimit = error.contains('429') || + error.toLowerCase().contains('rate limit') || + error.toLowerCase().contains('too many requests'); + + if (isRateLimit) { + return Card( + elevation: 0, + color: colorScheme.errorContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.timer_off, color: colorScheme.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Rate Limited', + style: TextStyle( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Too many requests. Please wait a moment and try again.', + style: TextStyle( + color: colorScheme.onErrorContainer, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + // Default error display + return Card( + elevation: 0, + color: colorScheme.errorContainer.withValues(alpha: 0.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))), + ], + ), + ), + ); + } } diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 18266612..6a2054a3 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -22,7 +22,6 @@ class HomeTab extends ConsumerStatefulWidget { class _HomeTabState extends ConsumerState with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin { final _urlController = TextEditingController(); - Timer? _debounce; bool _isTyping = false; final FocusNode _searchFocusNode = FocusNode(); String? _lastSearchQuery; // Track last searched query to avoid duplicate searches @@ -38,7 +37,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient @override void dispose() { - _debounce?.cancel(); _urlController.removeListener(_onSearchChanged); _urlController.dispose(); _searchFocusNode.dispose(); @@ -48,17 +46,18 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient /// Called when trackState changes - used to sync search bar with state void _onTrackStateChanged(TrackState? previous, TrackState next) { // If state was cleared (no content, no search text, not loading), clear the search bar + // BUT only if search field is not focused (to prevent clearing while user is typing) if (previous != null && !next.hasContent && !next.hasSearchText && !next.isLoading && - _urlController.text.isNotEmpty) { + _urlController.text.isNotEmpty && + !_searchFocusNode.hasFocus) { _urlController.clear(); setState(() => _isTyping = false); } } void _onSearchChanged() { final text = _urlController.text.trim(); - final wasFocused = _searchFocusNode.hasFocus; // Update search text state for MainShell back button handling ref.read(trackProvider.notifier).setSearchText(text.isNotEmpty); @@ -68,30 +67,13 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient setState(() => _isTyping = true); } else if (text.isEmpty && _isTyping) { setState(() => _isTyping = false); - ref.read(trackProvider.notifier).clear(); + // Don't clear provider here - it causes focus issues + // Provider will be cleared when user explicitly clears or navigates away return; } - // Re-request focus after rebuild if it was focused - if (wasFocused) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) { - _searchFocusNode.requestFocus(); - } - }); - } - - // Debounce all requests (URLs and searches) - _debounce?.cancel(); - _debounce = Timer(const Duration(milliseconds: 400), () { - if (text.isEmpty) return; - - if (text.startsWith('http') || text.startsWith('spotify:')) { - _fetchMetadata(); - } else if (text.length >= 2) { - _performSearch(text); - } - }); + // No auto-search - user must press Enter to search + // This saves API calls and avoids rate limiting } Future _performSearch(String query) async { @@ -116,7 +98,6 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } Future _clearAndRefresh() async { - _debounce?.cancel(); _urlController.clear(); _searchFocusNode.unfocus(); _lastSearchQuery = null; // Reset last query @@ -285,6 +266,7 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return Scaffold( body: CustomScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, slivers: [ // App Bar - always present SliverAppBar( @@ -479,6 +461,69 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient )); } + /// Build error widget with special handling for rate limit (429) + Widget _buildErrorWidget(String error, ColorScheme colorScheme) { + final isRateLimit = error.contains('429') || + error.toLowerCase().contains('rate limit') || + error.toLowerCase().contains('too many requests'); + + if (isRateLimit) { + return Card( + elevation: 0, + color: colorScheme.errorContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.timer_off, color: colorScheme.onErrorContainer), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Rate Limited', + style: TextStyle( + color: colorScheme.onErrorContainer, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Text( + 'Too many requests. Please wait a moment before searching again.', + style: TextStyle( + color: colorScheme.onErrorContainer, + fontSize: 12, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + // Default error display + return Card( + elevation: 0, + color: colorScheme.errorContainer.withValues(alpha: 0.5), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.error_outline, color: colorScheme.error), + const SizedBox(width: 12), + Expanded(child: Text(error, style: TextStyle(color: colorScheme.error))), + ], + ), + ), + ); + } + // Search results slivers - only shows search results (track list) List _buildSearchResults({ required List tracks, @@ -493,11 +538,11 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } return [ - // Error message + // Error message - with special handling for rate limit (429) if (error != null) SliverToBoxAdapter(child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text(error, style: TextStyle(color: colorScheme.error)), + child: _buildErrorWidget(error, colorScheme), )), // Loading indicator @@ -674,10 +719,29 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), contentPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), ), - onSubmitted: (_) => _fetchMetadata(), + onSubmitted: (_) => _onSearchSubmitted(), ); } + /// Handle Enter key press - search or fetch URL + void _onSearchSubmitted() { + final text = _urlController.text.trim(); + if (text.isEmpty) return; + + // If it's a URL, fetch metadata + if (text.startsWith('http') || text.startsWith('spotify:')) { + _fetchMetadata(); + _searchFocusNode.unfocus(); + return; + } + + // For search queries, always search (minimum 2 chars) + if (text.length >= 2) { + _performSearch(text); + } + _searchFocusNode.unfocus(); + } + } class _QualityPickerOption extends StatelessWidget { diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index 6fb05595..f3c160b8 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -125,6 +125,13 @@ class _MainShellState extends ConsumerState { void _handleBackPress() { final trackState = ref.read(trackProvider); + // Check if keyboard is visible - if so, just dismiss keyboard, don't clear search + final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; + if (isKeyboardVisible) { + FocusScope.of(context).unfocus(); + return; + } + // If on Home tab and has text in search bar or has content (but not loading), clear it if (_currentIndex == 0 && !trackState.isLoading && (trackState.hasSearchText || trackState.hasContent)) { ref.read(trackProvider.notifier).clear(); @@ -163,12 +170,17 @@ class _MainShellState extends ConsumerState { final queueState = ref.watch(downloadQueueProvider.select((s) => s.queuedCount)); final trackState = ref.watch(trackProvider); + // Check if keyboard is visible (bottom inset > 0 means keyboard is showing) + final isKeyboardVisible = MediaQuery.of(context).viewInsets.bottom > 0; + // Determine if we can pop (for predictive back animation) // canPop is true when we're at root with no content - enables predictive back gesture + // IMPORTANT: Never allow pop when keyboard is visible to prevent accidental navigation final canPop = _currentIndex == 0 && !trackState.hasSearchText && !trackState.hasContent && - !trackState.isLoading; + !trackState.isLoading && + !isKeyboardVisible; return PopScope( canPop: canPop, diff --git a/lib/screens/settings/options_settings_page.dart b/lib/screens/settings/options_settings_page.dart index df39925f..30934225 100644 --- a/lib/screens/settings/options_settings_page.dart +++ b/lib/screens/settings/options_settings_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.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/widgets/settings_group.dart'; @@ -116,6 +117,38 @@ class OptionsSettingsPage extends ConsumerWidget { ), ), + // Spotify API section + const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Spotify API')), + 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, + ), + 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, + ), + ], + ), + ), + // Data section const SliverToBoxAdapter(child: SettingsSectionHeader(title: 'Data')), SliverToBoxAdapter( @@ -163,6 +196,132 @@ 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, + isScrollControlled: true, + backgroundColor: colorScheme.surfaceContainerHigh, + 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: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Center(child: Container(width: 40, height: 4, decoration: BoxDecoration(color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(2)))), + Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 8), + child: Text('Spotify API Credentials', style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold)), + ), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Text( + 'Use your own credentials to avoid rate limiting.', + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: TextField( + controller: clientIdController, + decoration: InputDecoration( + labelText: 'Client ID', + hintText: 'Enter Spotify Client ID', + filled: true, + fillColor: colorScheme.surfaceContainerLow, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)), + ), + ), + ), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: TextField( + controller: clientSecretController, + obscureText: true, + decoration: InputDecoration( + labelText: 'Client Secret', + hintText: 'Enter Spotify Client Secret', + filled: true, + fillColor: colorScheme.surfaceContainerLow, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.outline.withValues(alpha: 0.3))), + focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(16), borderSide: BorderSide(color: colorScheme.primary, width: 2)), + ), + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 16), + child: Row( + children: [ + if (settings.spotifyClientId.isNotEmpty) + Expanded( + child: OutlinedButton( + onPressed: () { + ref.read(settingsProvider.notifier).clearSpotifyCredentials(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Credentials cleared')), + ); + }, + style: OutlinedButton.styleFrom( + foregroundColor: colorScheme.error, + side: BorderSide(color: colorScheme.error), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + minimumSize: const Size.fromHeight(52), + ), + child: const Text('Clear'), + ), + ), + if (settings.spotifyClientId.isNotEmpty) const SizedBox(width: 12), + Expanded( + child: 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( + const SnackBar(content: Text('Credentials saved')), + ); + } else if (clientId.isEmpty && clientSecret.isEmpty) { + Navigator.pop(context); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please fill both Client ID and Secret')), + ); + } + }, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + minimumSize: const Size.fromHeight(52), + ), + child: const Text('Save'), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } } class _ConcurrentDownloadsItem extends StatelessWidget { diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index f97216f4..36f357fc 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -284,4 +284,13 @@ class PlatformBridge { final result = await _channel.invokeMethod('isDownloadServiceRunning'); return result as bool; } + + /// Set custom Spotify API credentials + /// Pass empty strings to use default credentials + static Future setSpotifyCredentials(String clientId, String clientSecret) async { + await _channel.invokeMethod('setSpotifyCredentials', { + 'client_id': clientId, + 'client_secret': clientSecret, + }); + } } diff --git a/pubspec.yaml b/pubspec.yaml index 979a973c..28575702 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: 'none' -version: 2.0.2+32 +version: 2.0.3+33 environment: sdk: ^3.10.0