mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-05 01:26:45 +02:00
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:
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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? ?? '';
|
||||
|
||||
Reference in New Issue
Block a user