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

+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 (_) {