mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-28 20:32:15 +02:00
feat(library): search songs albums artists and playlists by relevance
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_collections_provider.dart';
|
||||
import 'package:spotiflac_android/providers/local_library_provider.dart';
|
||||
import 'package:spotiflac_android/providers/settings_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/library_search.dart';
|
||||
|
||||
typedef LibrarySearchRequest = ({
|
||||
String query,
|
||||
LibrarySearchKind kind,
|
||||
int limit,
|
||||
int offset,
|
||||
});
|
||||
|
||||
final librarySearchProvider = FutureProvider.autoDispose
|
||||
.family<List<LibrarySearchHit>, LibrarySearchRequest>((ref, request) async {
|
||||
final query = LibrarySearchQuery(request.query);
|
||||
if (query.terms.isEmpty) return const [];
|
||||
if (request.kind == LibrarySearchKind.playlists) {
|
||||
final playlists = ref.watch(
|
||||
libraryCollectionsProvider.select((state) => state.playlists),
|
||||
);
|
||||
final matches = playlists.where((p) => query.matches(p.name)).toList()
|
||||
..sort((a, b) {
|
||||
final relevance = query.rank(a.name).compareTo(query.rank(b.name));
|
||||
if (relevance != 0) return relevance;
|
||||
final title = a.name.toLowerCase().compareTo(b.name.toLowerCase());
|
||||
return title != 0 ? title : a.id.compareTo(b.id);
|
||||
});
|
||||
return matches
|
||||
.skip(request.offset)
|
||||
.take(request.limit)
|
||||
.map((p) {
|
||||
return LibrarySearchHit(
|
||||
kind: LibrarySearchKind.playlists,
|
||||
id: p.id,
|
||||
title: p.name,
|
||||
cover: p.coverImagePath ?? p.previewCover,
|
||||
trackCount: p.trackCount,
|
||||
);
|
||||
})
|
||||
.toList(growable: false);
|
||||
}
|
||||
ref.watch(downloadHistoryProvider.select((s) => s.loadedIndexVersion));
|
||||
ref.watch(localLibraryProvider.select((s) => s.loadedIndexVersion));
|
||||
final includeLocal = ref.watch(
|
||||
settingsProvider.select((s) => s.localLibraryEnabled),
|
||||
);
|
||||
return LibraryDatabase.instance.searchLibrary(
|
||||
query: request.query,
|
||||
kind: request.kind,
|
||||
includeLocal: includeLocal,
|
||||
limit: request.limit,
|
||||
offset: request.offset,
|
||||
);
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/app_search_field.dart';
|
||||
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:spotiflac_android/widgets/library_search_results.dart';
|
||||
|
||||
enum MornyeLibraryPage { overview, albums, artists }
|
||||
|
||||
@@ -161,7 +162,10 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = Theme.of(context).colorScheme;
|
||||
final result = ref.watch(libraryBrowseProvider(_request));
|
||||
final searchingLibrary = _overview && _query.isNotEmpty;
|
||||
final result = searchingLibrary
|
||||
? const AsyncData<List<LibraryBrowseEntry>>([])
|
||||
: ref.watch(libraryBrowseProvider(_request));
|
||||
_loading = result.isLoading;
|
||||
if (result.hasValue) _rows = result.requireValue;
|
||||
final title =
|
||||
@@ -205,6 +209,7 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
|
||||
];
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
if (searchingLibrary) return;
|
||||
ref.invalidate(libraryBrowseProvider(_request));
|
||||
await ref.read(libraryBrowseProvider(_request).future);
|
||||
},
|
||||
@@ -233,190 +238,200 @@ class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_overview && _query.isEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 20),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
for (final category in [
|
||||
(
|
||||
Icons.queue_music,
|
||||
context.l10n.searchPlaylists,
|
||||
() => widget.onOpenSection('playlists'),
|
||||
),
|
||||
(
|
||||
Icons.mic_none,
|
||||
context.l10n.searchArtists,
|
||||
() => _openPage(MornyeLibraryPage.artists),
|
||||
),
|
||||
(
|
||||
Icons.album_outlined,
|
||||
context.l10n.searchAlbums,
|
||||
() => _openPage(MornyeLibraryPage.albums),
|
||||
),
|
||||
(
|
||||
Icons.music_note_outlined,
|
||||
context.l10n.searchSongs,
|
||||
() => widget.onOpenSection('all'),
|
||||
),
|
||||
]) ...[
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
minVerticalPadding: 12,
|
||||
leading: Icon(
|
||||
category.$1,
|
||||
color: colors.primary,
|
||||
size: 28,
|
||||
),
|
||||
title: Text(
|
||||
category.$2,
|
||||
style: const TextStyle(fontSize: 22),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
onTap: category.$3,
|
||||
),
|
||||
const Divider(height: 1, indent: 48),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (searchingLibrary)
|
||||
LibrarySearchResults(
|
||||
query: _query,
|
||||
onOpenArtist: (artist) =>
|
||||
_openPage(MornyeLibraryPage.albums, artist: artist),
|
||||
),
|
||||
if (_overview)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
||||
child: Text(
|
||||
_query.isEmpty
|
||||
? context.l10n.libraryRecentlyAdded
|
||||
: context.l10n.searchAlbums,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
if (!searchingLibrary) ...[
|
||||
if (_overview && _query.isEmpty)
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 20),
|
||||
sliver: SliverList.list(
|
||||
children: [
|
||||
for (final category in [
|
||||
(
|
||||
Icons.queue_music,
|
||||
context.l10n.searchPlaylists,
|
||||
() => widget.onOpenSection('playlists'),
|
||||
),
|
||||
(
|
||||
Icons.mic_none,
|
||||
context.l10n.searchArtists,
|
||||
() => _openPage(MornyeLibraryPage.artists),
|
||||
),
|
||||
(
|
||||
Icons.album_outlined,
|
||||
context.l10n.searchAlbums,
|
||||
() => _openPage(MornyeLibraryPage.albums),
|
||||
),
|
||||
(
|
||||
Icons.music_note_outlined,
|
||||
context.l10n.searchSongs,
|
||||
() => widget.onOpenSection('all'),
|
||||
),
|
||||
]) ...[
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
minVerticalPadding: 12,
|
||||
leading: Icon(
|
||||
category.$1,
|
||||
color: colors.primary,
|
||||
size: 28,
|
||||
),
|
||||
title: Text(
|
||||
category.$2,
|
||||
style: const TextStyle(fontSize: 22),
|
||||
),
|
||||
trailing: Icon(
|
||||
Icons.chevron_right,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
onTap: category.$3,
|
||||
),
|
||||
const Divider(height: 1, indent: 48),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_overview)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
|
||||
child: Text(
|
||||
_query.isEmpty
|
||||
? context.l10n.libraryRecentlyAdded
|
||||
: context.l10n.searchAlbums,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_rows.isNotEmpty)
|
||||
if (_artists)
|
||||
SliverList.builder(
|
||||
itemCount: _rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = _rows[index];
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
leading: ClipOval(
|
||||
child: SizedBox.square(
|
||||
dimension: 56,
|
||||
child: _LibraryBrowseArtwork(entry: entry),
|
||||
if (_rows.isNotEmpty)
|
||||
if (_artists)
|
||||
SliverList.builder(
|
||||
itemCount: _rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = _rows[index];
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 8,
|
||||
),
|
||||
),
|
||||
title: Text(entry.artist),
|
||||
subtitle: Text(
|
||||
context.l10n.queueTrackCount(entry.trackCount),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _openPage(
|
||||
MornyeLibraryPage.albums,
|
||||
artist: entry.artist,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, indent: 96, endIndent: 24),
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final columns = (constraints.crossAxisExtent / 200)
|
||||
.floor()
|
||||
.clamp(2, 6);
|
||||
final width =
|
||||
(constraints.crossAxisExtent - 20 * (columns - 1)) /
|
||||
columns;
|
||||
final textHeight =
|
||||
MediaQuery.textScalerOf(context).scale(16) * 3.2 + 24;
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
crossAxisSpacing: 20,
|
||||
mainAxisSpacing: 16,
|
||||
mainAxisExtent: width + textHeight,
|
||||
),
|
||||
itemCount: _rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = _rows[index];
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _openAlbum(entry),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _LibraryBrowseArtwork(entry: entry),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
entry.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
entry.artist,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
leading: ClipOval(
|
||||
child: SizedBox.square(
|
||||
dimension: 56,
|
||||
child: _LibraryBrowseArtwork(entry: entry),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
title: Text(entry.artist),
|
||||
subtitle: Text(
|
||||
context.l10n.queueTrackCount(entry.trackCount),
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _openPage(
|
||||
MornyeLibraryPage.albums,
|
||||
artist: entry.artist,
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, indent: 96, endIndent: 24),
|
||||
],
|
||||
);
|
||||
},
|
||||
)
|
||||
else
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
sliver: SliverLayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final columns = (constraints.crossAxisExtent / 200)
|
||||
.floor()
|
||||
.clamp(2, 6);
|
||||
final width =
|
||||
(constraints.crossAxisExtent - 20 * (columns - 1)) /
|
||||
columns;
|
||||
final textHeight =
|
||||
MediaQuery.textScalerOf(context).scale(16) * 3.2 + 24;
|
||||
return SliverGrid.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: columns,
|
||||
crossAxisSpacing: 20,
|
||||
mainAxisSpacing: 16,
|
||||
mainAxisExtent: width + textHeight,
|
||||
),
|
||||
itemCount: _rows.length,
|
||||
itemBuilder: (context, index) {
|
||||
final entry = _rows[index];
|
||||
return Semantics(
|
||||
button: true,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => _openAlbum(entry),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: 1,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: _LibraryBrowseArtwork(
|
||||
entry: entry,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
entry.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Text(
|
||||
entry.artist,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (result.hasError)
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
ref.invalidate(libraryBrowseProvider(_request)),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(context.l10n.dialogRetry),
|
||||
if (result.hasError)
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () =>
|
||||
ref.invalidate(libraryBrowseProvider(_request)),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(context.l10n.dialogRetry),
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_loading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator.adaptive()),
|
||||
),
|
||||
)
|
||||
else if (_rows.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: Text(context.l10n.libraryEmptyCollection)),
|
||||
),
|
||||
)
|
||||
else if (_loading)
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator.adaptive()),
|
||||
),
|
||||
)
|
||||
else if (_rows.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(child: Text(context.l10n.libraryEmptyCollection)),
|
||||
),
|
||||
],
|
||||
const NavBarSliverSpacer(),
|
||||
],
|
||||
),
|
||||
|
||||
+51
-15
@@ -14,6 +14,7 @@ import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
|
||||
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
|
||||
import 'package:spotiflac_android/widgets/app_search_field.dart';
|
||||
import 'package:spotiflac_android/widgets/library_search_results.dart';
|
||||
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
@@ -1376,7 +1377,19 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
filterMetadata: _filterMetadata,
|
||||
localLibraryEnabled: localLibraryEnabled,
|
||||
);
|
||||
final countsValue = ref.watch(_queueLibraryCountsProvider(countsRequest));
|
||||
final searchingLibrary =
|
||||
_searchQuery.isNotEmpty &&
|
||||
!_isSelectionMode &&
|
||||
!_isPlaylistSelectionMode;
|
||||
final countsValue = searchingLibrary
|
||||
? const AsyncData(
|
||||
QueueLibraryCounts(
|
||||
allTrackCount: 0,
|
||||
albumCount: 0,
|
||||
singleTrackCount: 0,
|
||||
),
|
||||
)
|
||||
: ref.watch(_queueLibraryCountsProvider(countsRequest));
|
||||
final historySnapshotFallbackEnabled =
|
||||
hasQueueItems &&
|
||||
inMemoryHistoryItems.isNotEmpty &&
|
||||
@@ -1428,9 +1441,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
|
||||
final activePageRequest = pageRequest(historyFilterMode);
|
||||
final activePageValue = ref.watch(
|
||||
_queueLibraryPageProvider(activePageRequest),
|
||||
);
|
||||
final activePageValue = searchingLibrary
|
||||
? const AsyncData(_QueueLibraryPageData())
|
||||
: ref.watch(_queueLibraryPageProvider(activePageRequest));
|
||||
|
||||
_QueueLibraryPageData pageData(String filterMode) {
|
||||
final request = filterMode == historyFilterMode
|
||||
@@ -1489,12 +1502,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_searchQuery.isNotEmpty || _searchController.text.trim().isNotEmpty;
|
||||
final shouldShowLibraryControls =
|
||||
hasLibraryContent || hasAnyLibraryItems || hasActiveSearch;
|
||||
_scheduleBlankLibraryRepair(
|
||||
hasQueueItems: hasQueueItems,
|
||||
hasLibraryContent: hasLibraryContent,
|
||||
hasAnyLibraryItems: hasAnyLibraryItems,
|
||||
isLibraryPageLoading: isLibraryPageLoading,
|
||||
);
|
||||
if (!searchingLibrary) {
|
||||
_scheduleBlankLibraryRepair(
|
||||
hasQueueItems: hasQueueItems,
|
||||
hasLibraryContent: hasLibraryContent,
|
||||
hasAnyLibraryItems: hasAnyLibraryItems,
|
||||
isLibraryPageLoading: isLibraryPageLoading,
|
||||
);
|
||||
}
|
||||
|
||||
final selectionItems = getFilterData(
|
||||
historyFilterMode,
|
||||
@@ -1556,9 +1571,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
child: AppSearchField(
|
||||
controller: _searchController,
|
||||
focusNode: _searchFocusNode,
|
||||
hintText: widget.librarySection == null
|
||||
? context.l10n.historySearchHint
|
||||
: context.l10n.librarySearchHint,
|
||||
hintText: context.l10n.librarySearchHint,
|
||||
clearTooltip: context.l10n.dialogClear,
|
||||
onChanged: _onSearchChanged,
|
||||
onClear: () {
|
||||
@@ -1570,7 +1583,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
|
||||
if (shouldShowLibraryControls && widget.librarySection == null)
|
||||
if (!searchingLibrary &&
|
||||
shouldShowLibraryControls &&
|
||||
widget.librarySection == null)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
@@ -1639,7 +1654,28 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
],
|
||||
body: widget.librarySection != null
|
||||
body: searchingLibrary
|
||||
? CustomScrollView(
|
||||
key: const ValueKey('library-search-results'),
|
||||
slivers: [
|
||||
LibrarySearchResults(
|
||||
query: _searchQuery,
|
||||
onOpenArtist: (artist) => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => Scaffold(
|
||||
body: MornyeLibraryScreen(
|
||||
page: MornyeLibraryPage.albums,
|
||||
artist: artist,
|
||||
onOpenSection: (_) {},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const NavBarSliverSpacer(),
|
||||
],
|
||||
)
|
||||
: widget.librarySection != null
|
||||
? _buildFilterContent(
|
||||
context: context,
|
||||
colorScheme: colorScheme,
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/ios_container_paths.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/services/library_cleanup.dart';
|
||||
import 'package:spotiflac_android/services/library_search.dart';
|
||||
import 'package:spotiflac_android/services/sqlite_helpers.dart' as sqlite;
|
||||
|
||||
part 'library_database_models.dart';
|
||||
@@ -64,6 +65,28 @@ class LibraryDatabase {
|
||||
|
||||
bool get searchFtsAvailable => _searchFtsAvailable ?? false;
|
||||
|
||||
Future<List<LibrarySearchHit>> searchLibrary({
|
||||
required String query,
|
||||
required LibrarySearchKind kind,
|
||||
required bool includeLocal,
|
||||
int limit = 40,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final db = await database;
|
||||
await _ensureHistoryAttached(db);
|
||||
return LibrarySearchStore(
|
||||
db,
|
||||
historyFts: HistoryDatabase.instance.searchFtsAvailable,
|
||||
localFts: searchFtsAvailable,
|
||||
).search(
|
||||
query: query,
|
||||
kind: kind,
|
||||
includeLocal: includeLocal,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _migrateIosContainerPaths(Database db) async {
|
||||
if (!Platform.isIOS) return;
|
||||
final documents = await getApplicationDocumentsDirectory();
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
|
||||
enum LibrarySearchKind { songs, albums, artists, playlists }
|
||||
|
||||
/// Each word must match, but words can occur in different metadata fields and
|
||||
/// in any order. Punctuation is a separator, never a SQL/FTS operator.
|
||||
class LibrarySearchQuery {
|
||||
LibrarySearchQuery(String text)
|
||||
: text = text.trim().toLowerCase(),
|
||||
terms = RegExp(r'[\p{L}\p{N}]+', unicode: true)
|
||||
.allMatches(text.toLowerCase())
|
||||
.map((match) => match.group(0)!)
|
||||
.toSet()
|
||||
.toList(growable: false);
|
||||
|
||||
final String text;
|
||||
final List<String> terms;
|
||||
|
||||
bool matches(String value) {
|
||||
final normalized = value.toLowerCase();
|
||||
return terms.isNotEmpty && terms.every(normalized.contains);
|
||||
}
|
||||
|
||||
int rank(String value) {
|
||||
final normalized = value.trim().toLowerCase();
|
||||
if (normalized == text) return 0;
|
||||
if (normalized.startsWith(text)) return 1;
|
||||
if (normalized.contains(text)) return 2;
|
||||
return 3;
|
||||
}
|
||||
|
||||
String predicate(
|
||||
String column,
|
||||
List<Object?> args, {
|
||||
String? rowId,
|
||||
String? ftsTable,
|
||||
String? ftsContentTable,
|
||||
}) {
|
||||
if (terms.isEmpty) return '0';
|
||||
final indexed = ftsTable == null
|
||||
? const <String>[]
|
||||
: terms.where((term) => term.runes.length >= 3).toList();
|
||||
final predicates = <String>[];
|
||||
if (indexed.isNotEmpty) {
|
||||
final tableName = ftsTable!.split('.').last;
|
||||
final matches = 'SELECT rowid FROM $ftsTable WHERE $tableName MATCH ?';
|
||||
predicates.add(
|
||||
'$rowId IN (${ftsContentTable == null ? matches : 'SELECT id FROM $ftsContentTable WHERE rowid IN ($matches)'})',
|
||||
);
|
||||
args.add(indexed.map((term) => '"$term"').join(' AND '));
|
||||
}
|
||||
for (final term in terms) {
|
||||
if (indexed.contains(term)) continue;
|
||||
predicates.add("$column LIKE ? ESCAPE '\\'");
|
||||
args.add('%$term%');
|
||||
}
|
||||
return predicates.join(' AND ');
|
||||
}
|
||||
|
||||
String orderBy(String column, List<Object?> args) {
|
||||
final escaped = text
|
||||
.replaceAll('\\', '\\\\')
|
||||
.replaceAll('%', '\\%')
|
||||
.replaceAll('_', '\\_');
|
||||
args.addAll([text, '$escaped%', '%$escaped%']);
|
||||
return '''CASE WHEN $column = ? THEN 0
|
||||
WHEN $column LIKE ? ESCAPE '\\' THEN 1
|
||||
WHEN $column LIKE ? ESCAPE '\\' THEN 2 ELSE 3 END''';
|
||||
}
|
||||
}
|
||||
|
||||
class LibrarySearchHit {
|
||||
const LibrarySearchHit({
|
||||
required this.kind,
|
||||
required this.id,
|
||||
required this.title,
|
||||
this.artist = '',
|
||||
this.source = '',
|
||||
this.cover,
|
||||
this.samplePath = '',
|
||||
this.trackCount = 0,
|
||||
});
|
||||
|
||||
final LibrarySearchKind kind;
|
||||
final String id;
|
||||
final String title;
|
||||
final String artist;
|
||||
final String source;
|
||||
final String? cover;
|
||||
final String samplePath;
|
||||
final int trackCount;
|
||||
|
||||
factory LibrarySearchHit.fromRow(
|
||||
LibrarySearchKind kind,
|
||||
Map<String, Object?> row,
|
||||
) => LibrarySearchHit(
|
||||
kind: kind,
|
||||
id: row['id'] as String,
|
||||
title: row['title'] as String? ?? '',
|
||||
artist: row['artist'] as String? ?? '',
|
||||
source: row['source'] as String? ?? '',
|
||||
cover: row['cover'] as String?,
|
||||
samplePath: row['sample_path'] as String? ?? '',
|
||||
trackCount: (row['track_count'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Runs bounded, ranked searches in SQLite instead of copying the Library into
|
||||
/// Dart. The caller supplies the Library connection with history_db attached.
|
||||
class LibrarySearchStore {
|
||||
const LibrarySearchStore(
|
||||
this.db, {
|
||||
required this.historyFts,
|
||||
required this.localFts,
|
||||
});
|
||||
|
||||
final DatabaseExecutor db;
|
||||
final bool historyFts;
|
||||
final bool localFts;
|
||||
|
||||
Future<List<LibrarySearchHit>> search({
|
||||
required String query,
|
||||
required LibrarySearchKind kind,
|
||||
required bool includeLocal,
|
||||
int limit = 40,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
final search = LibrarySearchQuery(query);
|
||||
if (search.terms.isEmpty || kind == LibrarySearchKind.playlists) return [];
|
||||
final args = <Object?>[];
|
||||
final parts = <String>[];
|
||||
for (final local in [false, if (includeLocal) true]) {
|
||||
final alias = local ? 'l' : 'h';
|
||||
final source = local ? 'local' : 'downloaded';
|
||||
final table = local ? 'library_visible' : 'history_db.history';
|
||||
final artist = local ? 'album_artist_norm' : 'sort_album_artist';
|
||||
final album = local ? 'album_name_norm' : 'sort_album';
|
||||
final title = local ? 'track_name_norm' : 'sort_track';
|
||||
final where = <String>[
|
||||
if (local)
|
||||
'''NOT EXISTS (
|
||||
SELECT 1 FROM library_path_keys lpk
|
||||
JOIN history_db.history_path_keys hpk ON hpk.path_key = lpk.path_key
|
||||
WHERE lpk.item_id = l.id
|
||||
)''',
|
||||
];
|
||||
final column = switch (kind) {
|
||||
LibrarySearchKind.songs => '$alias.search_text',
|
||||
LibrarySearchKind.albums => "$alias.$album || ' ' || $alias.$artist",
|
||||
LibrarySearchKind.artists => '$alias.$artist',
|
||||
LibrarySearchKind.playlists => throw StateError('Not a track index'),
|
||||
};
|
||||
where.add(
|
||||
search.predicate(
|
||||
column,
|
||||
args,
|
||||
// Views do not expose their underlying table's implicit rowid.
|
||||
rowId: local ? '$alias.id' : '$alias.rowid',
|
||||
ftsContentTable: local ? 'library' : null,
|
||||
ftsTable: kind != LibrarySearchKind.songs
|
||||
? null
|
||||
: local
|
||||
? (localFts ? 'library_search_fts' : null)
|
||||
: (historyFts ? 'history_db.history_search_fts' : null),
|
||||
),
|
||||
);
|
||||
parts.add('''
|
||||
SELECT '$source' AS source, $alias.id, $alias.track_name AS title,
|
||||
$alias.artist_name AS artist, $alias.album_name AS album,
|
||||
COALESCE(NULLIF($alias.album_artist, ''), $alias.artist_name) AS album_artist,
|
||||
$alias.album_key, $alias.$title AS title_key,
|
||||
$alias.$album AS album_sort, $alias.$artist AS artist_key,
|
||||
${local ? '$alias.cover_path' : '$alias.cover_url'} AS cover,
|
||||
$alias.file_path AS sample_path
|
||||
FROM $table $alias WHERE ${where.join(' AND ')}
|
||||
''');
|
||||
}
|
||||
final rows = '(${parts.join(' UNION ALL ')})';
|
||||
// Album predicates use only album-level fields, preserving full track
|
||||
// counts. Keep downloaded/local albums separate, as their detail screens
|
||||
// resolve tracks from different stores. Artists merge both sources.
|
||||
final select = switch (kind) {
|
||||
LibrarySearchKind.songs => 'SELECT *, 1 AS track_count FROM $rows',
|
||||
LibrarySearchKind.albums =>
|
||||
'''
|
||||
SELECT source, album_key AS id, MIN(album) AS title,
|
||||
MIN(album_artist) AS artist, MAX(NULLIF(cover, '')) AS cover,
|
||||
MAX(sample_path) AS sample_path, COUNT(*) AS track_count,
|
||||
MIN(album_sort) AS title_key
|
||||
FROM $rows WHERE album_sort != '' GROUP BY source, album_key''',
|
||||
LibrarySearchKind.artists =>
|
||||
'''
|
||||
SELECT '' AS source, artist_key AS id, MIN(album_artist) AS title,
|
||||
MIN(album_artist) AS artist, MAX(NULLIF(cover, '')) AS cover,
|
||||
MAX(sample_path) AS sample_path, COUNT(*) AS track_count,
|
||||
artist_key AS title_key
|
||||
FROM $rows WHERE artist_key != '' GROUP BY artist_key''',
|
||||
LibrarySearchKind.playlists => throw StateError('Not a track index'),
|
||||
};
|
||||
final rank = search.orderBy('title_key', args);
|
||||
final result = await db.rawQuery(
|
||||
'''
|
||||
SELECT * FROM ($select)
|
||||
ORDER BY $rank, title_key, artist, source, id
|
||||
LIMIT ? OFFSET ?
|
||||
''',
|
||||
[...args, limit, offset],
|
||||
);
|
||||
return result.map((row) => LibrarySearchHit.fromRow(kind, row)).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:spotiflac_android/l10n/l10n.dart';
|
||||
import 'package:spotiflac_android/providers/download_history_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_search_provider.dart';
|
||||
import 'package:spotiflac_android/providers/music_player_provider.dart';
|
||||
import 'package:spotiflac_android/providers/playback_provider.dart';
|
||||
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
|
||||
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
|
||||
import 'package:spotiflac_android/screens/local_album_screen.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/services/library_database.dart';
|
||||
import 'package:spotiflac_android/services/library_search.dart';
|
||||
import 'package:spotiflac_android/widgets/app_choice_chip.dart';
|
||||
import 'package:spotiflac_android/widgets/cached_cover_image.dart';
|
||||
import 'package:spotiflac_android/widgets/track_card.dart';
|
||||
|
||||
/// Slivers shared by Material's Library and Mornye's Library/Songs screens.
|
||||
/// Only the current query's provider families are watched; late results from
|
||||
/// an earlier query cannot replace what the user is currently searching for.
|
||||
class LibrarySearchResults extends ConsumerStatefulWidget {
|
||||
const LibrarySearchResults({
|
||||
super.key,
|
||||
required this.query,
|
||||
required this.onOpenArtist,
|
||||
});
|
||||
|
||||
final String query;
|
||||
final ValueChanged<String> onOpenArtist;
|
||||
|
||||
@override
|
||||
ConsumerState<LibrarySearchResults> createState() =>
|
||||
_LibrarySearchResultsState();
|
||||
}
|
||||
|
||||
class _LibrarySearchResultsState extends ConsumerState<LibrarySearchResults> {
|
||||
LibrarySearchKind? _kind;
|
||||
int _pages = 1;
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant LibrarySearchResults oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.query != widget.query) _pages = 1;
|
||||
}
|
||||
|
||||
String _label(LibrarySearchKind kind) => switch (kind) {
|
||||
LibrarySearchKind.songs => context.l10n.searchSongs,
|
||||
LibrarySearchKind.albums => context.l10n.searchAlbums,
|
||||
LibrarySearchKind.artists => context.l10n.searchArtists,
|
||||
LibrarySearchKind.playlists => context.l10n.searchPlaylists,
|
||||
};
|
||||
|
||||
void _select(LibrarySearchKind? kind) => setState(() {
|
||||
_kind = kind;
|
||||
_pages = 1;
|
||||
});
|
||||
|
||||
Future<void> _open(LibrarySearchHit hit) async {
|
||||
FocusScope.of(context).unfocus();
|
||||
try {
|
||||
switch (hit.kind) {
|
||||
case LibrarySearchKind.songs:
|
||||
final row = hit.source == 'local'
|
||||
? await LibraryDatabase.instance.getById(hit.id)
|
||||
: await HistoryDatabase.instance.getById(hit.id);
|
||||
if (!mounted || row == null) return;
|
||||
final media = hit.source == 'local'
|
||||
? playableFromLocal(LocalLibraryItem.fromJson(row))
|
||||
: playableFromHistory(DownloadHistoryItem.fromJson(row));
|
||||
await ref
|
||||
.read(playbackProvider.notifier)
|
||||
.playMediaQueue(
|
||||
[media],
|
||||
startIndex: 0,
|
||||
externalPath: media.source,
|
||||
);
|
||||
case LibrarySearchKind.artists:
|
||||
widget.onOpenArtist(hit.title);
|
||||
case LibrarySearchKind.playlists:
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => LibraryTracksFolderScreen(
|
||||
mode: LibraryTracksFolderMode.playlist,
|
||||
playlistId: hit.id,
|
||||
),
|
||||
),
|
||||
);
|
||||
case LibrarySearchKind.albums:
|
||||
if (hit.source == 'local') {
|
||||
final rows = await LibraryDatabase.instance
|
||||
.getQueueLocalAlbumTracksByKey(hit.id);
|
||||
if (!mounted) return;
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => LocalAlbumScreen(
|
||||
albumName: hit.title,
|
||||
artistName: hit.artist,
|
||||
coverPath: hit.cover,
|
||||
tracks: rows.map(LocalLibraryItem.fromJson).toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => DownloadedAlbumScreen(
|
||||
albumName: hit.title,
|
||||
artistName: hit.artist,
|
||||
coverUrl: hit.cover,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(context.l10n.snackbarCannotOpenFile('$error'))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _row(LibrarySearchHit hit) {
|
||||
final icon = switch (hit.kind) {
|
||||
LibrarySearchKind.songs => Icons.music_note_outlined,
|
||||
LibrarySearchKind.albums => Icons.album_outlined,
|
||||
LibrarySearchKind.artists => Icons.person_outline,
|
||||
LibrarySearchKind.playlists => Icons.queue_music,
|
||||
};
|
||||
final subtitle = hit.kind == LibrarySearchKind.songs
|
||||
? hit.artist
|
||||
: [
|
||||
if (hit.kind == LibrarySearchKind.albums) hit.artist,
|
||||
context.l10n.queueTrackCount(hit.trackCount),
|
||||
].join(' · ');
|
||||
Widget fallback(BuildContext context) => ColoredBox(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Center(child: Icon(icon)),
|
||||
);
|
||||
return TrackCard(
|
||||
key: ValueKey('${hit.kind.name}:${hit.source}:${hit.id}'),
|
||||
style: TrackCardStyle.flat,
|
||||
leading: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(
|
||||
hit.kind == LibrarySearchKind.artists ? 28 : 8,
|
||||
),
|
||||
child: SizedBox.square(
|
||||
dimension: 52,
|
||||
child: hit.cover?.isNotEmpty == true
|
||||
? LocalOrNetworkCoverImage(url: hit.cover!, placeholder: fallback)
|
||||
: fallback(context),
|
||||
),
|
||||
),
|
||||
title: hit.title,
|
||||
subtitle: Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis),
|
||||
trailing: Icon(
|
||||
hit.kind == LibrarySearchKind.songs
|
||||
? Icons.play_arrow_rounded
|
||||
: Icons.chevron_right,
|
||||
),
|
||||
onTap: () => _open(hit),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final slivers = <Widget>[
|
||||
SliverToBoxAdapter(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(
|
||||
spacing: 8,
|
||||
children: [
|
||||
for (final kind in [null, ...LibrarySearchKind.values])
|
||||
AppChoiceChip(
|
||||
label: Text(
|
||||
kind == null ? context.l10n.historyFilterAll : _label(kind),
|
||||
),
|
||||
selected: _kind == kind,
|
||||
singleChoice: true,
|
||||
onSelected: (_) => _select(kind),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
var hasResults = false;
|
||||
var loading = false;
|
||||
var failed = false;
|
||||
for (final kind in _kind == null ? LibrarySearchKind.values : [_kind!]) {
|
||||
for (var page = 0; page < (_kind == null ? 1 : _pages); page++) {
|
||||
final pageSize = _kind == null ? 5 : 40;
|
||||
final request = (
|
||||
query: widget.query,
|
||||
kind: kind,
|
||||
limit: pageSize + 1,
|
||||
offset: page * pageSize,
|
||||
);
|
||||
final result = ref.watch(librarySearchProvider(request));
|
||||
loading |= result.isLoading;
|
||||
failed |= result.hasError;
|
||||
final hits = result.value ?? const <LibrarySearchHit>[];
|
||||
if (hits.isNotEmpty) {
|
||||
hasResults = true;
|
||||
if (page == 0) {
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
title: Text(
|
||||
_label(kind),
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
trailing: _kind == null
|
||||
? const Icon(Icons.chevron_right)
|
||||
: null,
|
||||
onTap: _kind == null ? () => _select(kind) : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
slivers.add(
|
||||
SliverList.builder(
|
||||
itemCount: hits.length.clamp(0, pageSize),
|
||||
itemBuilder: (context, index) => _row(hits[index]),
|
||||
),
|
||||
);
|
||||
if (_kind != null && page == _pages - 1 && hits.length > pageSize) {
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: IconButton(
|
||||
tooltip: _label(kind),
|
||||
icon: const Icon(Icons.expand_more),
|
||||
onPressed: () => setState(() => _pages++),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (result.hasError) {
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: TextButton.icon(
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text('${_label(kind)} · ${context.l10n.dialogRetry}'),
|
||||
onPressed: () =>
|
||||
ref.invalidate(librarySearchProvider(request)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (loading) {
|
||||
slivers.add(
|
||||
const SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(24),
|
||||
child: Center(child: CircularProgressIndicator.adaptive()),
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (!hasResults && !failed) {
|
||||
slivers.add(
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
context.l10n.searchEmptyResultSubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SliverMainAxisGroup(slivers: slivers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/l10n/app_localizations.dart';
|
||||
import 'package:spotiflac_android/providers/library_search_provider.dart';
|
||||
import 'package:spotiflac_android/services/library_search.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
import 'package:spotiflac_android/widgets/library_search_results.dart';
|
||||
|
||||
LibrarySearchHit _hit(
|
||||
LibrarySearchKind kind,
|
||||
int index, {
|
||||
String query = 'Found',
|
||||
}) => LibrarySearchHit(
|
||||
kind: kind,
|
||||
id: '$index',
|
||||
title: '$query ${kind.name} $index',
|
||||
);
|
||||
|
||||
void main() {
|
||||
for (final mornye in [false, true]) {
|
||||
testWidgets('Library shows every result type and paginates ($mornye)', (
|
||||
tester,
|
||||
) async {
|
||||
final requests = <LibrarySearchRequest>[];
|
||||
final artists = <String>[];
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
librarySearchProvider.overrideWith((ref, request) async {
|
||||
requests.add(request);
|
||||
return List.generate(
|
||||
request.limit,
|
||||
(i) => _hit(request.kind, request.offset + i),
|
||||
);
|
||||
}),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: mornye ? MornyeTheme.build(Brightness.dark) : ThemeData(),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
LibrarySearchResults(
|
||||
query: 'Found',
|
||||
onOpenArtist: artists.add,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
requests.map((r) => r.kind).toSet(),
|
||||
LibrarySearchKind.values.toSet(),
|
||||
);
|
||||
expect(requests.every((r) => r.limit == 6 && r.offset == 0), isTrue);
|
||||
await tester.tap(find.text('Artists').first);
|
||||
await tester.pumpAndSettle();
|
||||
expect(requests.last.limit, 41);
|
||||
await tester.tap(find.text('Found artists 0'));
|
||||
expect(artists, ['Found artists 0']);
|
||||
await tester.scrollUntilVisible(
|
||||
find.byIcon(Icons.expand_more),
|
||||
600,
|
||||
scrollable: find.byType(Scrollable).first,
|
||||
);
|
||||
await tester.tap(find.byIcon(Icons.expand_more));
|
||||
await tester.pumpAndSettle();
|
||||
expect(requests.last.offset, 40);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
|
||||
testWidgets('an older query cannot replace newer results', (tester) async {
|
||||
final old = Completer<List<LibrarySearchHit>>();
|
||||
late StateSetter update;
|
||||
var query = 'old';
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
librarySearchProvider.overrideWith((ref, request) {
|
||||
if (request.kind != LibrarySearchKind.songs) {
|
||||
return Future.value([]);
|
||||
}
|
||||
return request.query == 'old'
|
||||
? old.future
|
||||
: Future.value([_hit(request.kind, 0, query: request.query)]);
|
||||
}),
|
||||
],
|
||||
child: MaterialApp(
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
update = setState;
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
LibrarySearchResults(query: query, onOpenArtist: (_) {}),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
update(() => query = 'new');
|
||||
await tester.pumpAndSettle();
|
||||
old.complete([_hit(LibrarySearchKind.songs, 0, query: 'old')]);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('new songs 0'), findsOneWidget);
|
||||
expect(find.text('old songs 0'), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:spotiflac_android/services/library_search.dart';
|
||||
|
||||
// Execute the production SQL, including bound parameters, against real SQLite.
|
||||
// Python is also used by the repository's native artifact checks in CI.
|
||||
class _SearchDatabase implements DatabaseExecutor {
|
||||
@override
|
||||
Future<List<Map<String, Object?>>> rawQuery(
|
||||
String sql, [
|
||||
List<Object?>? arguments,
|
||||
]) async {
|
||||
final result = await Process.run('python3', [
|
||||
'-c',
|
||||
r'''
|
||||
import json, sqlite3, sys
|
||||
db = sqlite3.connect(':memory:')
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("ATTACH DATABASE ':memory:' AS history_db")
|
||||
columns = """id TEXT PRIMARY KEY, track_name TEXT, artist_name TEXT,
|
||||
album_name TEXT, album_artist TEXT, album_key TEXT, sort_track TEXT,
|
||||
sort_album TEXT, sort_album_artist TEXT, track_name_norm TEXT,
|
||||
album_name_norm TEXT, album_artist_norm TEXT, search_text TEXT,
|
||||
cover_url TEXT, cover_path TEXT, file_path TEXT, enabled INTEGER"""
|
||||
db.execute('CREATE TABLE history_db.history (' + columns + ')')
|
||||
db.execute('CREATE TABLE library (' + columns + ')')
|
||||
db.execute('CREATE VIEW library_visible AS SELECT * FROM library WHERE enabled = 1')
|
||||
for table in ['library_path_keys', 'history_db.history_path_keys']:
|
||||
db.execute('CREATE TABLE ' + table + ' (item_id TEXT, path_key TEXT)')
|
||||
for table in ['history_db.history_search_fts', 'library_search_fts']:
|
||||
db.execute('CREATE VIRTUAL TABLE ' + table + " USING fts5(search_text, tokenize='trigram')")
|
||||
def track(local, id, title, artist, album, path=None, enabled=1):
|
||||
table = 'library' if local else 'history_db.history'
|
||||
path = path or '/' + id + '.flac'
|
||||
fields = dict(id=id, track_name=title, artist_name=artist,
|
||||
album_name=album, album_artist=artist,
|
||||
album_key=(album+'|'+artist).lower(), sort_track=title.lower(),
|
||||
sort_album=album.lower(), sort_album_artist=artist.lower(),
|
||||
track_name_norm=title.lower(), album_name_norm=album.lower(),
|
||||
album_artist_norm=artist.lower(), search_text=(title+' '+artist+' '+album).lower(),
|
||||
file_path=path, enabled=enabled)
|
||||
cur = db.execute('INSERT INTO '+table+' ('+','.join(fields)+') VALUES ('+','.join('?' for _ in fields)+')', list(fields.values()))
|
||||
fts = 'library_search_fts' if local else 'history_db.history_search_fts'
|
||||
db.execute('INSERT INTO '+fts+' (rowid, search_text) VALUES (?, ?)', (cur.lastrowid, fields['search_text']))
|
||||
paths = 'library_path_keys' if local else 'history_db.history_path_keys'
|
||||
db.execute('INSERT INTO '+paths+' VALUES (?, ?)', (id, path))
|
||||
track(False, '1', 'Lilac', 'Mrs. GREEN APPLE', 'Spring')
|
||||
track(False, '2', 'Blue', 'Mrs. GREEN APPLE', 'Spring')
|
||||
track(False, '3', 'Live Lilac', 'Someone Else', 'Concert')
|
||||
track(True, 'local', 'Lilac acoustic', 'Mrs. GREEN APPLE', 'Acoustic')
|
||||
track(True, 'duplicate', 'Lilac', 'Mrs. GREEN APPLE', 'Spring', path='/1.flac')
|
||||
track(True, 'hidden', 'Lilac hidden', 'Hidden Artist', 'Hidden', enabled=0)
|
||||
track(False, '4', "Don't Stop", 'Artist', 'Night')
|
||||
track(True, '5', '夜の歌', '歌手', '夜空')
|
||||
track(False, '6', 'OR Nothing', 'Artist', 'Night')
|
||||
for i in range(85):
|
||||
track(False, 'page-'+str(i), 'Page '+str(i).zfill(3), 'Artist', 'Many')
|
||||
query = json.loads(sys.argv[1])
|
||||
print(json.dumps([dict(row) for row in db.execute(query['sql'], query['args'])]))
|
||||
''',
|
||||
jsonEncode({'sql': sql, 'args': arguments}),
|
||||
]);
|
||||
if (result.exitCode != 0) fail('${result.stderr}\n$sql\n$arguments');
|
||||
return (jsonDecode(result.stdout as String) as List)
|
||||
.cast<Map<String, dynamic>>();
|
||||
}
|
||||
|
||||
@override
|
||||
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final fts in [false, true]) {
|
||||
group('Library search (FTS: $fts)', () {
|
||||
final store = LibrarySearchStore(
|
||||
_SearchDatabase(),
|
||||
historyFts: fts,
|
||||
localFts: fts,
|
||||
);
|
||||
Future<List<LibrarySearchHit>> search(
|
||||
String query, {
|
||||
LibrarySearchKind kind = LibrarySearchKind.songs,
|
||||
bool includeLocal = true,
|
||||
int limit = 40,
|
||||
int offset = 0,
|
||||
}) => store.search(
|
||||
query: query,
|
||||
kind: kind,
|
||||
includeLocal: includeLocal,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
);
|
||||
|
||||
test(
|
||||
'matches unordered words across title and artist, including partial words',
|
||||
() async {
|
||||
expect((await search('green lil')).map((hit) => hit.id), [
|
||||
'1',
|
||||
'local',
|
||||
]);
|
||||
expect((await search('mrs. APPLE lilac')).map((hit) => hit.id), [
|
||||
'1',
|
||||
'local',
|
||||
]);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'ranks exact title first and excludes hidden sources and duplicate paths',
|
||||
() async {
|
||||
expect((await search('lilac')).map((hit) => hit.id), [
|
||||
'1',
|
||||
'local',
|
||||
'3',
|
||||
]);
|
||||
expect(
|
||||
(await search('lilac', includeLocal: false)).map((hit) => hit.id),
|
||||
['1', '3'],
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'handles short, non-Latin and punctuated queries as literal terms',
|
||||
() async {
|
||||
expect((await search('夜')).single.id, '5');
|
||||
expect((await search("DON’T stop")).single.id, '4');
|
||||
expect((await search('OR')).map((hit) => hit.id), contains('6'));
|
||||
expect(await search('***'), isEmpty);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'album results match album fields and retain full track counts',
|
||||
() async {
|
||||
final hits = await search(
|
||||
'apple spring',
|
||||
kind: LibrarySearchKind.albums,
|
||||
);
|
||||
expect(hits.single.title, 'Spring');
|
||||
expect(hits.single.trackCount, 2);
|
||||
expect(
|
||||
await search('lilac', kind: LibrarySearchKind.albums),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'artists combine local and downloaded songs without counting duplicate files',
|
||||
() async {
|
||||
final hits = await search('green', kind: LibrarySearchKind.artists);
|
||||
expect(hits.single.title, 'Mrs. GREEN APPLE');
|
||||
expect(hits.single.trackCount, 3);
|
||||
},
|
||||
);
|
||||
test(
|
||||
'pagination has stable ordering without missing or repeated hits',
|
||||
() async {
|
||||
final first = await search('page', limit: 40);
|
||||
final second = await search('page', limit: 40, offset: 40);
|
||||
final third = await search('page', limit: 40, offset: 80);
|
||||
expect([first.length, second.length, third.length], [40, 40, 5]);
|
||||
expect({
|
||||
...first.map((h) => h.id),
|
||||
...second.map((h) => h.id),
|
||||
...third.map((h) => h.id),
|
||||
}, hasLength(85));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:spotiflac_android/models/download_item.dart';
|
||||
import 'package:spotiflac_android/models/track.dart';
|
||||
import 'package:spotiflac_android/providers/download_queue_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_browse_provider.dart';
|
||||
import 'package:spotiflac_android/providers/library_search_provider.dart';
|
||||
import 'package:spotiflac_android/screens/mornye_library_screen.dart';
|
||||
import 'package:spotiflac_android/theme/mornye_theme.dart';
|
||||
|
||||
@@ -28,6 +29,7 @@ void main() {
|
||||
addTearDown(tester.view.reset);
|
||||
final opened = <String>[];
|
||||
final requests = <LibraryBrowseRequest>[];
|
||||
final searches = <LibrarySearchRequest>[];
|
||||
final queue = DownloadQueueLookup.fromItems([
|
||||
for (final status in [DownloadStatus.queued, DownloadStatus.completed])
|
||||
DownloadItem(
|
||||
@@ -52,6 +54,10 @@ void main() {
|
||||
requests.add(request);
|
||||
return [_album(0), _album(1)];
|
||||
}),
|
||||
librarySearchProvider.overrideWith((ref, request) async {
|
||||
searches.add(request);
|
||||
return [];
|
||||
}),
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: MornyeTheme.build(brightness),
|
||||
@@ -79,6 +85,17 @@ void main() {
|
||||
await tester.tap(find.text('Playlists'));
|
||||
expect(opened, ['downloads', 'all', 'playlists']);
|
||||
|
||||
await tester.tap(find.byTooltip('Search your library'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField), 'green lilac');
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
await tester.pumpAndSettle();
|
||||
expect(searches, hasLength(4));
|
||||
expect(find.byType(TextField), findsOneWidget);
|
||||
await tester.tap(find.byTooltip('Clear'));
|
||||
await tester.pump(const Duration(milliseconds: 350));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Artists'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(requests.last.artists, isTrue);
|
||||
|
||||
Reference in New Issue
Block a user