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
This commit is contained in:
zarzet
2026-03-25 13:51:38 +07:00
parent 98fdc0ed7c
commit 4f365ca7fe
20 changed files with 720 additions and 97 deletions
@@ -2642,6 +2642,28 @@ class MainActivity: FlutterFragmentActivity() {
}
result.success(response)
}
// Tidal search API
"searchTidalAll" -> {
val query = call.argument<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("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<String>("query") ?: ""
val trackLimit = call.argument<Int>("track_limit") ?: 15
val artistLimit = call.argument<Int>("artist_limit") ?: 2
val filter = call.argument<String>("filter") ?: ""
val response = withContext(Dispatchers.IO) {
Gobackend.searchQobuzAll(query, trackLimit.toLong(), artistLimit.toLong(), filter)
}
result.success(response)
}
"getDeezerRelatedArtists" -> {
val artistId = call.argument<String>("artist_id") ?: ""
val limit = call.argument<Int>("limit") ?: 12
+30
View File
@@ -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()
+128
View File
@@ -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{}
+115
View File
@@ -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 {
+20
View File
@@ -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
+6 -4
View File
@@ -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<void> _initializeAppServices() async {
+21 -4
View File
@@ -3642,6 +3642,15 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
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<DownloadQueueState> {
// 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<DownloadQueueState> {
_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<DownloadQueueState> {
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<DownloadQueueState> {
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,
+16
View File
@@ -504,6 +504,11 @@ class ExtensionNotifier extends Notifier<ExtensionState> {
}
Future<void> _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<ExtensionState> {
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);
+4
View File
@@ -53,6 +53,8 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
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<AppSettings> {
}
void _syncNetworkCompatibilitySettingsToBackend() {
if (!PlatformBridge.supportsCoreBackend) return;
final compatibilityMode = state.networkCompatibilityMode;
PlatformBridge.setNetworkCompatibilityOptions(
allowHttp: compatibilityMode,
+68 -25
View File
@@ -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<TrackState> {
}
}
Future<void> search(String query, {String? filterOverride}) async {
Future<void> search(
String query, {
String? filterOverride,
String? builtInSearchProvider,
}) async {
final requestId = ++_currentRequestId;
// Preserve selected filter during loading
@@ -640,39 +652,68 @@ class TrackNotifier extends Notifier<TrackState> {
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<String, dynamic> results;
List<Map<String, dynamic>> 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<TrackState> {
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;
+94 -5
View File
@@ -489,6 +489,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
if (searchProvider == null || searchProvider.isEmpty) return false;
// Built-in providers (tidal, qobuz) also support live search
if (_builtInSearchProviders.contains(searchProvider)) return true;
final extension = extState.extensions
.where((e) => e.id == searchProvider && e.enabled)
.firstOrNull;
@@ -546,6 +549,9 @@ class _HomeTabState extends ConsumerState<HomeTab>
}
}
/// Built-in search providers that are not extensions
static const _builtInSearchProviders = {'tidal', 'qobuz'};
Future<void> _performSearch(String query, {String? filterOverride}) async {
final settings = ref.read(settingsProvider);
final extState = ref.read(extensionProvider);
@@ -558,9 +564,14 @@ class _HomeTabState extends ConsumerState<HomeTab>
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<HomeTab>
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<HomeTab>
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<HomeTab>
}
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<String>(
value: 'tidal',
child: Row(
children: [
Icon(
Icons.music_note,
size: 20,
color: currentProvider == 'tidal'
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Tidal',
style: TextStyle(
fontWeight: currentProvider == 'tidal'
? FontWeight.w600
: FontWeight.normal,
),
),
),
if (currentProvider == 'tidal')
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
),
),
// Built-in Qobuz search option
PopupMenuItem<String>(
value: 'qobuz',
child: Row(
children: [
Icon(
Icons.music_note,
size: 20,
color: currentProvider == 'qobuz'
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'Qobuz',
style: TextStyle(
fontWeight: currentProvider == 'qobuz'
? FontWeight.w600
: FontWeight.normal,
),
),
),
if (currentProvider == 'qobuz')
Icon(Icons.check, size: 18, color: colorScheme.primary),
],
),
),
if (searchProviders.isNotEmpty) const PopupMenuDivider(),
...searchProviders.map(
(ext) => PopupMenuItem<String>(
@@ -1191,7 +1191,7 @@ class _DownloadSettingsPageState extends ConsumerState<DownloadSettingsPage> {
Future<void> _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<DownloadSettingsPage> {
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(
+37 -21
View File
@@ -73,11 +73,13 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
} else if (Platform.isIOS) {
// iOS doesn't need explicit storage permission for app documents
setState(() => _hasStoragePermission = true);
} else {
setState(() => _hasStoragePermission = true);
}
}
Future<bool> _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<LibrarySettingsPage> {
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<LibrarySettingsPage> {
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<void> _cancelScan() async {
@@ -272,10 +277,9 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
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<LibrarySettingsPage> {
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<LibrarySettingsPage> {
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<LibrarySettingsPage> {
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<LibrarySettingsPage> {
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<LibrarySettingsPage> {
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,
);
}
+8 -1
View File
@@ -91,6 +91,11 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
_notificationPermissionGranted = notificationStatus.isGranted;
});
}
} else {
setState(() {
_storagePermissionGranted = true;
_notificationPermissionGranted = true;
});
}
}
@@ -139,6 +144,8 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
SnackBar(content: Text(context.l10n.setupPermissionDeniedMessage)),
);
}
} else {
setState(() => _storagePermissionGranted = true);
}
} catch (e) {
debugPrint('Permission error: $e');
@@ -225,7 +232,7 @@ class _SetupScreenState extends ConsumerState<SetupScreen> {
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? ?? '';
+36
View File
@@ -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<Map<String, dynamic>> 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<String, dynamic>;
}
static Future<Map<String, dynamic>> 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<String, dynamic>;
}
static Future<Map<String, dynamic>> searchQobuzAll(
String query, {
int trackLimit = 15,
int artistLimit = 2,
String? filter,
}) async {
final result = await _channel.invokeMethod('searchQobuzAll', {
'query': query,
'track_limit': trackLimit,
'artist_limit': artistLimit,
'filter': filter ?? '',
});
return jsonDecode(result as String) as Map<String, dynamic>;
}
static Future<Map<String, dynamic>> getDeezerRelatedArtists(
String artistId, {
int limit = 12,
+15 -8
View File
@@ -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<SharedMediaFile> files, {bool isInitial = false}) {
void _handleSharedMedia(
List<SharedMediaFile> 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) {
+42 -24
View File
@@ -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<UpdateInfo?> checkForUpdate({String channel = 'stable'}) async {
if (!Platform.isAndroid) {
return null;
}
try {
Map<String, dynamic>? 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<String, dynamic>;
} 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<dynamic>? ?? [];
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');
+13 -1
View File
@@ -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<DownloadServicePicker> {
@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<DownloadServicePicker> {
_ServiceChip(
label: service.isDisabled
? '${service.label} (${service.disabledReason})'
: widget.recommendedService == service.id
? '${service.label} (Recommended)'
: service.label,
isSelected: _selectedService == service.id,
isDisabled: service.isDisabled,
+40
View File
@@ -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:
+1
View File
@@ -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