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
+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,