From 369afa550ba80e59b1e66262f21fb2b35dd8f24c Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:00:01 +0700 Subject: [PATCH] feat(library): search songs albums artists and playlists by relevance --- lib/providers/library_search_provider.dart | 57 ++++ lib/screens/mornye_library_screen.dart | 361 +++++++++++---------- lib/screens/queue_tab.dart | 66 +++- lib/services/library_database.dart | 23 ++ lib/services/library_search.dart | 211 ++++++++++++ lib/widgets/library_search_results.dart | 283 ++++++++++++++++ test/library_search_results_test.dart | 123 +++++++ test/library_search_test.dart | 172 ++++++++++ test/mornye_library_browse_test.dart | 17 + 9 files changed, 1125 insertions(+), 188 deletions(-) create mode 100644 lib/providers/library_search_provider.dart create mode 100644 lib/services/library_search.dart create mode 100644 lib/widgets/library_search_results.dart create mode 100644 test/library_search_results_test.dart create mode 100644 test/library_search_test.dart diff --git a/lib/providers/library_search_provider.dart b/lib/providers/library_search_provider.dart new file mode 100644 index 00000000..044ed720 --- /dev/null +++ b/lib/providers/library_search_provider.dart @@ -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, 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, + ); + }); diff --git a/lib/screens/mornye_library_screen.dart b/lib/screens/mornye_library_screen.dart index 71aca415..131df083 100644 --- a/lib/screens/mornye_library_screen.dart +++ b/lib/screens/mornye_library_screen.dart @@ -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 { @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>([]) + : ref.watch(libraryBrowseProvider(_request)); _loading = result.isLoading; if (result.hasValue) _rows = result.requireValue; final title = @@ -205,6 +209,7 @@ class _MornyeLibraryScreenState extends ConsumerState { ]; 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 { ), ), ), - 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(), ], ), diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 5df9baa7..d4a08b11 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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 { 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 { } 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 { _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 { 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 { ), ), - 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 { ), ), ], - body: widget.librarySection != null + body: searchingLibrary + ? CustomScrollView( + key: const ValueKey('library-search-results'), + slivers: [ + LibrarySearchResults( + query: _searchQuery, + onOpenArtist: (artist) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold( + body: MornyeLibraryScreen( + page: MornyeLibraryPage.albums, + artist: artist, + onOpenSection: (_) {}, + ), + ), + ), + ), + ), + const NavBarSliverSpacer(), + ], + ) + : widget.librarySection != null ? _buildFilterContent( context: context, colorScheme: colorScheme, diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index b572b3cb..2c51557f 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -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> 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 _migrateIosContainerPaths(Database db) async { if (!Platform.isIOS) return; final documents = await getApplicationDocumentsDirectory(); diff --git a/lib/services/library_search.dart b/lib/services/library_search.dart new file mode 100644 index 00000000..19df2824 --- /dev/null +++ b/lib/services/library_search.dart @@ -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 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 args, { + String? rowId, + String? ftsTable, + String? ftsContentTable, + }) { + if (terms.isEmpty) return '0'; + final indexed = ftsTable == null + ? const [] + : terms.where((term) => term.runes.length >= 3).toList(); + final predicates = []; + 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 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 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> 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 = []; + final parts = []; + 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 = [ + 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(); + } +} diff --git a/lib/widgets/library_search_results.dart b/lib/widgets/library_search_results.dart new file mode 100644 index 00000000..ae34ca23 --- /dev/null +++ b/lib/widgets/library_search_results.dart @@ -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 onOpenArtist; + + @override + ConsumerState createState() => + _LibrarySearchResultsState(); +} + +class _LibrarySearchResultsState extends ConsumerState { + 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 _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( + 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( + builder: (_) => LocalAlbumScreen( + albumName: hit.title, + artistName: hit.artist, + coverPath: hit.cover, + tracks: rows.map(LocalLibraryItem.fromJson).toList(), + ), + ), + ); + } else { + await Navigator.of(context).push( + MaterialPageRoute( + 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 = [ + 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 []; + 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); + } +} diff --git a/test/library_search_results_test.dart b/test/library_search_results_test.dart new file mode 100644 index 00000000..893c3f82 --- /dev/null +++ b/test/library_search_results_test.dart @@ -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 = []; + final artists = []; + 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>(); + 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); + }); +} diff --git a/test/library_search_test.dart b/test/library_search_test.dart new file mode 100644 index 00000000..1a1d3830 --- /dev/null +++ b/test/library_search_test.dart @@ -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>> rawQuery( + String sql, [ + List? 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>(); + } + + @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> 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)); + }, + ); + }); + } +} diff --git a/test/mornye_library_browse_test.dart b/test/mornye_library_browse_test.dart index 4107d5f0..7f8fad82 100644 --- a/test/mornye_library_browse_test.dart +++ b/test/mornye_library_browse_test.dart @@ -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 = []; final requests = []; + final searches = []; 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);