diff --git a/go_backend/extension_manifest.go b/go_backend/extension_manifest.go index 65740067..83a0609b 100644 --- a/go_backend/extension_manifest.go +++ b/go_backend/extension_manifest.go @@ -66,15 +66,23 @@ type QualitySpecificSetting struct { Options []string `json:"options,omitempty"` // For select type } +// SearchFilter defines a filter option for search +type SearchFilter struct { + ID string `json:"id"` // Filter identifier (e.g., "track", "album", "artist", "playlist") + Label string `json:"label,omitempty"` // Display label (e.g., "Songs", "Albums", "Artists", "Playlists") + Icon string `json:"icon,omitempty"` // Optional icon name +} + // SearchBehaviorConfig defines custom search behavior for an extension type SearchBehaviorConfig struct { - Enabled bool `json:"enabled"` // Whether extension provides custom search - Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box - Primary bool `json:"primary,omitempty"` // If true, show as primary search tab - Icon string `json:"icon,omitempty"` // Icon for search tab - ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3) - ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels - ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels + Enabled bool `json:"enabled"` // Whether extension provides custom search + Placeholder string `json:"placeholder,omitempty"` // Placeholder text for search box + Primary bool `json:"primary,omitempty"` // If true, show as primary search tab + Icon string `json:"icon,omitempty"` // Icon for search tab + ThumbnailRatio string `json:"thumbnailRatio,omitempty"` // Thumbnail aspect ratio: "square" (1:1), "wide" (16:9), "portrait" (2:3) + ThumbnailWidth int `json:"thumbnailWidth,omitempty"` // Custom thumbnail width in pixels + ThumbnailHeight int `json:"thumbnailHeight,omitempty"` // Custom thumbnail height in pixels + Filters []SearchFilter `json:"filters,omitempty"` // Available search filters (e.g., track, album, artist, playlist) } // URLHandlerConfig defines custom URL handling for an extension diff --git a/lib/providers/extension_provider.dart b/lib/providers/extension_provider.dart index a4c3d25e..e9893c9c 100644 --- a/lib/providers/extension_provider.dart +++ b/lib/providers/extension_provider.dart @@ -146,6 +146,26 @@ class Extension { bool get hasBrowseCategories => capabilities['browseCategories'] == true; } +class SearchFilter { + final String id; + final String? label; + final String? icon; + + const SearchFilter({ + required this.id, + this.label, + this.icon, + }); + + factory SearchFilter.fromJson(Map json) { + return SearchFilter( + id: json['id'] as String? ?? '', + label: json['label'] as String?, + icon: json['icon'] as String?, + ); + } +} + class SearchBehavior { final bool enabled; final String? placeholder; @@ -154,6 +174,7 @@ class SearchBehavior { final String? thumbnailRatio; // "square" (1:1), "wide" (16:9), "portrait" (2:3) final int? thumbnailWidth; final int? thumbnailHeight; + final List filters; // Available search filters (e.g., track, album, artist, playlist) const SearchBehavior({ required this.enabled, @@ -163,6 +184,7 @@ class SearchBehavior { this.thumbnailRatio, this.thumbnailWidth, this.thumbnailHeight, + this.filters = const [], }); factory SearchBehavior.fromJson(Map json) { @@ -174,6 +196,9 @@ class SearchBehavior { thumbnailRatio: json['thumbnailRatio'] as String?, thumbnailWidth: json['thumbnailWidth'] as int?, thumbnailHeight: json['thumbnailHeight'] as int?, + filters: (json['filters'] as List?) + ?.map((f) => SearchFilter.fromJson(f as Map)) + .toList() ?? [], ); } diff --git a/lib/providers/track_provider.dart b/lib/providers/track_provider.dart index 074fede8..e0f4a8c6 100644 --- a/lib/providers/track_provider.dart +++ b/lib/providers/track_provider.dart @@ -25,6 +25,7 @@ class TrackState { final bool hasSearchText; // For back button handling final bool isShowingRecentAccess; // For recent access mode final String? searchExtensionId; // Extension ID used for current search results + final String? selectedSearchFilter; // Currently selected search filter (e.g., "track", "album", "artist", "playlist") const TrackState({ this.tracks = const [], @@ -44,6 +45,7 @@ class TrackState { this.hasSearchText = false, this.isShowingRecentAccess = false, this.searchExtensionId, + this.selectedSearchFilter, }); bool get hasContent => tracks.isNotEmpty || artistAlbums != null || (searchArtists != null && searchArtists!.isNotEmpty); @@ -66,6 +68,8 @@ class TrackState { bool? hasSearchText, bool? isShowingRecentAccess, String? searchExtensionId, + String? selectedSearchFilter, + bool clearSelectedSearchFilter = false, }) { return TrackState( tracks: tracks ?? this.tracks, @@ -85,6 +89,7 @@ class TrackState { hasSearchText: hasSearchText ?? this.hasSearchText, isShowingRecentAccess: isShowingRecentAccess ?? this.isShowingRecentAccess, searchExtensionId: searchExtensionId, + selectedSearchFilter: clearSelectedSearchFilter ? null : (selectedSearchFilter ?? this.selectedSearchFilter), ); } } @@ -391,7 +396,11 @@ class TrackNotifier extends Notifier { Future customSearch(String extensionId, String query, {Map? options}) async { final requestId = ++_currentRequestId; - state = TrackState(isLoading: true, hasSearchText: state.hasSearchText); + state = TrackState( + isLoading: true, + hasSearchText: state.hasSearchText, + selectedSearchFilter: state.selectedSearchFilter, // Preserve filter during loading + ); try { _log.i('Custom search started: extension=$extensionId, query="$query"'); @@ -423,6 +432,7 @@ class TrackNotifier extends Notifier { isLoading: false, hasSearchText: state.hasSearchText, searchExtensionId: extensionId, // Store which extension was used + selectedSearchFilter: state.selectedSearchFilter, // Preserve selected filter ); } catch (e, stackTrace) { if (!_isRequestValid(requestId)) return; @@ -474,6 +484,15 @@ class TrackNotifier extends Notifier { state = const TrackState(); } + /// Set selected search filter for extension search + void setSearchFilter(String? filter) { + if (state.selectedSearchFilter == filter) return; + state = state.copyWith( + selectedSearchFilter: filter, + clearSelectedSearchFilter: filter == null, + ); + } + /// Set search text state for back button handling void setSearchText(bool hasText) { if (state.hasSearchText == hasText) { diff --git a/lib/screens/home_tab.dart b/lib/screens/home_tab.dart index ce3c6d5f..737caaec 100644 --- a/lib/screens/home_tab.dart +++ b/lib/screens/home_tab.dart @@ -208,8 +208,9 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final settings = ref.read(settingsProvider); final extState = ref.read(extensionProvider); final searchProvider = settings.searchProvider; + final selectedFilter = ref.read(trackProvider).selectedSearchFilter; - final searchKey = '${searchProvider ?? 'default'}:$query'; + final searchKey = '${searchProvider ?? 'default'}:$query:${selectedFilter ?? 'all'}'; if (_lastSearchQuery == searchKey) return; _lastSearchQuery = searchKey; @@ -218,7 +219,12 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient extState.extensions.any((e) => e.id == searchProvider && e.enabled); if (isExtensionEnabled) { - await ref.read(trackProvider.notifier).customSearch(searchProvider, query); + // Build options with filter if selected + Map? options; + if (selectedFilter != null) { + options = {'filter': selectedFilter}; + } + await ref.read(trackProvider.notifier).customSearch(searchProvider, query, options: options); } else { if (searchProvider != null && searchProvider.isNotEmpty && !isExtensionEnabled) { ref.read(settingsProvider.notifier).setSearchProvider(null); @@ -495,6 +501,20 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient final hasExploreContent = exploreSections.isNotEmpty; final showExplore = !hasActualResults && !isLoading && !showRecentAccess && hasHomeFeedExtension && hasExploreContent; + // Get current search extension and its filters + final settings = ref.watch(settingsProvider); + final extState = ref.watch(extensionProvider); + final currentSearchProvider = settings.searchProvider; + final selectedSearchFilter = ref.watch(trackProvider.select((s) => s.selectedSearchFilter)); + Extension? currentSearchExtension; + List searchFilters = []; + if (currentSearchProvider != null && currentSearchProvider.isNotEmpty) { + currentSearchExtension = extState.extensions.where((e) => e.id == currentSearchProvider && e.enabled).firstOrNull; + if (currentSearchExtension?.searchBehavior?.filters.isNotEmpty == true) { + searchFilters = currentSearchExtension!.searchBehavior!.filters; + } + } + if (hasActualResults && isShowingRecentAccess) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) ref.read(trackProvider.notifier).setShowingRecentAccess(false); @@ -603,6 +623,16 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient ), ), + // Search filter bar (only shown when has search results or loading search) + if (searchFilters.isNotEmpty && (hasActualResults || isLoading)) + SliverToBoxAdapter( + child: _buildSearchFilterBar( + searchFilters, + selectedSearchFilter, + colorScheme, + ), + ), + if (showRecentAccess) SliverToBoxAdapter( child: _buildRecentAccess(recentAccessView!, colorScheme), @@ -1912,6 +1942,106 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient return 'Paste Spotify URL or search...'; } + Widget _buildSearchFilterBar( + List filters, + String? selectedFilter, + ColorScheme colorScheme, + ) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + // "All" chip (no filter) + Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: const Text('All'), + selected: selectedFilter == null, + onSelected: (_) { + ref.read(trackProvider.notifier).setSearchFilter(null); + _triggerSearchWithFilter(null); + }, + showCheckmark: false, + selectedColor: colorScheme.primaryContainer, + backgroundColor: colorScheme.surfaceContainerHighest, + labelStyle: TextStyle( + color: selectedFilter == null + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: selectedFilter == null ? FontWeight.w600 : FontWeight.normal, + ), + ), + ), + // Filter chips from extension + ...filters.map((filter) { + final isSelected = selectedFilter == filter.id; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(filter.label ?? filter.id), + selected: isSelected, + onSelected: (_) { + ref.read(trackProvider.notifier).setSearchFilter(filter.id); + _triggerSearchWithFilter(filter.id); + }, + showCheckmark: false, + selectedColor: colorScheme.primaryContainer, + backgroundColor: colorScheme.surfaceContainerHighest, + labelStyle: TextStyle( + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + ), + avatar: filter.icon != null ? Icon( + _getFilterIcon(filter.icon!), + size: 18, + color: isSelected + ? colorScheme.onPrimaryContainer + : colorScheme.onSurfaceVariant, + ) : null, + ), + ); + }), + ], + ), + ), + ); + } + + IconData _getFilterIcon(String iconName) { + switch (iconName.toLowerCase()) { + case 'music': + case 'track': + case 'song': + return Icons.music_note; + case 'album': + return Icons.album; + case 'artist': + return Icons.person; + case 'playlist': + return Icons.playlist_play; + case 'video': + return Icons.video_library; + case 'podcast': + return Icons.podcasts; + default: + return Icons.search; + } + } + + void _triggerSearchWithFilter(String? filter) { + final text = _urlController.text.trim(); + if (text.isEmpty || text.length < _minLiveSearchChars) return; + if (text.startsWith('http') || text.startsWith('spotify:')) return; + + // Reset last search query to force new search + _lastSearchQuery = null; + _performSearch(text); + } + Widget _buildSearchBar(ColorScheme colorScheme) { final hasText = _urlController.text.isNotEmpty; @@ -1938,6 +2068,8 @@ class _HomeTabState extends ConsumerState with AutomaticKeepAliveClient prefixIcon: _SearchProviderDropdown( onProviderChanged: () { _lastSearchQuery = null; + // Reset filter when provider changes + ref.read(trackProvider.notifier).setSearchFilter(null); setState(() {}); final text = _urlController.text.trim(); if (text.isNotEmpty && text.length >= _minLiveSearchChars) {