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

+9
View File
@@ -71,6 +71,11 @@
"@historyFilterSingles": {
"description": "Filter chip - show singles only"
},
"libraryRecentlyAdded": "Recently Added",
"libraryDownloads": "Downloads",
"libraryNoActiveDownloads": "No active downloads",
"librarySearchHint": "Search your library",
"libraryEmptyCollection": "Your music will appear here",
"historySearchHint": "Search history...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
@@ -3372,6 +3377,10 @@
"description": "Progress message while preparing manual batch metadata changes"
},
"trackReEnrichSuccess": "Metadata re-enriched successfully",
"trackReEnrichMetadataSaved": "Metadata saved",
"trackReEnrichLyricsUpdated": "Lyrics updated",
"trackReEnrichLyricsPreserved": "No new lyrics found; existing lyrics kept",
"trackReEnrichLyricsDisabled": "Lyrics not updated: lyric downloading is disabled in Settings",
"@trackReEnrichSuccess": {
"description": "Snackbar after successful re-enrichment"
},
+9
View File
@@ -2069,6 +2069,11 @@
"@filenameFormat": {
"description": "Setting title - filename pattern"
},
"libraryRecentlyAdded": "Baru Ditambahkan",
"libraryDownloads": "Unduhan",
"libraryNoActiveDownloads": "Tidak ada unduhan aktif",
"librarySearchHint": "Cari di koleksi musik",
"libraryEmptyCollection": "Musikmu akan muncul di sini",
"historySearchHint": "Cari riwayat...",
"@historySearchHint": {
"description": "Search bar placeholder in history"
@@ -2345,6 +2350,10 @@
"description": "Filter section title"
},
"trackReEnrichSuccess": "Metadata re-enriched successfully",
"trackReEnrichMetadataSaved": "Metadata disimpan",
"trackReEnrichLyricsUpdated": "Lirik diperbarui",
"trackReEnrichLyricsPreserved": "Lirik baru tidak ditemukan; lirik lama dipertahankan",
"trackReEnrichLyricsDisabled": "Lirik tidak diperbarui: unduhan lirik dinonaktifkan di Pengaturan",
"@trackReEnrichSuccess": {
"description": "Snackbar after successful re-enrichment"
},
@@ -0,0 +1,80 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/providers/download_history_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';
typedef LibraryBrowseRequest = ({
bool artists,
String? artist,
String search,
String sort,
int limit,
});
class LibraryBrowseEntry {
final String source;
final String key;
final String name;
final String artist;
final String? cover;
final String samplePath;
final int trackCount;
const LibraryBrowseEntry({
required this.source,
required this.key,
required this.name,
required this.artist,
this.cover,
required this.samplePath,
required this.trackCount,
});
factory LibraryBrowseEntry.fromRow(
Map<String, dynamic> row, {
required bool isArtist,
}) {
final artist = row['artist_name'] as String? ?? '';
final localCover = row['cover_path'] as String?;
return LibraryBrowseEntry(
source: row['queue_source'] as String? ?? '',
key: (row[isArtist ? 'artist_key' : 'album_key'] as String?) ?? '',
name: isArtist ? artist : row['album_name'] as String? ?? '',
artist: artist,
cover: localCover?.isNotEmpty == true
? localCover
: row['cover_url'] as String?,
samplePath: row['sample_file_path'] as String? ?? '',
trackCount: (row['track_count'] as num?)?.toInt() ?? 0,
);
}
}
final libraryBrowseProvider = FutureProvider.autoDispose
.family<List<LibraryBrowseEntry>, LibraryBrowseRequest>((
ref,
request,
) async {
ref.watch(downloadHistoryProvider.select((s) => s.loadedIndexVersion));
ref.watch(localLibraryProvider.select((s) => s.loadedIndexVersion));
final includeLocal = ref.watch(
settingsProvider.select((s) => s.localLibraryEnabled),
);
final query = QueueLibraryDbQuery(
limit: request.limit,
includeSingleTrackAlbums: true,
albumArtist: request.artist,
searchQuery: request.search,
sortMode: request.sort,
includeLocal: includeLocal,
);
final rows = request.artists
? await LibraryDatabase.instance.getQueueArtistPage(query)
: await LibraryDatabase.instance.getQueueAlbumPage(query);
return rows
.map(
(row) => LibraryBrowseEntry.fromRow(row, isArtist: request.artists),
)
.toList(growable: false);
});
+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';
+49
View File
@@ -1604,6 +1604,55 @@ class LibraryDatabase {
);
}
/// Album artists across downloaded and scanned music, without loading tracks
/// into Dart. Scanned paths already represented by downloads are excluded.
Future<List<Map<String, dynamic>>> getQueueArtistPage(
QueueLibraryDbQuery request,
) async {
final db = await database;
await _ensureHistoryAttached(db);
final parts = <String>[
'''
SELECT sort_album_artist AS artist_key,
COALESCE(NULLIF(album_artist, ''), artist_name) AS artist_name,
cover_url, NULL AS cover_path, file_path AS sample_file_path
FROM history_db.history
''',
if (request.includeLocal)
'''
SELECT album_artist_norm AS artist_key,
COALESCE(NULLIF(album_artist, ''), artist_name) AS artist_name,
NULL AS cover_url, cover_path, file_path AS sample_file_path
FROM $visibleLibraryView l
WHERE 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 search = normalizeLookupText(request.searchQuery);
return db.rawQuery(
'''
SELECT artist_key, MIN(artist_name) AS artist_name,
MAX(NULLIF(cover_url, '')) AS cover_url,
MAX(NULLIF(cover_path, '')) AS cover_path,
MAX(sample_file_path) AS sample_file_path,
COUNT(*) AS track_count
FROM (${parts.join(' UNION ALL ')})
WHERE artist_key != '' ${search.isEmpty ? '' : "AND artist_key LIKE ? ESCAPE '\\'"}
GROUP BY artist_key
ORDER BY artist_key
LIMIT ? OFFSET ?
''',
[
if (search.isNotEmpty) '%${_escapeLikePattern(search)}%',
request.limit,
request.offset,
],
);
}
Future<List<Map<String, dynamic>>> getQueueLocalAlbumTracks(
String albumName,
String artistName,
@@ -298,6 +298,8 @@ class IsrcDuplicateGroup {
}
class QueueLibraryDbQuery {
final bool includeSingleTrackAlbums;
final String? albumArtist;
final int limit;
final int offset;
final String filterMode;
@@ -311,6 +313,8 @@ class QueueLibraryDbQuery {
final QueueLibraryDbCursor? cursor;
const QueueLibraryDbQuery({
this.includeSingleTrackAlbums = false,
this.albumArtist,
this.limit = 100,
this.offset = 0,
this.filterMode = 'all',
+10 -2
View File
@@ -266,7 +266,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
MAX(COALESCE(sort_added, 0)) AS latest_added
FROM history_db.history
GROUP BY album_key
HAVING COUNT(*) > 1
HAVING COUNT(*) > ${request.includeSingleTrackAlbums ? 0 : 1}
) c
ON c.album_key = h.album_key
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
@@ -325,7 +325,7 @@ extension _LibraryDbQueueSql on LibraryDatabase {
WHERE lpk.item_id = candidate.id
)
GROUP BY album_key
HAVING COUNT(*) > 1
HAVING COUNT(*) > ${request.includeSingleTrackAlbums ? 0 : 1}
) c ON c.album_key = l.album_key
${where.isEmpty ? '' : 'WHERE ${where.join(' AND ')}'}
GROUP BY c.album_key
@@ -368,6 +368,10 @@ extension _LibraryDbQueueSql on LibraryDatabase {
List<Object?> args,
QueueLibraryDbQuery request,
) {
if (request.albumArtist != null) {
where.add('h.sort_album_artist = ?');
args.add(LibraryDatabase.normalizeLookupText(request.albumArtist));
}
final query = LibraryDatabase.normalizeLookupText(request.searchQuery);
if (query.isNotEmpty) {
final ftsQuery = sqlite.ftsPhraseSearchQuery(query);
@@ -412,6 +416,10 @@ extension _LibraryDbQueueSql on LibraryDatabase {
List<Object?> args,
QueueLibraryDbQuery request,
) {
if (request.albumArtist != null) {
where.add('l.album_artist_norm = ?');
args.add(LibraryDatabase.normalizeLookupText(request.albumArtist));
}
final query = LibraryDatabase.normalizeLookupText(request.searchQuery);
if (query.isNotEmpty) {
final ftsQuery = sqlite.ftsPhraseSearchQuery(query);
+7 -8
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/re_enrich_result.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/models/track.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
@@ -228,8 +229,10 @@ Future<void> reEnrichLocalTracks(
},
);
final lyricsSummary = ReEnrichLyricsSummary();
final successCount = await runner.apply(
previews,
onResult: lyricsSummary.add,
shouldStop: () => cancelled || !context.mounted || !isActive(),
onProgress: (index, item) => BatchProgressDialog.update(
current: index + 1,
@@ -254,14 +257,7 @@ Future<void> reEnrichLocalTracks(
onComplete();
ScaffoldMessenger.of(context).clearSnackBars();
final failedCount = total - successCount;
final summary = failedCount <= 0
? '${context.l10n.trackReEnrichSuccess} ($successCount/$total)'
: context.l10n.trackReEnrichSuccessWithFailures(
successCount,
total,
failedCount,
);
final summary = lyricsSummary.message(context.l10n, successCount, total);
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(summary)));
}
@@ -345,6 +341,7 @@ class BatchReEnrichRunner {
List<BatchReEnrichPreview> previews, {
required bool Function() shouldStop,
required void Function(int, LocalLibraryItem) onProgress,
void Function(Map<String, dynamic>)? onResult,
}) async {
if (shouldStop()) return 0;
// Settings may have changed while the review sheet was open.
@@ -370,6 +367,7 @@ class BatchReEnrichRunner {
reEnrichResult: result,
);
successes++;
onResult?.call(result);
case 'ffmpeg':
if (await applyFfmpeg(
item: preview.item,
@@ -377,6 +375,7 @@ class BatchReEnrichRunner {
artistTagMode: settings.artistTagMode,
)) {
successes++;
onResult?.call(result);
}
}
} catch (_) {
+51
View File
@@ -0,0 +1,51 @@
import 'package:spotiflac_android/l10n/l10n.dart';
String? reEnrichLyricsMessage(AppLocalizations l10n, String? status) =>
switch (status) {
'updated' => l10n.trackReEnrichLyricsUpdated,
'instrumental' => l10n.trackInstrumental,
'not_found' => l10n.trackLyricsNotAvailable,
'preserved' => l10n.trackReEnrichLyricsPreserved,
'disabled' => l10n.trackReEnrichLyricsDisabled,
_ => null,
};
String reEnrichCompletionMessage(
AppLocalizations l10n,
Map<String, dynamic> result,
) {
final lyrics = reEnrichLyricsMessage(
l10n,
result['lyrics_status'] as String?,
);
return lyrics == null
? l10n.trackReEnrichSuccess
: '${l10n.trackReEnrichMetadataSaved}\n$lyrics';
}
/// Count lyric outcomes only after the metadata and sidecar writes finish.
/// Metadata saved is independent of whether new lyrics could be found.
class ReEnrichLyricsSummary {
final _counts = <String, int>{};
void add(Map<String, dynamic> result) {
final status = result['lyrics_status'] as String?;
if (status == null || status == 'not_requested') return;
_counts.update(status, (count) => count + 1, ifAbsent: () => 1);
}
String message(AppLocalizations l10n, int saved, int total) {
final failed = total - saved;
final lines = [
failed > 0
? l10n.trackReEnrichSuccessWithFailures(saved, total, failed)
: _counts.isEmpty
? '${l10n.trackReEnrichSuccess} ($saved/$total)'
: '${l10n.trackReEnrichMetadataSaved} ($saved/$total)',
for (final entry in _counts.entries)
if (reEnrichLyricsMessage(l10n, entry.key) case final label?)
'$label (${entry.value})',
];
return lines.join('\n');
}
}
+33 -25
View File
@@ -98,10 +98,11 @@ class MornyeBottomBar extends ConsumerWidget {
child: const SizedBox.expand(),
),
);
final homeSurface = sideSurface();
final leadingSurface = sideSurface();
final searchSurface = sideSurface(radius: 32);
final mainDestinations = destinations.sublist(0, destinations.length - 1);
final searchSelected = selectedIndex == destinations.length - 1;
final leadingIndex = searchSelected ? 0 : selectedIndex;
final scheme = Theme.of(context).colorScheme;
final inactiveIconColor = Color.lerp(
scheme.onSurfaceVariant,
@@ -109,7 +110,7 @@ class MornyeBottomBar extends ConsumerWidget {
scheme.brightness == Brightness.dark ? 0.5 : 0.4,
);
final player = MiniPlayer(compact: collapsed, bottomPadding: 0);
Widget tabs({required bool hideHomeIcon}) => TickerMode(
Widget tabs({required bool hideLeadingIcon}) => TickerMode(
enabled: !collapsed,
child: RepaintBoundary(
child: MornyeTabBar(
@@ -117,15 +118,15 @@ class MornyeBottomBar extends ConsumerWidget {
selectedIndex: searchSelected ? -1 : selectedIndex,
onSelected: onSelected,
blurEnabled: blurEnabled,
hideHomeIcon: hideHomeIcon,
hiddenIconIndex: hideLeadingIcon ? leadingIndex : null,
),
),
);
// At rest the glass bar must paint its own Home icon: its selected layer
// At rest the glass bar must paint its own active icon: its selected layer
// follows a dragged pill before the destination is committed. Hand them
// to the moving overlays only while folding, at the same coordinates.
final fullTabs = tabs(hideHomeIcon: false);
final foldingTabs = tabs(hideHomeIcon: true);
final fullTabs = tabs(hideLeadingIcon: false);
final foldingTabs = tabs(hideLeadingIcon: true);
return LayoutBuilder(
builder: (context, constraints) {
// Match the tab's actual label height, including accessibility scaling.
@@ -149,8 +150,8 @@ class MornyeBottomBar extends ConsumerWidget {
final fullIconStart =
tabInset +
(constraints.maxWidth - 76 - tabInset * 2) /
mainDestinations.length /
2;
mainDestinations.length *
(leadingIndex + 0.5);
final fullSearchBottom = glassTabs
? 8.0
: (math.max(64.0, 49 + labelHeight) - 64) / 2;
@@ -161,28 +162,31 @@ class MornyeBottomBar extends ConsumerWidget {
: const Duration(milliseconds: 380),
curve: Curves.easeInOutCubic,
builder: (context, amount, _) {
Widget movingIcon({required bool home, required Widget surface}) {
final index = home ? 0 : destinations.length - 1;
Widget movingIcon({
required bool leading,
required Widget surface,
}) {
final index = leading ? leadingIndex : destinations.length - 1;
final offset = (fullIconStart - 26) * (1 - amount);
final size = home ? 52.0 : 64 - 12 * amount;
final size = leading ? 52.0 : 64 - 12 * amount;
return PositionedDirectional(
start: home ? offset : null,
end: home ? null : 0,
bottom: home
start: leading ? offset : null,
end: leading ? null : 0,
bottom: leading
? fullIconBottom * (1 - amount) + 34 * amount - 26
: fullSearchBottom * (1 - amount) + 8 * amount,
width: size,
height: size,
child: IgnorePointer(
ignoring: home && amount < 0.5,
ignoring: leading && amount < 0.5,
child: ExcludeSemantics(
excluding: home && amount < 0.5,
excluding: leading && amount < 0.5,
child: Stack(
alignment: Alignment.center,
children: [
Positioned.fill(
child: Opacity(
opacity: home ? amount : 1,
opacity: leading ? amount : 1,
child: surface,
),
),
@@ -190,12 +194,12 @@ class MornyeBottomBar extends ConsumerWidget {
color: Colors.transparent,
child: IconButton(
key: ValueKey(
home
? 'mornye-compact-home'
leading
? 'mornye-compact-leading'
: 'mornye-compact-search',
),
tooltip: home
? context.l10n.navHome
tooltip: leading
? destinations[leadingIndex].label
: context.l10n.mornyeSearch,
iconSize: 25,
constraints: BoxConstraints.tightFor(
@@ -210,10 +214,14 @@ class MornyeBottomBar extends ConsumerWidget {
amount,
),
icon: Opacity(
opacity: home && amount == 0 ? 0 : 1,
opacity: leading && amount == 0 ? 0 : 1,
child: destinations[index].icon,
),
onPressed: home ? onHome : onSearch,
onPressed: leading
? leadingIndex == 0
? onHome
: () => onSelected(leadingIndex)
: onSearch,
),
),
],
@@ -279,8 +287,8 @@ class MornyeBottomBar extends ConsumerWidget {
),
],
),
movingIcon(home: true, surface: homeSurface),
movingIcon(home: false, surface: searchSurface),
movingIcon(leading: true, surface: leadingSurface),
movingIcon(leading: false, surface: searchSurface),
],
);
},
+5 -5
View File
@@ -437,15 +437,15 @@ class MornyeTabBar extends StatelessWidget {
required this.selectedIndex,
required this.onSelected,
required this.blurEnabled,
this.hideHomeIcon = false,
this.hiddenIconIndex,
});
final List<NavigationDestination> destinations;
final int selectedIndex;
final ValueChanged<int> onSelected;
final bool blurEnabled;
// Home moves independently while the full tab capsule folds away.
final bool hideHomeIcon;
// The active icon moves independently while the full capsule folds away.
final int? hiddenIconIndex;
@override
Widget build(BuildContext context) {
@@ -535,7 +535,7 @@ class MornyeTabBar extends StatelessWidget {
: inactiveIconColor,
),
child: Opacity(
opacity: hideHomeIcon && index == 0 ? 0 : 1,
opacity: hiddenIconIndex == index ? 0 : 1,
child: destination.icon,
),
),
@@ -635,7 +635,7 @@ class MornyeTabBar extends StatelessWidget {
// Keep tab selection quiet, as in Mornye. Badges
// stay live without the Material bounce/spin.
child: Opacity(
opacity: hideHomeIcon && index == 0 ? 0 : 1,
opacity: hiddenIconIndex == index ? 0 : 1,
child: destinations[index].icon,
),
),
+21 -5
View File
@@ -23,6 +23,21 @@ class ProfileAvatar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
// ListTile can constrain height more than width. Keep the clipped
// image square even when the surrounding slot is rectangular.
final diameter = constraints.biggest.shortestSide.clamp(0.0, size);
return Align(
widthFactor: 1,
heightFactor: 1,
child: _buildAvatar(context, diameter),
);
},
);
}
Widget _buildAvatar(BuildContext context, double diameter) {
final scheme = Theme.of(context).colorScheme;
final fallback = ColoredBox(
color: scheme.primaryContainer,
@@ -30,13 +45,13 @@ class ProfileAvatar extends StatelessWidget {
child: name.trim().isEmpty
? Icon(
Icons.person,
size: size * 0.55,
size: diameter * 0.55,
color: scheme.onPrimaryContainer,
)
: Text(
name.trim().characters.first.toUpperCase(),
style: TextStyle(
fontSize: size * 0.42,
fontSize: diameter * 0.42,
fontWeight: FontWeight.w600,
color: scheme.onPrimaryContainer,
),
@@ -46,7 +61,7 @@ class ProfileAvatar extends StatelessWidget {
return ExcludeSemantics(
child: ClipOval(
child: SizedBox.square(
dimension: size,
dimension: diameter,
child: photo != null
? Image.memory(
photo!,
@@ -57,8 +72,9 @@ class ProfileAvatar extends StatelessWidget {
? Image.file(
File(photoPath!),
fit: BoxFit.cover,
cacheWidth: (size * MediaQuery.devicePixelRatioOf(context))
.ceil(),
cacheWidth:
(diameter * MediaQuery.devicePixelRatioOf(context))
.ceil(),
errorBuilder: (_, _, _) => fallback,
)
: fallback,