diff --git a/CHANGELOG.md b/CHANGELOG.md index 0569a894..f79544f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,24 @@ # Changelog -## [3.1.2] - 2026-01-18 +## [3.1.2] - 2026-01-19 ### Added +- **Quick Search Provider Switcher**: Dropdown menu in search bar for instant provider switching + - Tap the search icon to reveal a dropdown menu with all available search providers + - Shows default provider (Deezer/Spotify based on metadata source setting) at the top + - Lists all enabled extensions with custom search capability + - Displays extension icons when available + - Checkmark indicates currently selected provider + - Search hint text updates immediately when switching providers + - Re-triggers search automatically if there's existing text in the search bar + - Eliminates need to navigate to Settings > Extensions > Search Provider + - **Genre & Label Metadata**: Downloaded tracks now include genre and record label information - Fetches genre and label from Deezer album API for each track - Embeds GENRE, ORGANIZATION (label), and COPYRIGHT tags into FLAC files - Works automatically when Deezer track ID is available (via ISRC matching) - - Supports all download services (Tidal, Qobuz, Amazon) + - Supports all download services (Tidal, Qobuz, Amazon) and extension downloads - **MP3 Quality Option**: Optional MP3 download format with FLAC-to-MP3 conversion - New "Enable MP3 Option" toggle in Settings > Download > Audio Quality diff --git a/go_backend/extension_providers.go b/go_backend/extension_providers.go index 9fe583ea..456bee20 100644 --- a/go_backend/extension_providers.go +++ b/go_backend/extension_providers.go @@ -797,6 +797,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Service: req.Source, } + // Embed genre and label if provided (from Deezer metadata) + if req.Genre != "" || req.Label != "" { + if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { + GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) + } else { + GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) + } + } + // If extension has skipMetadataEnrichment, copy metadata if ext.Manifest.SkipMetadataEnrichment { resp.SkipMetadataEnrichment = true @@ -937,6 +946,15 @@ func DownloadWithExtensionFallback(req DownloadRequest) (*DownloadResponse, erro Service: providerID, } + // Embed genre and label if provided (from Deezer metadata) + if req.Genre != "" || req.Label != "" { + if err := EmbedGenreLabel(result.FilePath, req.Genre, req.Label); err != nil { + GoLog("[DownloadWithExtensionFallback] Warning: failed to embed genre/label: %v\n", err) + } else { + GoLog("[DownloadWithExtensionFallback] Embedded genre=%q label=%q\n", req.Genre, req.Label) + } + } + // If extension has skipMetadataEnrichment and returned metadata, use it if ext.Manifest.SkipMetadataEnrichment { resp.SkipMetadataEnrichment = true diff --git a/go_backend/metadata.go b/go_backend/metadata.go index fd390704..e6f96fdc 100644 --- a/go_backend/metadata.go +++ b/go_backend/metadata.go @@ -375,6 +375,53 @@ func EmbedLyrics(filePath string, lyrics string) error { return f.Save(filePath) } +// EmbedGenreLabel embeds genre and label into a FLAC file as a separate operation +// This is used for extension downloads where the file is already downloaded +func EmbedGenreLabel(filePath string, genre, label string) error { + if genre == "" && label == "" { + return nil // Nothing to embed + } + + f, err := flac.ParseFile(filePath) + if err != nil { + return fmt.Errorf("failed to parse FLAC file: %w", err) + } + + var cmtIdx int = -1 + var cmt *flacvorbis.MetaDataBlockVorbisComment + + for idx, meta := range f.Meta { + if meta.Type == flac.VorbisComment { + cmtIdx = idx + cmt, err = flacvorbis.ParseFromMetaDataBlock(*meta) + if err != nil { + return fmt.Errorf("failed to parse vorbis comment: %w", err) + } + break + } + } + + if cmt == nil { + cmt = flacvorbis.New() + } + + if genre != "" { + setComment(cmt, "GENRE", genre) + } + if label != "" { + setComment(cmt, "ORGANIZATION", label) + } + + cmtBlock := cmt.Marshal() + if cmtIdx >= 0 { + f.Meta[cmtIdx] = &cmtBlock + } else { + f.Meta = append(f.Meta, &cmtBlock) + } + + return f.Save(filePath) +} + // ExtractLyrics extracts embedded lyrics from a FLAC file func ExtractLyrics(filePath string) (string, error) { f, err := flac.ParseFile(filePath) diff --git a/lib/constants/app_info.dart b/lib/constants/app_info.dart index 187318d6..1dc767de 100644 --- a/lib/constants/app_info.dart +++ b/lib/constants/app_info.dart @@ -1,8 +1,8 @@ /// App version and info constants /// Update version here only - all other files will reference this class AppInfo { - static const String version = '3.1.1'; - static const String buildNumber = '60'; + static const String version = '3.1.2'; + static const String buildNumber = '61'; static const String fullVersion = '$version+$buildNumber'; diff --git a/lib/providers/download_queue_provider.dart b/lib/providers/download_queue_provider.dart index 3a3dc082..c78ab9c0 100644 --- a/lib/providers/download_queue_provider.dart +++ b/lib/providers/download_queue_provider.dart @@ -1626,6 +1626,8 @@ class DownloadQueueNotifier extends Notifier { itemId: item.id, durationMs: trackToDownload.duration, source: trackToDownload.source, // Pass extension ID that provided this track + genre: genre, + label: label, ); } else if (state.autoFallback) { _log.d('Using auto-fallback mode'); diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index e9b501e6..90179966 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -1411,7 +1411,19 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient borderRadius: BorderRadius.circular(28), borderSide: BorderSide(color: colorScheme.primary, width: 2), ), - prefixIcon: const Icon(Icons.search), + prefixIcon: _SearchProviderDropdown( + onProviderChanged: () { + // Reset search state when provider changes + _lastSearchQuery = null; + // Force rebuild to update hint text + setState(() {}); + // Re-trigger search if there's text + final text = _urlController.text.trim(); + if (text.isNotEmpty && text.length >= _minLiveSearchChars) { + _performSearch(text); + } + }, + ), suffixIcon: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -1464,6 +1476,185 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient } +/// Dropdown widget for quick search provider switching +class _SearchProviderDropdown extends ConsumerWidget { + final VoidCallback? onProviderChanged; + + const _SearchProviderDropdown({this.onProviderChanged}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final settings = ref.watch(settingsProvider); + final extState = ref.watch(extensionProvider); + final colorScheme = Theme.of(context).colorScheme; + + // Get current provider info + final currentProvider = settings.searchProvider; + final searchProviders = extState.extensions + .where((ext) => ext.enabled && ext.hasCustomSearch) + .toList(); + + // Find current provider extension + Extension? currentExt; + if (currentProvider != null && currentProvider.isNotEmpty) { + currentExt = searchProviders.where((e) => e.id == currentProvider).firstOrNull; + } + + // Determine display icon + IconData displayIcon = Icons.search; + String? iconPath; + if (currentExt != null) { + iconPath = currentExt.iconPath; + if (currentExt.searchBehavior?.icon != null) { + // Use search behavior icon if available + displayIcon = _getIconFromName(currentExt.searchBehavior!.icon!); + } + } + + // Don't show dropdown if no custom search providers available + if (searchProviders.isEmpty) { + return const Icon(Icons.search); + } + + return Padding( + padding: const EdgeInsets.only(left: 8), + child: PopupMenuButton( + icon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (iconPath != null && iconPath.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(iconPath), + width: 20, + height: 20, + fit: BoxFit.cover, + errorBuilder: (_, e, st) => Icon(displayIcon, size: 20), + ), + ) + else + Icon(displayIcon, size: 20), + const SizedBox(width: 2), + Icon( + Icons.arrow_drop_down, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + tooltip: 'Change search provider', + offset: const Offset(0, 40), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onSelected: (String providerId) { + // Empty string means default (Deezer/Spotify) + final provider = providerId.isEmpty ? null : providerId; + ref.read(settingsProvider.notifier).setSearchProvider(provider); + onProviderChanged?.call(); + }, + itemBuilder: (context) => [ + // Default option (Deezer/Spotify based on metadata source) + PopupMenuItem( + value: '', // Empty string = default provider + child: Row( + children: [ + Icon( + Icons.music_note, + size: 20, + color: currentProvider == null || currentProvider.isEmpty + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + settings.metadataSource == 'spotify' ? 'Spotify' : 'Deezer', + style: TextStyle( + fontWeight: currentProvider == null || currentProvider.isEmpty + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (currentProvider == null || currentProvider.isEmpty) + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), + ), + if (searchProviders.isNotEmpty) const PopupMenuDivider(), + // Extension providers + ...searchProviders.map((ext) => PopupMenuItem( + value: ext.id, + child: Row( + children: [ + if (ext.iconPath != null && ext.iconPath!.isNotEmpty) + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: Image.file( + File(ext.iconPath!), + width: 20, + height: 20, + fit: BoxFit.cover, + errorBuilder: (_, e, st) => Icon( + _getIconFromName(ext.searchBehavior?.icon), + size: 20, + color: currentProvider == ext.id + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + ), + ) + else + Icon( + _getIconFromName(ext.searchBehavior?.icon), + size: 20, + color: currentProvider == ext.id + ? colorScheme.primary + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + ext.displayName, + style: TextStyle( + fontWeight: currentProvider == ext.id + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (currentProvider == ext.id) + Icon(Icons.check, size: 18, color: colorScheme.primary), + ], + ), + )), + ], + ), + ); + } + + IconData _getIconFromName(String? iconName) { + switch (iconName) { + case 'video': + case 'movie': + return Icons.video_library; + case 'music': + return Icons.music_note; + case 'podcast': + return Icons.podcasts; + case 'book': + case 'audiobook': + return Icons.menu_book; + case 'cloud': + return Icons.cloud; + case 'download': + return Icons.download; + default: + return Icons.search; + } + } +} + /// Separate Consumer widget for each track item - only rebuilds when this specific track's status changes class _TrackItemWithStatus extends ConsumerWidget { final Track track; diff --git a/lib/services/platform_bridge.dart b/lib/services/platform_bridge.dart index 44ef3939..91632c1c 100644 --- a/lib/services/platform_bridge.dart +++ b/lib/services/platform_bridge.dart @@ -656,6 +656,8 @@ class PlatformBridge { String? itemId, int durationMs = 0, String? source, // Extension ID that provided this track (prioritize this extension) + String? genre, + String? label, }) async { _log.i('downloadWithExtensions: "$trackName" by $artistName${source != null ? ' (source: $source)' : ''}'); final request = jsonEncode({ @@ -678,6 +680,8 @@ class PlatformBridge { 'item_id': itemId ?? '', 'duration_ms': durationMs, 'source': source ?? '', // Extension ID that provided this track + 'genre': genre ?? '', + 'label': label ?? '', }); final result = await _channel.invokeMethod('downloadWithExtensions', request); diff --git a/pubspec_ios.yaml b/pubspec_ios.yaml index fe320546..dc2fb835 100644 --- a/pubspec_ios.yaml +++ b/pubspec_ios.yaml @@ -1,7 +1,7 @@ name: spotiflac_android description: Download Spotify tracks in FLAC from Tidal, Qobuz & Amazon Music publish_to: "none" -version: 3.1.1+60 +version: 3.1.2+61 environment: sdk: ^3.10.0