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

This commit is contained in:
zarzet committed 2026-09-27 00:00:01 +07:00
1 parent 7a26f3beac
commit 369afa550b
9 files changed
+1125 -188

No files matched your search

+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,