feat(library): search songs albums artists and playlists by relevance

This commit is contained in:
zarzet
2026-09-27 00:00:01 +07:00
parent 7a26f3beac
commit 369afa550b
9 changed files with 1125 additions and 188 deletions
@@ -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,
);
});
+188 -173
View File
@@ -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
View File
@@ -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,
+23
View File
@@ -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();
+211
View File
@@ -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();
}
}
+283
View File
@@ -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);
}
}
+123
View File
@@ -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);
});
}
+172
View File
@@ -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));
},
);
});
}
}
+17
View File
@@ -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);