mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-05 02:28:36 +02:00
254 lines
7.2 KiB
Dart
254 lines
7.2 KiB
Dart
import 'package:spotiflac_android/models/track.dart';
|
|
import 'package:spotiflac_android/providers/extension_provider.dart';
|
|
|
|
bool looksLikeUrlOrSpotifyUri(String text) =>
|
|
text.startsWith('http') || text.startsWith('spotify:');
|
|
|
|
class HomeSearchResultBuckets {
|
|
final List<Track> realTracks;
|
|
final List<int> realTrackIndexes;
|
|
final List<Track> albumItems;
|
|
final List<Track> playlistItems;
|
|
final List<Track> artistItems;
|
|
|
|
const HomeSearchResultBuckets({
|
|
required this.realTracks,
|
|
required this.realTrackIndexes,
|
|
required this.albumItems,
|
|
required this.playlistItems,
|
|
required this.artistItems,
|
|
});
|
|
}
|
|
|
|
HomeSearchResultBuckets bucketHomeSearchResults(List<Track> tracks) {
|
|
final realTracks = <Track>[];
|
|
final realTrackIndexes = <int>[];
|
|
final albumItems = <Track>[];
|
|
final playlistItems = <Track>[];
|
|
final artistItems = <Track>[];
|
|
|
|
for (var index = 0; index < tracks.length; index++) {
|
|
final track = tracks[index];
|
|
if (!track.isCollection) {
|
|
realTracks.add(track);
|
|
realTrackIndexes.add(index);
|
|
}
|
|
if (track.isAlbumItem) albumItems.add(track);
|
|
if (track.isPlaylistItem) playlistItems.add(track);
|
|
if (track.isArtistItem) artistItems.add(track);
|
|
}
|
|
|
|
return HomeSearchResultBuckets(
|
|
realTracks: realTracks,
|
|
realTrackIndexes: realTrackIndexes,
|
|
albumItems: albumItems,
|
|
playlistItems: playlistItems,
|
|
artistItems: artistItems,
|
|
);
|
|
}
|
|
|
|
enum HomeSearchSortOption {
|
|
defaultOrder,
|
|
titleAsc,
|
|
titleDesc,
|
|
artistAsc,
|
|
artistDesc,
|
|
durationAsc,
|
|
durationDesc,
|
|
dateAsc,
|
|
dateDesc,
|
|
}
|
|
|
|
List<T> sortHomeSearchItems<T>({
|
|
required List<T> items,
|
|
required HomeSearchSortOption option,
|
|
required String Function(T) nameOf,
|
|
required String Function(T) artistOf,
|
|
required int Function(T) durationOf,
|
|
required String? Function(T) dateOf,
|
|
}) {
|
|
if (option == HomeSearchSortOption.defaultOrder) return items;
|
|
|
|
final sorted = List<T>.of(items);
|
|
switch (option) {
|
|
case HomeSearchSortOption.defaultOrder:
|
|
break;
|
|
case HomeSearchSortOption.titleAsc:
|
|
sorted.sort(
|
|
(a, b) => nameOf(a).toLowerCase().compareTo(nameOf(b).toLowerCase()),
|
|
);
|
|
case HomeSearchSortOption.titleDesc:
|
|
sorted.sort(
|
|
(a, b) => nameOf(b).toLowerCase().compareTo(nameOf(a).toLowerCase()),
|
|
);
|
|
case HomeSearchSortOption.artistAsc:
|
|
sorted.sort(
|
|
(a, b) =>
|
|
artistOf(a).toLowerCase().compareTo(artistOf(b).toLowerCase()),
|
|
);
|
|
case HomeSearchSortOption.artistDesc:
|
|
sorted.sort(
|
|
(a, b) =>
|
|
artistOf(b).toLowerCase().compareTo(artistOf(a).toLowerCase()),
|
|
);
|
|
case HomeSearchSortOption.durationAsc:
|
|
sorted.sort((a, b) => durationOf(a).compareTo(durationOf(b)));
|
|
case HomeSearchSortOption.durationDesc:
|
|
sorted.sort((a, b) => durationOf(b).compareTo(durationOf(a)));
|
|
case HomeSearchSortOption.dateAsc:
|
|
sorted.sort((a, b) => (dateOf(a) ?? '').compareTo(dateOf(b) ?? ''));
|
|
case HomeSearchSortOption.dateDesc:
|
|
sorted.sort((a, b) => (dateOf(b) ?? '').compareTo(dateOf(a) ?? ''));
|
|
}
|
|
return sorted;
|
|
}
|
|
|
|
/// Provider and filter selection rules shared by the home search surface and
|
|
/// its provider dropdown.
|
|
class HomeSearchProviderPolicy {
|
|
const HomeSearchProviderPolicy._();
|
|
|
|
static Extension? defaultExtension(List<Extension> extensions) {
|
|
return defaultSearchExtension(extensions);
|
|
}
|
|
|
|
static String? resolveProvider(
|
|
String? explicitSearchProvider,
|
|
List<Extension> extensions,
|
|
) {
|
|
final explicit = explicitSearchProvider?.trim();
|
|
if (explicit != null &&
|
|
explicit.isNotEmpty &&
|
|
extensions.any(
|
|
(extension) =>
|
|
extension.enabled &&
|
|
extension.hasCustomSearch &&
|
|
extension.id == explicit,
|
|
)) {
|
|
return explicit;
|
|
}
|
|
return defaultExtension(extensions)?.id;
|
|
}
|
|
|
|
static bool hasProvider(
|
|
String? explicitSearchProvider,
|
|
List<Extension> extensions,
|
|
) {
|
|
return resolveProvider(explicitSearchProvider, extensions) != null;
|
|
}
|
|
|
|
/// Keeps the Home search surface stable while extension/provider state is
|
|
/// being reconciled. A transient missing search capability must not remove
|
|
/// the field above an already-visible Home feed; changing providers should
|
|
/// never be required just to make Search reappear.
|
|
static bool shouldShowSearchBar({
|
|
required bool hasSearchProvider,
|
|
required bool isSearchProviderLoading,
|
|
required bool hasHomeFeedExtension,
|
|
required bool hasExploreContent,
|
|
required bool hasSearchInput,
|
|
}) {
|
|
return hasSearchProvider ||
|
|
isSearchProviderLoading ||
|
|
hasHomeFeedExtension ||
|
|
hasExploreContent ||
|
|
hasSearchInput;
|
|
}
|
|
|
|
static String? sanitizeFilter(
|
|
String? filter,
|
|
String? currentSearchProvider,
|
|
List<Extension> extensions,
|
|
) {
|
|
if (filter == null || filter.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
final canonicalFilter = canonicalFilterId(filter);
|
|
if (currentSearchProvider == null || currentSearchProvider.isEmpty) {
|
|
return switch (canonicalFilter) {
|
|
'track' || 'artist' || 'album' || 'playlist' => canonicalFilter,
|
|
_ => null,
|
|
};
|
|
}
|
|
|
|
final extension = extensions
|
|
.where(
|
|
(candidate) =>
|
|
candidate.id == currentSearchProvider && candidate.enabled,
|
|
)
|
|
.firstOrNull;
|
|
final filters = extension?.searchBehavior?.filters;
|
|
if (filters == null || filters.isEmpty) {
|
|
return null;
|
|
}
|
|
|
|
return filters
|
|
.where(
|
|
(candidate) =>
|
|
canonicalFilterId(candidate.id) == canonicalFilter ||
|
|
(candidate.label != null &&
|
|
canonicalFilterId(candidate.label!) == canonicalFilter) ||
|
|
(candidate.icon != null &&
|
|
canonicalFilterId(candidate.icon!) == canonicalFilter),
|
|
)
|
|
.firstOrNull
|
|
?.id;
|
|
}
|
|
|
|
static String canonicalFilterId(String value) {
|
|
final normalized = value.trim().toLowerCase().replaceAll(
|
|
RegExp(r'[^a-z0-9]+'),
|
|
'',
|
|
);
|
|
return switch (normalized) {
|
|
'track' || 'tracks' || 'song' || 'songs' || 'music' => 'track',
|
|
'artist' || 'artists' => 'artist',
|
|
'album' || 'albums' => 'album',
|
|
'playlist' || 'playlists' => 'playlist',
|
|
_ => normalized,
|
|
};
|
|
}
|
|
|
|
static String? preferredFilter(
|
|
String preferredSearchTab,
|
|
String? currentSearchProvider,
|
|
List<Extension> extensions,
|
|
) {
|
|
final preferred = switch (preferredSearchTab) {
|
|
'track' => 'track',
|
|
'artist' => 'artist',
|
|
'album' => 'album',
|
|
'playlist' => 'playlist',
|
|
_ => null,
|
|
};
|
|
|
|
return sanitizeFilter(preferred, currentSearchProvider, extensions);
|
|
}
|
|
|
|
static String displayFilterSelection(
|
|
String? selectedSearchFilter,
|
|
String preferredSearchTab,
|
|
String? currentSearchProvider,
|
|
List<Extension> extensions,
|
|
) {
|
|
if (selectedSearchFilter == 'all') {
|
|
return 'all';
|
|
}
|
|
if (selectedSearchFilter != null && selectedSearchFilter.isNotEmpty) {
|
|
return sanitizeFilter(
|
|
selectedSearchFilter,
|
|
currentSearchProvider,
|
|
extensions,
|
|
) ??
|
|
'all';
|
|
}
|
|
return preferredFilter(
|
|
preferredSearchTab,
|
|
currentSearchProvider,
|
|
extensions,
|
|
) ??
|
|
'all';
|
|
}
|
|
}
|