mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-20 07:04:49 +02:00
feat: add search filter bar for extension custom search
- Add SearchFilter struct in Go backend and Dart - Add filters array to SearchBehaviorConfig manifest - Add selectedSearchFilter state to TrackProvider - Add filter bar UI with FilterChips below search bar - Filter bar only shows when search results exist or loading - Preserve selectedSearchFilter during customSearch loading - Pass filter option to extension customSearch
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<String, dynamic> 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<SearchFilter> 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<String, dynamic> 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<dynamic>?)
|
||||
?.map((f) => SearchFilter.fromJson(f as Map<String, dynamic>))
|
||||
.toList() ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<TrackState> {
|
||||
Future<void> customSearch(String extensionId, String query, {Map<String, dynamic>? 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<TrackState> {
|
||||
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<TrackState> {
|
||||
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) {
|
||||
|
||||
+134
-2
@@ -208,8 +208,9 @@ class _HomeTabState extends ConsumerState<HomeTab> 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<HomeTab> 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<String, dynamic>? 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<HomeTab> 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<SearchFilter> 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<HomeTab> 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<HomeTab> with AutomaticKeepAliveClient
|
||||
return 'Paste Spotify URL or search...';
|
||||
}
|
||||
|
||||
Widget _buildSearchFilterBar(
|
||||
List<SearchFilter> 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<HomeTab> 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) {
|
||||
|
||||
Reference in New Issue
Block a user