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);
}
}