feat(library): add Mornye browsing and contextual compact navigation

Add category browsing, a two-column Recently Added grid, and a dedicated download queue. Keep compact navigation icons and their animations aligned with the active tab.

Keep profile avatars circular and report lyric enrichment outcomes accurately, preserving existing lyrics and supporting instrumental markers.
This commit is contained in:
zarzet committed 2026-09-21 23:02:52 +07:00
1 parent 4c5af08cdd
commit c87c9ff848
23 files changed
+1257 -128

No files matched your search

+462
View File
@@ -0,0 +1,462 @@
import 'dart:async';
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_queue_provider.dart';
import 'package:spotiflac_android/providers/library_browse_provider.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/nav_bar_inset.dart';
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';
enum MornyeLibraryPage { overview, albums, artists }
class MornyeLibraryScreen extends ConsumerStatefulWidget {
const MornyeLibraryScreen({
super.key,
required this.onOpenSection,
this.page = MornyeLibraryPage.overview,
this.artist,
});
final ValueChanged<String> onOpenSection;
final MornyeLibraryPage page;
final String? artist;
@override
ConsumerState<MornyeLibraryScreen> createState() =>
_MornyeLibraryScreenState();
}
class _MornyeLibraryScreenState extends ConsumerState<MornyeLibraryScreen> {
final _scroll = ScrollController();
final _search = TextEditingController();
Timer? _debounce;
bool _showSearch = false;
String _query = '';
late String _sort;
int _limit = 40;
List<LibraryBrowseEntry> _rows = const [];
bool _loading = true;
bool get _overview => widget.page == MornyeLibraryPage.overview;
bool get _artists => widget.page == MornyeLibraryPage.artists;
LibraryBrowseRequest get _request => (
artists: _artists,
artist: widget.artist,
search: _query,
sort: _sort,
limit: _limit,
);
@override
void initState() {
super.initState();
_sort = _overview ? 'latest' : 'a-z';
_scroll.addListener(() {
if (_scroll.position.extentAfter < 500 &&
!_loading &&
_rows.length >= _limit) {
setState(() => _limit += 40);
}
});
}
@override
void dispose() {
_debounce?.cancel();
_scroll.dispose();
_search.dispose();
super.dispose();
}
void _searchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
if (!mounted || _query == value.trim()) return;
setState(() {
_query = value.trim();
_limit = 40;
_rows = const [];
});
});
}
void _openPage(MornyeLibraryPage page, {String? artist}) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => Scaffold(
body: MornyeLibraryScreen(
page: page,
artist: artist,
onOpenSection: widget.onOpenSection,
),
),
),
);
}
Future<void> _openAlbum(LibraryBrowseEntry entry) async {
if (entry.source != 'local') {
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DownloadedAlbumScreen(
albumName: entry.name,
artistName: entry.artist,
coverUrl: entry.cover,
),
),
);
return;
}
final rows = await LibraryDatabase.instance.getQueueLocalAlbumTracksByKey(
entry.key,
);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => LocalAlbumScreen(
albumName: entry.name,
artistName: entry.artist,
coverPath: entry.cover,
tracks: rows.map(LocalLibraryItem.fromJson).toList(growable: false),
),
),
);
}
Future<void> _chooseSort() async {
final selected = await showAppBottomSheet<String>(
context: context,
builder: (context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final option in [
('a-z', context.l10n.searchSortTitleAZ),
('latest', context.l10n.libraryRecentlyAdded),
])
ListTile(
title: Text(option.$2),
trailing: _sort == option.$1 ? const Icon(Icons.check) : null,
onTap: () => Navigator.pop(context, option.$1),
),
],
),
);
if (!mounted || selected == null || selected == _sort) return;
setState(() {
_sort = selected;
_limit = 40;
_rows = const [];
});
_scroll.jumpTo(0);
}
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
final result = ref.watch(libraryBrowseProvider(_request));
_loading = result.isLoading;
if (result.hasValue) _rows = result.requireValue;
final title =
widget.artist ??
(_overview
? context.l10n.navLibrary
: _artists
? context.l10n.searchArtists
: context.l10n.searchAlbums);
final actions = <Widget>[
IconButton(
tooltip: context.l10n.librarySearchHint,
icon: const Icon(Icons.search),
onPressed: () => setState(() => _showSearch = !_showSearch),
),
if (_overview)
Consumer(
builder: (context, ref, _) {
final count = ref.watch(
downloadQueueLookupProvider.select(
(s) => s.notCompletedItemIds.length,
),
);
return TextButton.icon(
onPressed: () => widget.onOpenSection('downloads'),
icon: Badge(
isLabelVisible: count > 0,
label: Text('$count'),
child: const Icon(Icons.downloading_outlined),
),
label: Text(context.l10n.libraryDownloads),
);
},
)
else if (!_artists)
IconButton(
tooltip: context.l10n.searchSortTitle,
icon: const Icon(Icons.sort),
onPressed: _chooseSort,
),
];
return RefreshIndicator(
onRefresh: () async {
ref.invalidate(libraryBrowseProvider(_request));
await ref.read(libraryBrowseProvider(_request).future);
},
child: CustomScrollView(
controller: _scroll,
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
if (_overview)
AppSliverHeader.tabRoot(title: title, actions: actions)
else
AppSliverHeader.page(title: title, actions: actions),
if (_showSearch)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
child: AppSearchField(
controller: _search,
autofocus: true,
hintText: context.l10n.librarySearchHint,
clearTooltip: context.l10n.dialogClear,
onChanged: _searchChanged,
onClear: () {
_search.clear();
_searchChanged('');
},
),
),
),
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),
),
),
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),
),
),
)
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(),
],
),
);
}
}
class _LibraryBrowseArtwork extends StatefulWidget {
const _LibraryBrowseArtwork({required this.entry});
final LibraryBrowseEntry entry;
@override
State<_LibraryBrowseArtwork> createState() => _LibraryBrowseArtworkState();
}
class _LibraryBrowseArtworkState extends State<_LibraryBrowseArtwork> {
@override
Widget build(BuildContext context) {
final entry = widget.entry;
final embedded = entry.cover?.isNotEmpty == true
? null
: DownloadedEmbeddedCoverResolver.resolve(
entry.samplePath,
onChanged: () {
if (mounted) setState(() {});
},
);
Widget fallback(BuildContext context) => ColoredBox(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
child: Center(
child: Icon(
Icons.album_outlined,
size: 40,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
);
final cover = entry.cover?.isNotEmpty == true ? entry.cover! : embedded;
return cover == null
? fallback(context)
: LocalOrNetworkCoverImage(url: cover, placeholder: fallback);
}
}
+101 -34
View File
@@ -50,6 +50,7 @@ import 'package:cached_network_image/cached_network_image.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart';
import 'package:spotiflac_android/screens/local_album_screen.dart';
import 'package:spotiflac_android/screens/mornye_library_screen.dart';
import 'package:spotiflac_android/screens/queue_library_refresh_policy.dart';
import 'package:spotiflac_android/utils/clickable_metadata.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
@@ -207,12 +208,14 @@ DownloadHistoryItem? _historyItemForCompletionBridge(
}
class QueueTab extends ConsumerStatefulWidget {
final String? librarySection;
final PageController? parentPageController;
final int parentPageIndex;
final int? nextPageIndex;
const QueueTab({
super.key,
this.librarySection,
this.parentPageController,
this.parentPageIndex = 1,
this.nextPageIndex,
@@ -349,13 +352,16 @@ class _QueueTabState extends ConsumerState<QueueTab> {
if (_isPageControllerInitialized) return;
_isPageControllerInitialized = true;
final settings = ref.read(settingsProvider);
final initialFilter = settings.defaultLibraryView == 'last'
? settings.historyFilterMode
: settings.defaultLibraryView;
final initialFilter =
widget.librarySection ??
(settings.defaultLibraryView == 'last'
? settings.historyFilterMode
: settings.defaultLibraryView);
final initialPage = _filterModes
.indexOf(initialFilter)
.clamp(0, _filterModes.length - 1);
if (settings.historyFilterMode != _filterModes[initialPage]) {
if (widget.librarySection == null &&
settings.historyFilterMode != _filterModes[initialPage]) {
Future.microtask(() {
if (!mounted) return;
ref
@@ -369,6 +375,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
/// When the shell switches back to this tab and a fixed default view is
/// configured, jump the filter pager to it.
void _applyDefaultLibraryViewOnTabVisible() {
if (widget.librarySection != null) return;
final isVisible = TickerMode.valuesOf(context).enabled;
final becameVisible = isVisible && !_wasTabVisible;
_wasTabVisible = isVisible;
@@ -651,6 +658,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
}
void _onFilterPageChanged(int index) {
if (widget.librarySection != null) return;
HapticFeedback.selectionClick();
final filterMode = _filterModes[index];
ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode);
@@ -1220,6 +1228,15 @@ class _QueueTabState extends ConsumerState<QueueTab> {
@override
Widget build(BuildContext context) {
if (context.isMornye && widget.librarySection == null) {
return MornyeLibraryScreen(
onOpenSection: (section) => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => Scaffold(body: QueueTab(librarySection: section)),
),
),
);
}
_initializePageController();
_applyDefaultLibraryViewOnTabVisible();
@@ -1278,6 +1295,34 @@ class _QueueTabState extends ConsumerState<QueueTab> {
},
);
if (widget.librarySection == 'downloads') {
final ids = ref.watch(
downloadQueueLookupProvider.select((s) => s.notCompletedItemIds),
);
final colors = Theme.of(context).colorScheme;
return CustomScrollView(
slivers: [
AppSliverHeader.page(title: context.l10n.libraryDownloads),
_buildQueueHeaderSliver(context, colors),
SliverList.builder(
itemCount: ids.length,
itemBuilder: (context, index) => _QueueItemSliverRow(
key: ValueKey(ids[index]),
itemId: ids[index],
colorScheme: colors,
itemBuilder: _buildQueueItem,
),
),
if (ids.isEmpty)
SliverFillRemaining(
hasScrollBody: false,
child: Center(child: Text(context.l10n.libraryNoActiveDownloads)),
),
const NavBarSliverSpacer(),
],
);
}
final hasQueueItems = ref.watch(
downloadQueueLookupProvider.select((lookup) => lookup.itemIds.isNotEmpty),
);
@@ -1314,9 +1359,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
final historyViewMode = ref.watch(
settingsProvider.select((s) => s.historyViewMode),
);
final historyFilterMode = ref.watch(
settingsProvider.select((s) => s.historyFilterMode),
);
final String historyFilterMode =
widget.librarySection ??
ref.watch<String>(settingsProvider.select((s) => s.historyFilterMode));
// Keep this mode out of the page-provider request: changing only badge
// text must not re-query the database or reset Library pagination.
_libraryQualityLabelMode = ref.watch(
@@ -1493,7 +1538,14 @@ class _QueueTabState extends ConsumerState<QueueTab> {
).copyWith(overscroll: false),
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxIsScrolled) => [
AppSliverHeader.tabRoot(title: context.l10n.navLibrary),
if (widget.librarySection == null)
AppSliverHeader.tabRoot(title: context.l10n.navLibrary)
else
AppSliverHeader.page(
title: widget.librarySection == 'playlists'
? context.l10n.searchPlaylists
: context.l10n.searchSongs,
),
if (shouldShowLibraryControls || hasQueueItems)
SliverToBoxAdapter(
@@ -1504,7 +1556,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
child: AppSearchField(
controller: _searchController,
focusNode: _searchFocusNode,
hintText: context.l10n.historySearchHint,
hintText: widget.librarySection == null
? context.l10n.historySearchHint
: context.l10n.librarySearchHint,
clearTooltip: context.l10n.dialogClear,
onChanged: _onSearchChanged,
onClear: () {
@@ -1516,7 +1570,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
if (shouldShowLibraryControls)
if (shouldShowLibraryControls && widget.librarySection == null)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
@@ -1585,30 +1639,43 @@ class _QueueTabState extends ConsumerState<QueueTab> {
),
),
],
body: PageView.builder(
controller: _filterPageController!,
physics: const ClampingScrollPhysics(),
onPageChanged: _onFilterPageChanged,
itemCount: _filterModes.length,
itemBuilder: (context, index) {
final filterMode = _filterModes[index];
final filterData = getFilterData(filterMode);
return _buildFilterContent(
context: context,
colorScheme: colorScheme,
filterMode: filterMode,
historyViewMode: historyViewMode,
hasQueueItems: hasQueueItems,
filterData: filterData,
collectionState: collectionState,
hasMoreLibrary: filterMode == historyFilterMode
? hasMoreLibrary
: false,
isPageLoading: isLibraryPageLoading,
inMemoryHistoryItems: inMemoryHistoryItems,
);
},
),
body: widget.librarySection != null
? _buildFilterContent(
context: context,
colorScheme: colorScheme,
filterMode: historyFilterMode,
historyViewMode: 'list',
hasQueueItems: false,
filterData: getFilterData(historyFilterMode),
collectionState: collectionState,
hasMoreLibrary: hasMoreLibrary,
isPageLoading: isLibraryPageLoading,
inMemoryHistoryItems: inMemoryHistoryItems,
)
: PageView.builder(
controller: _filterPageController!,
physics: const ClampingScrollPhysics(),
onPageChanged: _onFilterPageChanged,
itemCount: _filterModes.length,
itemBuilder: (context, index) {
final filterMode = _filterModes[index];
final filterData = getFilterData(filterMode);
return _buildFilterContent(
context: context,
colorScheme: colorScheme,
filterMode: filterMode,
historyViewMode: historyViewMode,
hasQueueItems: hasQueueItems,
filterData: filterData,
collectionState: collectionState,
hasMoreLibrary: filterMode == historyFilterMode
? hasMoreLibrary
: false,
isPageLoading: isLibraryPageLoading,
inMemoryHistoryItems: inMemoryHistoryItems,
);
},
),
),
), // ScrollConfiguration
],
+6 -3
View File
@@ -52,7 +52,7 @@ extension _QueueTabFilterWidgets on _QueueTabState {
final totalTrackCount = filterData.totalTrackCount;
final totalAlbumCount = filterData.totalAlbumCount;
final activeDownloadIds = filterMode == 'albums'
final activeDownloadIds = !hasQueueItems || filterMode == 'albums'
? const <String>[]
: ref
.watch(
@@ -74,7 +74,9 @@ extension _QueueTabFilterWidgets on _QueueTabState {
),
};
List<String> bridgeIds = const [];
if (filterMode != 'albums' && _completionBridge.isNotEmpty) {
if (hasQueueItems &&
filterMode != 'albums' &&
_completionBridge.isNotEmpty) {
final now = DateTime.now();
final stale = <String>[];
final pending = <String>[];
@@ -394,7 +396,8 @@ extension _QueueTabFilterWidgets on _QueueTabState {
),
),
if (filterMode == 'all') _buildQueueHeaderSliver(context, colorScheme),
if (filterMode == 'all' && hasQueueItems)
_buildQueueHeaderSliver(context, colorScheme),
if (filterMode == 'albums' &&
(filteredGroupedAlbums.isNotEmpty ||
+10 -2
View File
@@ -1067,9 +1067,12 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
(result['lyrics'] ?? result['lyrics_lrc'])?.toString() ?? '',
),
);
await _checkEmbeddedLyrics();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
SnackBar(
content: Text(reEnrichCompletionMessage(context.l10n, result)),
),
);
}
} else if (method == 'ffmpeg') {
@@ -1168,9 +1171,14 @@ extension _TrackMetadataLyricsAndSaving on _TrackMetadataScreenState {
(result['lyrics'] ?? result['lyrics_lrc'])?.toString() ?? '',
),
);
await _checkEmbeddedLyrics();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.trackReEnrichSuccess)),
SnackBar(
content: Text(
reEnrichCompletionMessage(context.l10n, result),
),
),
);
}
} else if (mounted) {
+1
View File
@@ -19,6 +19,7 @@ import 'package:path/path.dart' as p;
import 'package:spotiflac_android/services/conversion_library_service.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/re_enrich_result.dart';
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:share_plus/share_plus.dart';