From 4f365ca7fe10b4be349bb9d6ea48cb4f67b27e59 Mon Sep 17 00:00:00 2001 From: zarzet Date: Wed, 25 Mar 2026 13:51:38 +0700 Subject: [PATCH] feat: add built-in Tidal/Qobuz search with recommended service picker - Add SearchAll() for Tidal and Qobuz in Go backend (tracks, artists, albums) - Add searchTidalAll/searchQobuzAll platform routing for Android and iOS - Add Tidal/Qobuz options to search provider dropdown in home tab - Show (Recommended) label and auto-select service in download picker --- .../kotlin/com/zarz/spotiflac/MainActivity.kt | 22 +++ go_backend/exports.go | 30 ++++ go_backend/qobuz.go | 128 ++++++++++++++++++ go_backend/tidal.go | 115 ++++++++++++++++ ios/Runner/AppDelegate.swift | 20 +++ lib/main.dart | 10 +- lib/providers/download_queue_provider.dart | 25 +++- lib/providers/extension_provider.dart | 16 +++ lib/providers/settings_provider.dart | 4 + lib/providers/track_provider.dart | 93 +++++++++---- lib/screens/home_tab.dart | 99 +++++++++++++- .../settings/download_settings_page.dart | 8 +- .../settings/library_settings_page.dart | 58 +++++--- lib/screens/setup_screen.dart | 9 +- lib/services/platform_bridge.dart | 36 +++++ lib/services/share_intent_service.dart | 23 ++-- lib/services/update_checker.dart | 66 +++++---- lib/widgets/download_service_picker.dart | 14 +- pubspec.lock | 40 ++++++ pubspec.yaml | 1 + 20 files changed, 720 insertions(+), 97 deletions(-) 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 ada75b95..3fba0054 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -2642,6 +2642,28 @@ class MainActivity: FlutterFragmentActivity() { } result.success(response) } + // Tidal search API + "searchTidalAll" -> { + val query = call.argument("query") ?: "" + val trackLimit = call.argument("track_limit") ?: 15 + val artistLimit = call.argument("artist_limit") ?: 2 + val filter = call.argument("filter") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.searchTidalAll(query, trackLimit.toLong(), artistLimit.toLong(), filter) + } + result.success(response) + } + // Qobuz search API + "searchQobuzAll" -> { + val query = call.argument("query") ?: "" + val trackLimit = call.argument("track_limit") ?: 15 + val artistLimit = call.argument("artist_limit") ?: 2 + val filter = call.argument("filter") ?: "" + val response = withContext(Dispatchers.IO) { + Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter) + } + result.success(response) + } "getDeezerRelatedArtists" -> { val artistId = call.argument("artist_id") ?: "" val limit = call.argument("limit") ?: 12 diff --git a/go_backend/exports.go b/go_backend/exports.go index d0a86ccc..457a3e6e 100644 --- a/go_backend/exports.go +++ b/go_backend/exports.go @@ -1147,6 +1147,36 @@ func SearchDeezerAll(query string, trackLimit, artistLimit int, filter string) ( return string(jsonBytes), nil } +func SearchTidalAll(query string, trackLimit, artistLimit int, filter string) (string, error) { + downloader := NewTidalDownloader() + results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(results) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + +func SearchQobuzAll(query string, trackLimit, artistLimit int, filter string) (string, error) { + downloader := NewQobuzDownloader() + results, err := downloader.SearchAll(query, trackLimit, artistLimit, filter) + if err != nil { + return "", err + } + + jsonBytes, err := json.Marshal(results) + if err != nil { + return "", err + } + + return string(jsonBytes), nil +} + func GetDeezerRelatedArtists(artistID string, limit int) (string, error) { ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) defer cancel() diff --git a/go_backend/qobuz.go b/go_backend/qobuz.go index 2a4d35c8..dc7d160a 100644 --- a/go_backend/qobuz.go +++ b/go_backend/qobuz.go @@ -1307,6 +1307,134 @@ func (q *QobuzDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad return results, nil } +// SearchAll searches Qobuz for tracks, artists, and albums matching the query. +// Returns results in the same SearchAllResult format as Deezer's SearchAll. +func (q *QobuzDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) { + GoLog("[Qobuz] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter) + + cleanQuery := strings.TrimSpace(query) + if cleanQuery == "" { + return nil, fmt.Errorf("empty qobuz search query") + } + + albumLimit := 5 + + if filter != "" { + switch filter { + case "track": + trackLimit = 50 + artistLimit = 0 + albumLimit = 0 + case "artist": + trackLimit = 0 + artistLimit = 20 + albumLimit = 0 + case "album": + trackLimit = 0 + artistLimit = 0 + albumLimit = 20 + } + } + + result := &SearchAllResult{ + Tracks: make([]TrackMetadata, 0, trackLimit), + Artists: make([]SearchArtistResult, 0, artistLimit), + Albums: make([]SearchAlbumResult, 0, albumLimit), + Playlists: make([]SearchPlaylistResult, 0), + } + + if trackLimit > 0 { + tracks, err := q.searchQobuzTracksWithFallback(cleanQuery, trackLimit) + if err != nil { + GoLog("[Qobuz] Track search failed: %v\n", err) + return nil, fmt.Errorf("qobuz track search failed: %w", err) + } + GoLog("[Qobuz] Got %d tracks from API\n", len(tracks)) + for i := range tracks { + result.Tracks = append(result.Tracks, qobuzTrackToTrackMetadata(&tracks[i])) + } + } + + if artistLimit > 0 { + searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/artist/search?query=%s&limit=%d&app_id=%s", + url.QueryEscape(cleanQuery), artistLimit, q.appID) + req, err := http.NewRequest("GET", searchURL, nil) + if err == nil { + resp, reqErr := DoRequestWithUserAgent(q.client, req) + if reqErr == nil { + defer resp.Body.Close() + if resp.StatusCode == 200 { + var artistResp struct { + Artists struct { + Items []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Image qobuzImageSet `json:"image"` + } `json:"items"` + } `json:"artists"` + } + if decErr := json.NewDecoder(resp.Body).Decode(&artistResp); decErr == nil { + GoLog("[Qobuz] Got %d artists from API\n", len(artistResp.Artists.Items)) + for _, artist := range artistResp.Artists.Items { + imageURL := qobuzFirstNonEmpty(artist.Image.Large, artist.Image.Small, artist.Image.Thumbnail) + result.Artists = append(result.Artists, SearchArtistResult{ + ID: qobuzPrefixedNumericID(artist.ID), + Name: strings.TrimSpace(artist.Name), + Images: imageURL, + }) + } + } else { + GoLog("[Qobuz] Artist search decode failed: %v\n", decErr) + } + } + } else { + GoLog("[Qobuz] Artist search request failed: %v\n", reqErr) + } + } + } + + if albumLimit > 0 { + searchURL := fmt.Sprintf("https://www.qobuz.com/api.json/0.2/album/search?query=%s&limit=%d&app_id=%s", + url.QueryEscape(cleanQuery), albumLimit, q.appID) + req, err := http.NewRequest("GET", searchURL, nil) + if err == nil { + resp, reqErr := DoRequestWithUserAgent(q.client, req) + if reqErr == nil { + defer resp.Body.Close() + if resp.StatusCode == 200 { + var albumResp struct { + Albums struct { + Items []qobuzAlbumDetails `json:"items"` + } `json:"albums"` + } + if decErr := json.NewDecoder(resp.Body).Decode(&albumResp); decErr == nil { + GoLog("[Qobuz] Got %d albums from API\n", len(albumResp.Albums.Items)) + for i := range albumResp.Albums.Items { + album := &albumResp.Albums.Items[i] + result.Albums = append(result.Albums, SearchAlbumResult{ + ID: qobuzPrefixedID(album.ID), + Name: strings.TrimSpace(album.Title), + Artists: qobuzArtistsDisplayName(album.Artists, album.Artist.Name), + Images: qobuzAlbumImage(album), + ReleaseDate: qobuzNormalizeReleaseDate(album.ReleaseDateOriginal), + TotalTracks: album.TracksCount, + AlbumType: qobuzNormalizeAlbumType(album.ReleaseType, album.ProductType, album.TracksCount), + }) + } + } else { + GoLog("[Qobuz] Album search decode failed: %v\n", decErr) + } + } + } else { + GoLog("[Qobuz] Album search request failed: %v\n", reqErr) + } + } + } + + GoLog("[Qobuz] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums)) + return result, nil +} + func (q *QobuzDownloader) SearchTrackByMetadataWithDuration(trackName, artistName string, expectedDurationSec int) (*QobuzTrack, error) { queries := []string{} diff --git a/go_backend/tidal.go b/go_backend/tidal.go index de8b1039..34de563e 100644 --- a/go_backend/tidal.go +++ b/go_backend/tidal.go @@ -874,6 +874,121 @@ func (t *TidalDownloader) SearchTracks(query string, limit int) ([]ExtTrackMetad return results, nil } +// SearchAll searches Tidal for tracks, artists, and albums matching the query. +// Returns results in the same SearchAllResult format as Deezer's SearchAll. +func (t *TidalDownloader) SearchAll(query string, trackLimit, artistLimit int, filter string) (*SearchAllResult, error) { + GoLog("[Tidal] SearchAll: query=%q, trackLimit=%d, artistLimit=%d, filter=%q\n", query, trackLimit, artistLimit, filter) + + cleanQuery := strings.TrimSpace(query) + if cleanQuery == "" { + return nil, fmt.Errorf("empty tidal search query") + } + + albumLimit := 5 + + if filter != "" { + switch filter { + case "track": + trackLimit = 50 + artistLimit = 0 + albumLimit = 0 + case "artist": + trackLimit = 0 + artistLimit = 20 + albumLimit = 0 + case "album": + trackLimit = 0 + artistLimit = 0 + albumLimit = 20 + } + } + + result := &SearchAllResult{ + Tracks: make([]TrackMetadata, 0, trackLimit), + Artists: make([]SearchArtistResult, 0, artistLimit), + Albums: make([]SearchAlbumResult, 0, albumLimit), + Playlists: make([]SearchPlaylistResult, 0), + } + + if trackLimit > 0 { + page, err := t.getTrackSearchPage(cleanQuery, trackLimit) + if err != nil { + GoLog("[Tidal] Track search failed: %v\n", err) + return nil, fmt.Errorf("tidal track search failed: %w", err) + } + GoLog("[Tidal] Got %d tracks from API\n", len(page.Items)) + for i := range page.Items { + result.Tracks = append(result.Tracks, tidalTrackToTrackMetadata(&page.Items[i])) + } + } + + if artistLimit > 0 { + requestURL := tidalBuildMetadataURL("search/artists", url.Values{ + "query": {cleanQuery}, + "limit": {strconv.Itoa(artistLimit)}, + "offset": {"0"}, + }) + var artistResp struct { + Items []struct { + ID int64 `json:"id"` + Name string `json:"name"` + Picture string `json:"picture"` + Popularity int `json:"popularity"` + URL string `json:"url"` + } `json:"items"` + } + if err := t.getTidalMetadataJSON(requestURL, &artistResp); err == nil { + GoLog("[Tidal] Got %d artists from API\n", len(artistResp.Items)) + for _, artist := range artistResp.Items { + result.Artists = append(result.Artists, SearchArtistResult{ + ID: tidalPrefixedNumericID(artist.ID), + Name: strings.TrimSpace(artist.Name), + Images: tidalImageURL(artist.Picture, "750x750"), + Followers: 0, + Popularity: artist.Popularity, + }) + } + } else { + GoLog("[Tidal] Artist search failed: %v\n", err) + } + } + + if albumLimit > 0 { + requestURL := tidalBuildMetadataURL("search/albums", url.Values{ + "query": {cleanQuery}, + "limit": {strconv.Itoa(albumLimit)}, + "offset": {"0"}, + }) + var albumResp struct { + Items []tidalPublicAlbum `json:"items"` + } + if err := t.getTidalMetadataJSON(requestURL, &albumResp); err == nil { + GoLog("[Tidal] Got %d albums from API\n", len(albumResp.Items)) + for i := range albumResp.Items { + album := &albumResp.Items[i] + albumType := strings.ToLower(strings.TrimSpace(album.Type)) + if albumType == "" { + albumType = "album" + } + result.Albums = append(result.Albums, SearchAlbumResult{ + ID: tidalPrefixedNumericID(album.ID), + Name: strings.TrimSpace(album.Title), + Artists: tidalAlbumArtistsDisplay(album), + Images: tidalImageURL(album.Cover, "1280x1280"), + ReleaseDate: strings.TrimSpace(album.ReleaseDate), + TotalTracks: album.NumberOfTracks, + AlbumType: albumType, + }) + } + } else { + GoLog("[Tidal] Album search failed: %v\n", err) + } + } + + GoLog("[Tidal] SearchAll complete: %d tracks, %d artists, %d albums\n", len(result.Tracks), len(result.Artists), len(result.Albums)) + return result, nil +} + func (t *TidalDownloader) GetTrackMetadata(resourceID string) (*TrackResponse, error) { track, err := t.getPublicTrack(resourceID) if err != nil { diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 5bdc9792..740a9fd3 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -367,6 +367,26 @@ import Gobackend // Import Go framework if let error = error { throw error } return response + case "searchTidalAll": + let args = call.arguments as! [String: Any] + let query = args["query"] as! String + let trackLimit = args["track_limit"] as? Int ?? 15 + let artistLimit = args["artist_limit"] as? Int ?? 3 + let filter = args["filter"] as? String ?? "" + let response = GobackendSearchTidalAll(query, Int(trackLimit), Int(artistLimit), filter, &error) + if let error = error { throw error } + return response + + case "searchQobuzAll": + let args = call.arguments as! [String: Any] + let query = args["query"] as! String + let trackLimit = args["track_limit"] as? Int ?? 15 + let artistLimit = args["artist_limit"] as? Int ?? 3 + let filter = args["filter"] as? String ?? "" + let response = GobackendSearchQobuzAll(query, Int(trackLimit), Int(artistLimit), filter, &error) + if let error = error { throw error } + return response + case "getDeezerRelatedArtists": let args = call.arguments as! [String: Any] let artistId = args["artist_id"] as! String diff --git a/lib/main.dart b/lib/main.dart index 5ac51db6..89d5d93c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -222,10 +222,12 @@ class _EagerInitializationState extends ConsumerState<_EagerInitialization> // All checks passed -- start an incremental scan. final iosBookmark = settings.localLibraryBookmark; - ref.read(localLibraryProvider.notifier).startScan( - settings.localLibraryPath, - iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, - ); + ref + .read(localLibraryProvider.notifier) + .startScan( + settings.localLibraryPath, + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); } Future _initializeAppServices() async { diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index c07f50a6..22c834bd 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -3642,6 +3642,15 @@ class DownloadQueueNotifier extends Notifier { e.hasDownloadProvider && e.id.toLowerCase() == item.service.toLowerCase(), ); + final trackSource = (trackToDownload.source ?? '').trim().toLowerCase(); + final shouldSkipExtensionSongLinkPrelookup = + trackSource.isNotEmpty && + extensionState.extensions.any( + (e) => + e.enabled && + e.hasMetadataProvider && + e.id.toLowerCase() == trackSource, + ); String? deezerTrackId = trackToDownload.deezerId; if (deezerTrackId == null && trackToDownload.id.startsWith('deezer:')) { @@ -3678,6 +3687,7 @@ class DownloadQueueNotifier extends Notifier { // Fallback: Use SongLink to convert Spotify ID to Deezer ID if (!selectedExtensionDownloadProvider && deezerTrackId == null && + !shouldSkipExtensionSongLinkPrelookup && trackToDownload.id.isNotEmpty && !trackToDownload.id.startsWith('deezer:') && !trackToDownload.id.startsWith('extension:')) { @@ -3782,6 +3792,11 @@ class DownloadQueueNotifier extends Notifier { _log.d( 'Skipping Flutter SongLink Deezer prelookup for extension provider: ${item.service}', ); + } else if (shouldSkipExtensionSongLinkPrelookup && + deezerTrackId == null) { + _log.d( + 'Skipping Flutter SongLink Deezer prelookup for extension-sourced track; backend metadata enrichment will resolve identifiers first', + ); } if (deezerTrackId != null && deezerTrackId.isNotEmpty) { @@ -4179,8 +4194,9 @@ class DownloadQueueNotifier extends Notifier { progress: 0.95, ); - final format = - tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3'; + final format = tidalHighFormat.startsWith('opus') + ? 'opus' + : 'mp3'; convertedPath = await FFmpegService.convertM4aToLossy( tempPath, format: format, @@ -4359,8 +4375,9 @@ class DownloadQueueNotifier extends Notifier { progress: 0.95, ); - final format = - tidalHighFormat.startsWith('opus') ? 'opus' : 'mp3'; + final format = tidalHighFormat.startsWith('opus') + ? 'opus' + : 'mp3'; final convertedPath = await FFmpegService.convertM4aToLossy( currentFilePath, format: format, diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index 19eafa75..43f25997 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -504,6 +504,11 @@ class ExtensionNotifier extends Notifier { } Future _cleanupExtensions({required String reason}) async { + if (!PlatformBridge.supportsExtensionSystem) { + _cleanupInFlight = false; + return; + } + try { await PlatformBridge.cleanupExtensions(); _log.d('Extensions cleaned up ($reason)'); @@ -519,6 +524,17 @@ class ExtensionNotifier extends Notifier { state = state.copyWith(isLoading: true, error: null); + if (!PlatformBridge.supportsExtensionSystem) { + state = state.copyWith( + isInitialized: true, + isLoading: false, + extensions: const [], + error: null, + ); + _log.i('Extension system disabled on this platform'); + return; + } + try { await PlatformBridge.initExtensionSystem(extensionsDir, dataDir); await loadExtensions(extensionsDir); diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index a32277e7..7b89df1a 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -53,6 +53,8 @@ class SettingsNotifier extends Notifier { } void _syncLyricsSettingsToBackend() { + if (!PlatformBridge.supportsCoreBackend) return; + PlatformBridge.setLyricsProviders(state.lyricsProviders).catchError((e) { _log.w('Failed to sync lyrics providers to backend: $e'); }); @@ -68,6 +70,8 @@ class SettingsNotifier extends Notifier { } void _syncNetworkCompatibilitySettingsToBackend() { + if (!PlatformBridge.supportsCoreBackend) return; + final compatibilityMode = state.networkCompatibilityMode; PlatformBridge.setNetworkCompatibilityOptions( allowHttp: compatibilityMode, diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 8947dba0..eee4fc4b 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -30,6 +30,8 @@ class TrackState { searchExtensionId; // Extension ID used for current search results final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist") + final String? + searchSource; // Built-in search provider used for current results (e.g., "deezer", "tidal", "qobuz") const TrackState({ this.tracks = const [], @@ -52,6 +54,7 @@ class TrackState { this.isShowingRecentAccess = false, this.searchExtensionId, this.selectedSearchFilter, + this.searchSource, }); bool get hasContent => @@ -83,6 +86,8 @@ class TrackState { String? searchExtensionId, String? selectedSearchFilter, bool clearSelectedSearchFilter = false, + String? searchSource, + bool clearSearchSource = false, }) { return TrackState( tracks: tracks ?? this.tracks, @@ -108,6 +113,9 @@ class TrackState { selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter), + searchSource: clearSearchSource + ? null + : (searchSource ?? this.searchSource), ); } } @@ -618,7 +626,11 @@ class TrackNotifier extends Notifier { } } - Future search(String query, {String? filterOverride}) async { + Future search( + String query, { + String? filterOverride, + String? builtInSearchProvider, + }) async { final requestId = ++_currentRequestId; // Preserve selected filter during loading @@ -640,39 +652,68 @@ class TrackNotifier extends Notifier { final includeExtensions = settings.useExtensionProviders && hasActiveMetadataExtensions; + // Determine the effective search provider + final effectiveProvider = builtInSearchProvider ?? 'deezer'; + _log.i( - 'Search started: metadataProviders, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter', + 'Search started: provider=$effectiveProvider, query="$query", includeExtensions=$includeExtensions, filter=$currentFilter', ); Map results; List> metadataTrackResults = []; - try { - _log.d('Calling metadata provider search API...'); - metadataTrackResults = - await PlatformBridge.searchTracksWithMetadataProviders( - query, - limit: 20, - includeExtensions: includeExtensions, - ); - _log.i( - 'Metadata providers returned ${metadataTrackResults.length} tracks', - ); - } catch (e) { - _log.w( - 'Metadata provider search failed, falling back to Deezer tracks: $e', - ); + // Only use metadata providers for Deezer search (default behavior) + if (effectiveProvider == 'deezer') { + try { + _log.d('Calling metadata provider search API...'); + metadataTrackResults = + await PlatformBridge.searchTracksWithMetadataProviders( + query, + limit: 20, + includeExtensions: includeExtensions, + ); + _log.i( + 'Metadata providers returned ${metadataTrackResults.length} tracks', + ); + } catch (e) { + _log.w( + 'Metadata provider search failed, falling back to Deezer tracks: $e', + ); + } } - _log.d('Calling Deezer search API...'); - results = await PlatformBridge.searchDeezerAll( - query, - trackLimit: 20, - artistLimit: 2, - filter: currentFilter, - ); + // Call the appropriate search API + switch (effectiveProvider) { + case 'tidal': + _log.d('Calling Tidal search API...'); + results = await PlatformBridge.searchTidalAll( + query, + trackLimit: 20, + artistLimit: 2, + filter: currentFilter, + ); + break; + case 'qobuz': + _log.d('Calling Qobuz search API...'); + results = await PlatformBridge.searchQobuzAll( + query, + trackLimit: 20, + artistLimit: 2, + filter: currentFilter, + ); + break; + default: + _log.d('Calling Deezer search API...'); + results = await PlatformBridge.searchDeezerAll( + query, + trackLimit: 20, + artistLimit: 2, + filter: currentFilter, + ); + break; + } _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', + '$effectiveProvider 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)) { @@ -758,6 +799,8 @@ class TrackNotifier extends Notifier { hasSearchText: state.hasSearchText, isShowingRecentAccess: state.isShowingRecentAccess, selectedSearchFilter: currentFilter, // Preserve filter in results + searchSource: + effectiveProvider, // Track which service was used for search ); } catch (e, stackTrace) { if (!_isRequestValid(requestId)) return; diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index 1cffcf2d..eeef064e 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -489,6 +489,9 @@ class _HomeTabState extends ConsumerState if (searchProvider == null || searchProvider.isEmpty) return false; + // Built-in providers (tidal, qobuz) also support live search + if (_builtInSearchProviders.contains(searchProvider)) return true; + final extension = extState.extensions .where((e) => e.id == searchProvider && e.enabled) .firstOrNull; @@ -546,6 +549,9 @@ class _HomeTabState extends ConsumerState } } + /// Built-in search providers that are not extensions + static const _builtInSearchProviders = {'tidal', 'qobuz'}; + Future _performSearch(String query, {String? filterOverride}) async { final settings = ref.read(settingsProvider); final extState = ref.read(extensionProvider); @@ -558,9 +564,14 @@ class _HomeTabState extends ConsumerState if (_lastSearchQuery == searchKey) return; _lastSearchQuery = searchKey; + final isBuiltInProvider = + searchProvider != null && + _builtInSearchProviders.contains(searchProvider); + final isExtensionEnabled = searchProvider != null && searchProvider.isNotEmpty && + !isBuiltInProvider && extState.extensions.any((e) => e.id == searchProvider && e.enabled); if (isExtensionEnabled) { @@ -571,10 +582,20 @@ class _HomeTabState extends ConsumerState await ref .read(trackProvider.notifier) .customSearch(searchProvider, query, options: options); + } else if (isBuiltInProvider) { + // Use built-in Tidal or Qobuz search + await ref + .read(trackProvider.notifier) + .search( + query, + filterOverride: selectedFilter, + builtInSearchProvider: searchProvider, + ); } else { if (searchProvider != null && searchProvider.isNotEmpty && - !isExtensionEnabled) { + !isExtensionEnabled && + !isBuiltInProvider) { ref.read(settingsProvider.notifier).setSearchProvider(null); } await ref @@ -718,6 +739,7 @@ class _HomeTabState extends ConsumerState trackName: track.name, artistName: track.artistName, coverUrl: track.coverUrl, + recommendedService: trackState.searchSource, onSelect: (quality, service) { ref .read(downloadQueueProvider.notifier) @@ -2770,6 +2792,14 @@ class _HomeTabState extends ConsumerState } if (searchProvider != null && searchProvider.isNotEmpty) { + // Check built-in providers first + if (searchProvider == 'tidal') { + return 'Search with Tidal...'; + } + if (searchProvider == 'qobuz') { + return 'Search with Qobuz...'; + } + final ext = extState.extensions .where((e) => e.id == searchProvider) .firstOrNull; @@ -3004,6 +3034,11 @@ class _SearchProviderDropdown extends ConsumerWidget { .firstOrNull; } + // Check if current provider is a built-in provider (tidal/qobuz) + const builtInProviders = {'tidal', 'qobuz'}; + final isBuiltInProvider = + currentProvider != null && builtInProviders.contains(currentProvider); + IconData displayIcon = Icons.search; String? iconPath; if (currentExt != null) { @@ -3011,10 +3046,8 @@ class _SearchProviderDropdown extends ConsumerWidget { if (currentExt.searchBehavior?.icon != null) { displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); } - } - - if (searchProviders.isEmpty) { - return const Icon(Icons.search); + } else if (isBuiltInProvider) { + displayIcon = Icons.music_note; } return Padding( @@ -3081,6 +3114,62 @@ class _SearchProviderDropdown extends ConsumerWidget { ], ), ), + // Built-in Tidal search option + PopupMenuItem( + 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, + ), + ), + ), + if (currentProvider == 'tidal') + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), + ), + // Built-in Qobuz search option + PopupMenuItem( + 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 (searchProviders.isNotEmpty) const PopupMenuDivider(), ...searchProviders.map( (ext) => PopupMenuItem( diff --git a/lib/screens/settings/download_settings_page.dart b/lib/screens/settings/download_settings_page.dart index d10851e2..85470e99 100644 --- a/lib/screens/settings/download_settings_page.dart +++ b/lib/screens/settings/download_settings_page.dart @@ -1191,7 +1191,7 @@ class _DownloadSettingsPageState extends ConsumerState { Future _pickDirectory(BuildContext context, WidgetRef ref) async { if (Platform.isIOS) { _showIOSDirectoryOptions(context, ref); - } else { + } else if (Platform.isAndroid) { _showAndroidDirectoryOptions(context, ref); } } @@ -1626,9 +1626,9 @@ class _DownloadSettingsPageState extends ConsumerState { padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( context.l10n.downloadLossy320Format, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.bold, - ), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), Padding( diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 608eb4bf..86c461a3 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -73,11 +73,13 @@ class _LibrarySettingsPageState extends ConsumerState { } else if (Platform.isIOS) { // iOS doesn't need explicit storage permission for app documents setState(() => _hasStoragePermission = true); + } else { + setState(() => _hasStoragePermission = true); } } Future _requestStoragePermission() async { - if (Platform.isIOS) return true; + if (!Platform.isAndroid) return true; // SAF on Android 10+ doesn't need MANAGE_EXTERNAL_STORAGE if (_androidSdkVersion >= 29) return true; @@ -135,8 +137,9 @@ class _LibrarySettingsPageState extends ConsumerState { if (Platform.isIOS) { // On iOS, create a security-scoped bookmark so we can access // this folder across app restarts and from the Go backend. - final bookmark = - await PlatformBridge.createIosBookmarkFromPath(result); + final bookmark = await PlatformBridge.createIosBookmarkFromPath( + result, + ); if (bookmark != null && bookmark.isNotEmpty) { ref .read(settingsProvider.notifier) @@ -182,11 +185,13 @@ class _LibrarySettingsPageState extends ConsumerState { return; } - await ref.read(localLibraryProvider.notifier).startScan( - libraryPath, - forceFullScan: forceFullScan, - iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, - ); + await ref + .read(localLibraryProvider.notifier) + .startScan( + libraryPath, + forceFullScan: forceFullScan, + iosBookmark: iosBookmark.isNotEmpty ? iosBookmark : null, + ); } Future _cancelScan() async { @@ -272,10 +277,9 @@ class _LibrarySettingsPageState extends ConsumerState { padding: const EdgeInsets.fromLTRB(24, 24, 24, 8), child: Text( context.l10n.libraryAutoScan, - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(fontWeight: FontWeight.bold), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), ), ), Padding( @@ -293,7 +297,9 @@ class _LibrarySettingsPageState extends ConsumerState { selected: current == 'off', colorScheme: colorScheme, onTap: () { - ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('off'); + ref + .read(settingsProvider.notifier) + .setLocalLibraryAutoScan('off'); Navigator.pop(context); }, ), @@ -303,7 +309,9 @@ class _LibrarySettingsPageState extends ConsumerState { selected: current == 'on_open', colorScheme: colorScheme, onTap: () { - ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('on_open'); + ref + .read(settingsProvider.notifier) + .setLocalLibraryAutoScan('on_open'); Navigator.pop(context); }, ), @@ -313,7 +321,9 @@ class _LibrarySettingsPageState extends ConsumerState { selected: current == 'daily', colorScheme: colorScheme, onTap: () { - ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('daily'); + ref + .read(settingsProvider.notifier) + .setLocalLibraryAutoScan('daily'); Navigator.pop(context); }, ), @@ -323,7 +333,9 @@ class _LibrarySettingsPageState extends ConsumerState { selected: current == 'weekly', colorScheme: colorScheme, onTap: () { - ref.read(settingsProvider.notifier).setLocalLibraryAutoScan('weekly'); + ref + .read(settingsProvider.notifier) + .setLocalLibraryAutoScan('weekly'); Navigator.pop(context); }, ), @@ -443,9 +455,15 @@ class _LibrarySettingsPageState extends ConsumerState { child: SettingsItem( icon: Icons.autorenew_rounded, title: context.l10n.libraryAutoScan, - subtitle: _getAutoScanLabel(context, settings.localLibraryAutoScan), + subtitle: _getAutoScanLabel( + context, + settings.localLibraryAutoScan, + ), onTap: settings.localLibraryEnabled - ? () => _showAutoScanPicker(context, settings.localLibraryAutoScan) + ? () => _showAutoScanPicker( + context, + settings.localLibraryAutoScan, + ) : null, showDivider: false, ), @@ -950,9 +968,7 @@ class _AutoScanOption extends StatelessWidget { return ListTile( leading: Icon(icon), title: Text(title), - trailing: selected - ? Icon(Icons.check, color: colorScheme.primary) - : null, + trailing: selected ? Icon(Icons.check, color: colorScheme.primary) : null, onTap: onTap, ); } diff --git a/lib/screens/setup_screen.dart b/lib/screens/setup_screen.dart index 6d12825d..5f137482 100644 --- a/lib/screens/setup_screen.dart +++ b/lib/screens/setup_screen.dart @@ -91,6 +91,11 @@ class _SetupScreenState extends ConsumerState { _notificationPermissionGranted = notificationStatus.isGranted; }); } + } else { + setState(() { + _storagePermissionGranted = true; + _notificationPermissionGranted = true; + }); } } @@ -139,6 +144,8 @@ class _SetupScreenState extends ConsumerState { SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)), ); } + } else { + setState(() => _storagePermissionGranted = true); } } catch (e) { debugPrint('Permission error: $e'); @@ -225,7 +232,7 @@ class _SetupScreenState extends ConsumerState { try { if (Platform.isIOS) { await _showIOSDirectoryOptions(); - } else { + } else if (Platform.isAndroid) { final result = await PlatformBridge.pickSafTree(); if (result != null) { final treeUri = result['tree_uri'] as String? ?? ''; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 99696825..e2c1df83 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/services.dart'; import 'package:spotiflac_android/services/download_request_payload.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -14,6 +15,11 @@ class PlatformBridge { 'com.zarz.spotiflac/library_scan_progress_stream', ); + static bool get supportsCoreBackend => Platform.isAndroid || Platform.isIOS; + + static bool get supportsExtensionSystem => + Platform.isAndroid || Platform.isIOS; + static Future> parseSpotifyUrl(String url) async { _log.d('parseSpotifyUrl: $url'); final result = await _channel.invokeMethod('parseSpotifyUrl', {'url': url}); @@ -503,6 +509,36 @@ class PlatformBridge { return jsonDecode(result as String) as Map; } + static Future> searchTidalAll( + 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; + } + + static Future> searchQobuzAll( + String query, { + int trackLimit = 15, + int artistLimit = 2, + String? filter, + }) async { + final result = await _channel.invokeMethod('searchQobuzAll', { + 'query': query, + 'track_limit': trackLimit, + 'artist_limit': artistLimit, + 'filter': filter ?? '', + }); + return jsonDecode(result as String) as Map; + } + static Future> getDeezerRelatedArtists( String artistId, { int limit = 12, diff --git a/lib/services/share_intent_service.dart b/lib/services/share_intent_service.dart index 4f8867b1..e958394f 100644 --- a/lib/services/share_intent_service.dart +++ b/lib/services/share_intent_service.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:receive_sharing_intent/receive_sharing_intent.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -10,8 +11,9 @@ class ShareIntentService { ShareIntentService._internal(); // Spotify patterns - static final RegExp _spotifyUriPattern = - RegExp(r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+'); + static final RegExp _spotifyUriPattern = RegExp( + r'spotify:(track|album|playlist|artist):[a-zA-Z0-9]+', + ); static final RegExp _spotifyUrlPattern = RegExp( r'https?://open\.spotify\.com/(track|album|playlist|artist)/[a-zA-Z0-9]+(\?[^\s]*)?', ); @@ -56,6 +58,11 @@ class ShareIntentService { if (_initialized) return; _initialized = true; + if (!Platform.isAndroid && !Platform.isIOS) { + _log.i('Share intent is not supported on this platform'); + return; + } + _mediaSubscription = ReceiveSharingIntent.instance.getMediaStream().listen( _handleSharedMedia, onError: (err) => _log.e('Error: $err'), @@ -68,14 +75,14 @@ class ShareIntentService { } } - void _handleSharedMedia(List files, {bool isInitial = false}) { + void _handleSharedMedia( + List files, { + bool isInitial = false, + }) { for (final file in files) { // Check both path and message - apps may share URL in either field - final textsToCheck = [ - file.path, - if (file.message != null) file.message!, - ]; - + final textsToCheck = [file.path, if (file.message != null) file.message!]; + for (final textToCheck in textsToCheck) { final url = _extractMusicUrl(textToCheck); if (url != null) { diff --git a/lib/services/update_checker.dart b/lib/services/update_checker.dart index 3a2fb50f..c24b47f0 100644 --- a/lib/services/update_checker.dart +++ b/lib/services/update_checker.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:io'; import 'package:http/http.dart' as http; import 'package:spotiflac_android/constants/app_info.dart'; import 'package:spotiflac_android/utils/logger.dart'; @@ -24,20 +25,28 @@ class UpdateInfo { } class UpdateChecker { - static const String _latestApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; - static const String _allReleasesApiUrl = 'https://api.github.com/repos/${AppInfo.githubRepo}/releases'; + static const String _latestApiUrl = + 'https://api.github.com/repos/${AppInfo.githubRepo}/releases/latest'; + static const String _allReleasesApiUrl = + 'https://api.github.com/repos/${AppInfo.githubRepo}/releases'; /// Check for updates based on channel preference /// [channel] can be 'stable' or 'preview' static Future checkForUpdate({String channel = 'stable'}) async { + if (!Platform.isAndroid) { + return null; + } + try { Map? releaseData; - + if (channel == 'preview') { - final response = await http.get( - Uri.parse('$_allReleasesApiUrl?per_page=10'), - headers: {'Accept': 'application/vnd.github.v3+json'}, - ).timeout(const Duration(seconds: 10)); + final response = await http + .get( + Uri.parse('$_allReleasesApiUrl?per_page=10'), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ) + .timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { _log.w('GitHub API returned ${response.statusCode}'); @@ -49,13 +58,15 @@ class UpdateChecker { _log.i('No releases found'); return null; } - + releaseData = releases.first as Map; } else { - final response = await http.get( - Uri.parse(_latestApiUrl), - headers: {'Accept': 'application/vnd.github.v3+json'}, - ).timeout(const Duration(seconds: 10)); + final response = await http + .get( + Uri.parse(_latestApiUrl), + headers: {'Accept': 'application/vnd.github.v3+json'}, + ) + .timeout(const Duration(seconds: 10)); if (response.statusCode != 200) { _log.w('GitHub API returned ${response.statusCode}'); @@ -68,19 +79,24 @@ class UpdateChecker { final tagName = releaseData['tag_name'] as String? ?? ''; final latestVersion = tagName.replaceFirst('v', ''); final isPrerelease = releaseData['prerelease'] as bool? ?? false; - + if (!_isNewerVersion(latestVersion, AppInfo.version)) { - _log.i('No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)'); + _log.i( + 'No update available (current: ${AppInfo.version}, latest: $latestVersion, channel: $channel)', + ); return null; } final body = releaseData['body'] as String? ?? 'No changelog available'; - final htmlUrl = releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; - final publishedAt = DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? DateTime.now(); + final htmlUrl = + releaseData['html_url'] as String? ?? '${AppInfo.githubUrl}/releases'; + final publishedAt = + DateTime.tryParse(releaseData['published_at'] as String? ?? '') ?? + DateTime.now(); String? arm64Url; String? universalUrl; - + final assets = releaseData['assets'] as List? ?? []; for (final asset in assets) { final name = (asset['name'] as String? ?? '').toLowerCase(); @@ -98,12 +114,14 @@ class UpdateChecker { } } } - + // Only arm64 is supported; fall back to universal if available final apkUrl = arm64Url ?? universalUrl; - _log.i('Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl'); - + _log.i( + 'Update available: $latestVersion (prerelease: $isPrerelease), APK URL: $apkUrl', + ); + return UpdateInfo( version: latestVersion, changelog: body, @@ -122,7 +140,7 @@ class UpdateChecker { try { final latestBase = latest.split('-').first; final currentBase = current.split('-').first; - + final latestParts = latestBase.split('.').map(int.parse).toList(); final currentParts = currentBase.split('.').map(int.parse).toList(); @@ -137,12 +155,12 @@ class UpdateChecker { if (latestParts[i] > currentParts[i]) return true; if (latestParts[i] < currentParts[i]) return false; } - + final latestHasSuffix = latest.contains('-'); final currentHasSuffix = current.contains('-'); - + if (!latestHasSuffix && currentHasSuffix) return true; - + return false; } catch (e) { _log.e('Error comparing versions: $e'); diff --git a/lib/widgets/download_service_picker.dart b/lib/widgets/download_service_picker.dart index 3b8bac12..523970b9 100644 --- a/lib/widgets/download_service_picker.dart +++ b/lib/widgets/download_service_picker.dart @@ -102,6 +102,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { final String? artistName; final String? coverUrl; final void Function(String quality, String service) onSelect; + final String? recommendedService; // Service to show as "(Recommended)" const DownloadServicePicker({ super.key, @@ -109,6 +110,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { this.artistName, this.coverUrl, required this.onSelect, + this.recommendedService, }); @override @@ -121,6 +123,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { String? trackName, String? artistName, String? coverUrl, + String? recommendedService, required void Function(String quality, String service) onSelect, }) { final colorScheme = Theme.of(context).colorScheme; @@ -138,6 +141,7 @@ class DownloadServicePicker extends ConsumerStatefulWidget { artistName: artistName, coverUrl: coverUrl, onSelect: onSelect, + recommendedService: recommendedService, ), ); } @@ -152,7 +156,13 @@ class _DownloadServicePickerState extends ConsumerState { @override void initState() { super.initState(); - _selectedService = ref.read(settingsProvider).defaultService; + // Default to recommended service if available, otherwise use default + final recommended = widget.recommendedService; + if (recommended != null && recommended.isNotEmpty) { + _selectedService = recommended; + } else { + _selectedService = ref.read(settingsProvider).defaultService; + } } /// Get quality options for the selected service @@ -282,6 +292,8 @@ class _DownloadServicePickerState extends ConsumerState { _ServiceChip( label: service.isDisabled ? '${service.label} (${service.disabledReason})' + : widget.recommendedService == service.id + ? '${service.label} (Recommended)' : service.label, isSelected: _selectedService == service.id, isDisabled: service.isDisabled, diff --git a/pubspec.lock b/pubspec.lock index 0370159a..5f81e3da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -169,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" code_builder: dependency: transitive description: @@ -509,6 +517,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.2" + hooks: + dependency: transitive + description: + name: hooks + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + url: "https://pub.dev" + source: hosted + version: "1.0.2" http: dependency: "direct main" description: @@ -661,6 +677,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.6.1" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" nm: dependency: transitive description: @@ -1082,6 +1106,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.5.6" + sqflite_common_ffi: + dependency: "direct main" + description: + name: sqflite_common_ffi + sha256: c59fcdc143839a77581f7a7c4de018e53682408903a0a0800b95ef2dc4033eff + url: "https://pub.dev" + source: hosted + version: "2.4.0+2" sqflite_darwin: dependency: transitive description: @@ -1098,6 +1130,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + sqlite3: + dependency: transitive + description: + name: sqlite3 + sha256: caa693ad15a587a2b4fde093b728131a1827903872171089dedb16f7665d3a91 + url: "https://pub.dev" + source: hosted + version: "3.2.0" stack_trace: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1bf2b50f..0d962edc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: path_provider: ^2.1.5 path: ^1.9.0 sqflite: ^2.4.1 + sqflite_common_ffi: ^2.3.6 # HTTP & Network http: ^1.6.0