From c87c9ff84898bf3353a6fa135eb81bd626ee2fd0 Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Mon, 21 Sep 2026 23:02:52 +0700 Subject: [PATCH] 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. --- lib/l10n/arb/app_en.arb | 9 + lib/l10n/arb/app_id.arb | 9 + lib/providers/library_browse_provider.dart | 80 +++ lib/screens/mornye_library_screen.dart | 462 ++++++++++++++++++ lib/screens/queue_tab.dart | 135 +++-- lib/screens/queue_tab_filter_widgets.dart | 9 +- lib/screens/track_metadata_lyrics.dart | 12 +- lib/screens/track_metadata_screen.dart | 1 + lib/services/library_database.dart | 49 ++ lib/services/library_database_models.dart | 4 + lib/services/library_database_queue_sql.dart | 12 +- lib/services/local_track_batch_actions.dart | 15 +- lib/utils/re_enrich_result.dart | 51 ++ lib/widgets/mornye_bottom_bar.dart | 58 ++- lib/widgets/mornye_chrome.dart | 10 +- lib/widgets/profile_avatar.dart | 26 +- .../src/backend/download_latency_tests.rs | 115 ++++- .../crates/extensions/src/backend/reenrich.rs | 41 +- test/local_track_batch_actions_test.dart | 3 + test/mornye_bottom_bar_test.dart | 57 ++- test/mornye_library_browse_test.dart | 140 ++++++ test/re_enrich_result_test.dart | 60 +++ test/track_metadata_lyrics_test.dart | 27 +- 23 files changed, 1257 insertions(+), 128 deletions(-) create mode 100644 lib/providers/library_browse_provider.dart create mode 100644 lib/screens/mornye_library_screen.dart create mode 100644 lib/utils/re_enrich_result.dart create mode 100644 test/mornye_library_browse_test.dart create mode 100644 test/re_enrich_result_test.dart diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index a034dbf3..87c6aba0 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -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" }, diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index ac81deb1..22f7d639 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -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" }, diff --git a/lib/providers/library_browse_provider.dart b/lib/providers/library_browse_provider.dart new file mode 100644 index 00000000..90a74f0e --- /dev/null +++ b/lib/providers/library_browse_provider.dart @@ -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 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, 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); + }); diff --git a/lib/screens/mornye_library_screen.dart b/lib/screens/mornye_library_screen.dart new file mode 100644 index 00000000..71aca415 --- /dev/null +++ b/lib/screens/mornye_library_screen.dart @@ -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 onOpenSection; + final MornyeLibraryPage page; + final String? artist; + + @override + ConsumerState createState() => + _MornyeLibraryScreenState(); +} + +class _MornyeLibraryScreenState extends ConsumerState { + final _scroll = ScrollController(); + final _search = TextEditingController(); + Timer? _debounce; + bool _showSearch = false; + String _query = ''; + late String _sort; + int _limit = 40; + List _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( + builder: (_) => Scaffold( + body: MornyeLibraryScreen( + page: page, + artist: artist, + onOpenSection: widget.onOpenSection, + ), + ), + ), + ); + } + + Future _openAlbum(LibraryBrowseEntry entry) async { + if (entry.source != 'local') { + await Navigator.of(context).push( + MaterialPageRoute( + 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( + builder: (_) => LocalAlbumScreen( + albumName: entry.name, + artistName: entry.artist, + coverPath: entry.cover, + tracks: rows.map(LocalLibraryItem.fromJson).toList(growable: false), + ), + ), + ); + } + + Future _chooseSort() async { + final selected = await showAppBottomSheet( + 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 = [ + 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); + } +} diff --git a/lib/screens/queue_tab.dart b/lib/screens/queue_tab.dart index 64f66cb8..5df9baa7 100644 --- a/lib/screens/queue_tab.dart +++ b/lib/screens/queue_tab.dart @@ -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 { 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 { /// 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 { } 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 { @override Widget build(BuildContext context) { + if (context.isMornye && widget.librarySection == null) { + return MornyeLibraryScreen( + onOpenSection: (section) => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold(body: QueueTab(librarySection: section)), + ), + ), + ); + } _initializePageController(); _applyDefaultLibraryViewOnTabVisible(); @@ -1278,6 +1295,34 @@ class _QueueTabState extends ConsumerState { }, ); + 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 { 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(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 { ).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 { 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 { ), ), - 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 { ), ), ], - 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 ], diff --git a/lib/screens/queue_tab_filter_widgets.dart b/lib/screens/queue_tab_filter_widgets.dart index 9f786121..eb730dd3 100644 --- a/lib/screens/queue_tab_filter_widgets.dart +++ b/lib/screens/queue_tab_filter_widgets.dart @@ -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 [] : ref .watch( @@ -74,7 +74,9 @@ extension _QueueTabFilterWidgets on _QueueTabState { ), }; List bridgeIds = const []; - if (filterMode != 'albums' && _completionBridge.isNotEmpty) { + if (hasQueueItems && + filterMode != 'albums' && + _completionBridge.isNotEmpty) { final now = DateTime.now(); final stale = []; final pending = []; @@ -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 || diff --git a/lib/screens/track_metadata_lyrics.dart b/lib/screens/track_metadata_lyrics.dart index 3590b6d5..65e50ef5 100644 --- a/lib/screens/track_metadata_lyrics.dart +++ b/lib/screens/track_metadata_lyrics.dart @@ -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) { diff --git a/lib/screens/track_metadata_screen.dart b/lib/screens/track_metadata_screen.dart index d3b6f8fb..3245e635 100644 --- a/lib/screens/track_metadata_screen.dart +++ b/lib/screens/track_metadata_screen.dart @@ -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'; diff --git a/lib/services/library_database.dart b/lib/services/library_database.dart index 8c87cd21..b572b3cb 100644 --- a/lib/services/library_database.dart +++ b/lib/services/library_database.dart @@ -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>> getQueueArtistPage( + QueueLibraryDbQuery request, + ) async { + final db = await database; + await _ensureHistoryAttached(db); + final parts = [ + ''' + 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>> getQueueLocalAlbumTracks( String albumName, String artistName, diff --git a/lib/services/library_database_models.dart b/lib/services/library_database_models.dart index 2f880848..6ca3a24f 100644 --- a/lib/services/library_database_models.dart +++ b/lib/services/library_database_models.dart @@ -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', diff --git a/lib/services/library_database_queue_sql.dart b/lib/services/library_database_queue_sql.dart index d46c3426..9176c507 100644 --- a/lib/services/library_database_queue_sql.dart +++ b/lib/services/library_database_queue_sql.dart @@ -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 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 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); diff --git a/lib/services/local_track_batch_actions.dart b/lib/services/local_track_batch_actions.dart index 46ffd71b..2c1ef6cc 100644 --- a/lib/services/local_track_batch_actions.dart +++ b/lib/services/local_track_batch_actions.dart @@ -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 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 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 previews, { required bool Function() shouldStop, required void Function(int, LocalLibraryItem) onProgress, + void Function(Map)? 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 (_) { diff --git a/lib/utils/re_enrich_result.dart b/lib/utils/re_enrich_result.dart new file mode 100644 index 00000000..347086db --- /dev/null +++ b/lib/utils/re_enrich_result.dart @@ -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 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 = {}; + + void add(Map 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'); + } +} diff --git a/lib/widgets/mornye_bottom_bar.dart b/lib/widgets/mornye_bottom_bar.dart index b19bcd57..03a84a53 100644 --- a/lib/widgets/mornye_bottom_bar.dart +++ b/lib/widgets/mornye_bottom_bar.dart @@ -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), ], ); }, diff --git a/lib/widgets/mornye_chrome.dart b/lib/widgets/mornye_chrome.dart index 6585dabb..a3492079 100644 --- a/lib/widgets/mornye_chrome.dart +++ b/lib/widgets/mornye_chrome.dart @@ -437,15 +437,15 @@ class MornyeTabBar extends StatelessWidget { required this.selectedIndex, required this.onSelected, required this.blurEnabled, - this.hideHomeIcon = false, + this.hiddenIconIndex, }); final List destinations; final int selectedIndex; final ValueChanged 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, ), ), diff --git a/lib/widgets/profile_avatar.dart b/lib/widgets/profile_avatar.dart index 984ef2b4..90ca96c5 100644 --- a/lib/widgets/profile_avatar.dart +++ b/lib/widgets/profile_avatar.dart @@ -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, diff --git a/rust_backend/crates/extensions/src/backend/download_latency_tests.rs b/rust_backend/crates/extensions/src/backend/download_latency_tests.rs index 2d2e9ee2..7944b78b 100644 --- a/rust_backend/crates/extensions/src/backend/download_latency_tests.rs +++ b/rust_backend/crates/extensions/src/backend/download_latency_tests.rs @@ -1,6 +1,9 @@ use super::*; use crate::environment::ExtensionEnvironment; -use crate::{RuntimeLimits, backend::MetadataOptions}; +use crate::{ + RuntimeLimits, + backend::{LyricsRequest, MetadataOptions}, +}; use spotiflac_network::{NetworkOptions, NetworkService}; use std::io::{Read, Write}; use std::net::TcpListener; @@ -494,19 +497,36 @@ fn catalog_search_gates_required_tracks_then_overlaps_optional_categories() { #[test] fn reenrich_cover_and_lyrics_overlap_preserve_best_effort_results_and_cancel() { - for mode in ["success", "cover_failure", "lyrics_failure", "cancel"] { + for mode in [ + "success", + "cover_failure", + "lyrics_failure", + "missing", + "instrumental", + "instrumental_native", + "instrumental_with_lyrics", + "cancel", + ] { let (network, config) = network(); let mut server = GatedServer::new( config, &[ ( "/cover", - if mode == "cover_failure" { 404 } else { 200 }, + if matches!(mode, "cover_failure" | "instrumental_native") { + 404 + } else { + 200 + }, "artwork", ), ( "/lyrics", - if mode == "lyrics_failure" { 404 } else { 200 }, + if matches!(mode, "lyrics_failure" | "missing") { + 404 + } else { + 200 + }, "New lyrics", ), ], @@ -524,8 +544,13 @@ fn reenrich_cover_and_lyrics_overlap_preserve_best_effort_results_and_cancel() { "description":"Generic lyrics fixture","type":["lyrics_provider"],"permissions":{"network":["127.0.0.1"]}}); let source = format!( r#"registerExtension({{fetchLyrics(){{const response=http.get({}); - return response.statusCode===200 ? {{plainLyrics:response.body}} : {{}};}}}});"#, - json!(format!("{}/lyrics", server.url)) + return response.statusCode===200 ? {} : {{}};}}}});"#, + json!(format!("{}/lyrics", server.url)), + if mode.starts_with("instrumental") { + "{instrumental:true}" + } else { + "{plainLyrics:response.body}" + }, ); for (name, body) in [ ("manifest.json", manifest.to_string()), @@ -542,11 +567,24 @@ fn reenrich_cover_and_lyrics_overlap_preserve_best_effort_results_and_cancel() { backend .set_lyrics_providers_json(r#"["extension:example.lyrics"]"#) .unwrap(); - let audio = directory.path().join("data/track.mp3"); + let native = mode == "instrumental_native"; + let file_name = if native { "track.flac" } else { "track.mp3" }; + let audio = directory.path().join("data").join(file_name); let sidecar = directory.path().join("data/track.lrc"); - std::fs::write(&audio, "original audio").unwrap(); - std::fs::write(&sidecar, "[00:01.00]Old lyrics").unwrap(); - let request = json!({"file_path":"track.mp3","track_name":"Track","artist_name":"Artist", + if native { + let mut flac = b"fLaC\x80\x00\x00\x22".to_vec(); + flac.extend_from_slice(&[0; 34]); + flac.extend_from_slice(&[0xff, 0xf8]); + flac.extend_from_slice(&[0; 32]); + std::fs::write(&audio, flac).unwrap(); + } else { + std::fs::write(&audio, "original audio").unwrap(); + } + let has_existing = !matches!(mode, "missing" | "instrumental" | "instrumental_native"); + if has_existing { + std::fs::write(&sidecar, "[00:01.00]Old lyrics").unwrap(); + } + let request = json!({"file_path":file_name,"track_name":"Track","artist_name":"Artist", "cover_url":format!("{}/cover", server.url),"embed_lyrics":true,"lyrics_mode":"both", "update_fields":["cover","lyrics"]}) .to_string(); @@ -588,25 +626,54 @@ fn reenrich_cover_and_lyrics_overlap_preserve_best_effort_results_and_cancel() { second.1.send(()).unwrap(); } else { let result: Value = serde_json::from_str(&result.unwrap()).unwrap(); - assert_eq!(result["method"], "ffmpeg"); - assert_eq!(result["write_external_lrc"], true); + assert_eq!(result["method"], if native { "native" } else { "ffmpeg" }); + assert_eq!(result["write_external_lrc"], mode != "missing"); let lyrics = result["lyrics"].as_str().unwrap(); - assert!(lyrics.contains(if mode == "lyrics_failure" { - "Old lyrics" + let (status, expected) = match mode { + "lyrics_failure" | "instrumental_with_lyrics" => ("preserved", "Old lyrics"), + "missing" => ("not_found", ""), + "instrumental" | "instrumental_native" => ("instrumental", "[instrumental:true]"), + _ => ("updated", "New lyrics"), + }; + assert_eq!(result["lyrics_status"], status); + if mode == "missing" { + assert!(lyrics.is_empty()); + assert!(result["metadata"].get("LYRICS").is_none()); } else { - "New lyrics" - })); - assert_eq!(result["metadata"]["LYRICS"], lyrics); - let cover = result["cover_path"].as_str().unwrap(); - if mode == "cover_failure" { - assert!(cover.is_empty()); + assert!(lyrics.contains(expected)); + if !native { + assert_eq!(result["metadata"]["LYRICS"], lyrics); + } + } + if !native { + let cover = result["cover_path"].as_str().unwrap(); + if mode == "cover_failure" { + assert!(cover.is_empty()); + } else { + assert_eq!(std::fs::read(cover).unwrap(), b"artwork"); + std::fs::remove_file(cover).unwrap(); + } } else { - assert_eq!(std::fs::read(cover).unwrap(), b"artwork"); - std::fs::remove_file(cover).unwrap(); + let embedded = backend + .get_lyrics_lrc( + &LyricsRequest { + file_path: file_name.into(), + ..Default::default() + }, + &|| Ok(()), + ) + .unwrap(); + assert_eq!(embedded, "[instrumental:true]"); } } - assert_eq!(std::fs::read(&audio).unwrap(), b"original audio"); - assert_eq!(std::fs::read(&sidecar).unwrap(), b"[00:01.00]Old lyrics"); + if !native { + assert_eq!(std::fs::read(&audio).unwrap(), b"original audio"); + } + if has_existing { + assert_eq!(std::fs::read(&sidecar).unwrap(), b"[00:01.00]Old lyrics"); + } else { + assert!(!sidecar.exists()); + } assert!( std::fs::read_dir(directory.path().join("data")) .unwrap() diff --git a/rust_backend/crates/extensions/src/backend/reenrich.rs b/rust_backend/crates/extensions/src/backend/reenrich.rs index fb458da5..2e43d205 100644 --- a/rust_backend/crates/extensions/src/backend/reenrich.rs +++ b/rust_backend/crates/extensions/src/backend/reenrich.rs @@ -67,10 +67,11 @@ impl Backend { None } }; - let fetch_lyrics = || -> Result { + let fetch_lyrics = || -> Result<(String, &str), String> { // Fetches and existing lyrics are best-effort; cancellation is not. check()?; let mut lyrics = String::new(); + let mut status = "not_requested"; if request.selected("lyrics", "lyrics") { if let Ok(existing) = file::extract( &request.file_path, @@ -81,11 +82,17 @@ impl Backend { .map_err(|error| error.to_string()) }, &check, - ) { + ) && lrc::has_usable_content(&existing) + { lyrics = existing; } check()?; if request.embed_lyrics { + status = if lyrics.is_empty() { + "not_found" + } else { + "preserved" + }; let query = LyricsRequest { spotify_id: request.spotify_id.clone(), track: request.track_name.clone(), @@ -93,17 +100,31 @@ impl Backend { duration_ms: request.duration_ms, ..LyricsRequest::default() }; - if let Ok(response) = self.fetch_lyrics(&query, &check) - && !response.instrumental - { - lyrics = lrc::with_metadata(&response, &query.track, &query.artist); + if let Ok(response) = self.fetch_lyrics(&query, &check) { + if response.instrumental { + // Preserve real existing lyrics if a provider disagrees. + // Otherwise persist the marker used by the offline player. + if lyrics.is_empty() || lrc::is_instrumental_marker(&lyrics) { + lyrics = "[instrumental:true]".into(); + status = "instrumental"; + } + } else { + let fetched = + lrc::with_metadata(&response, &query.track, &query.artist); + if lrc::has_usable_content(&fetched) { + lyrics = fetched; + status = "updated"; + } + } } check()?; + } else { + status = "disabled"; } } - Ok(lyrics) + Ok((lyrics, status)) }; - let (cover, lyrics) = std::thread::scope(|scope| { + let (cover, (lyrics, lyrics_status)) = std::thread::scope(|scope| { let worker = (wants_cover && request.selected("lyrics", "lyrics")).then(|| { std::thread::Builder::new() .name("reenrich-cover".into()) @@ -163,7 +184,7 @@ impl Backend { })?; return Ok( json!({"method":"native","success":true,"enriched_metadata":enriched, - "lyrics":lyrics,"write_external_lrc":external}) + "lyrics":lyrics,"lyrics_status":lyrics_status,"write_external_lrc":external}) .to_string(), ); } @@ -194,7 +215,7 @@ impl Backend { } else { String::new() }; - let result = json!({"method":"ffmpeg","cover_path":cover_path,"lyrics":lyrics, + let result = json!({"method":"ffmpeg","cover_path":cover_path,"lyrics":lyrics,"lyrics_status":lyrics_status, "enriched_metadata":enriched,"metadata":metadata,"write_external_lrc":external}) .to_string(); check()?; diff --git a/test/local_track_batch_actions_test.dart b/test/local_track_batch_actions_test.dart index 83c2a945..5e9b7a55 100644 --- a/test/local_track_batch_actions_test.dart +++ b/test/local_track_batch_actions_test.dart @@ -56,6 +56,7 @@ void main() { final requests = >[]; final sidecars = []; final ffmpegModes = []; + final reported = []; final runner = BatchReEnrichRunner( beginPhase: () async { phaseCount++; @@ -107,8 +108,10 @@ void main() { previews, shouldStop: running, onProgress: progress, + onResult: (result) => reported.add(result['method'] as String), ); expect(successes, 2); + expect(reported, ['native', 'ffmpeg']); expect(phaseCount, 2); expect(sidecars, ['/music/1.flac']); expect(ffmpegModes, ['split_vorbis']); diff --git a/test/mornye_bottom_bar_test.dart b/test/mornye_bottom_bar_test.dart index a0955c59..91e6dffb 100644 --- a/test/mornye_bottom_bar_test.dart +++ b/test/mornye_bottom_bar_test.dart @@ -108,8 +108,12 @@ void main() { selectedIndex: activeTab?.value ?? 0, onSelected: (index) { activeTab?.value = index; + chrome.expand(); + }, + onHome: () { + activeTab?.value = 0; + chrome.expand(); }, - onHome: chrome.expand, onSearch: () { searches++; activeTab?.value = 3; @@ -144,6 +148,7 @@ void main() { for (final (index, label) in [ (1, 'Library'), + (2, 'Repo'), (3, 'Search'), (0, 'Home'), ]) { @@ -180,15 +185,55 @@ void main() { } } expect( - iconColor('mornye-compact-home', Icons.home), - index == 0 ? primary : isNot(primary), + iconColor('mornye-compact-leading', switch (index) { + 1 => Icons.music_note, + 2 => Icons.grid_view, + _ => Icons.home, + }), + index == 3 ? isNot(primary) : primary, ); expect( iconColor('mornye-compact-search', Icons.search), index == 3 ? primary : isNot(primary), ); + final movingIcon = switch (index) { + 1 => Icons.music_note, + 2 => Icons.grid_view, + _ => Icons.home, + }; + final leading = find.byKey(const ValueKey('mornye-compact-leading')); + final origin = tester.getCenter( + find.descendant(of: leading, matching: find.byIcon(movingIcon)), + ); + final originalTabIcon = find + .descendant( + of: find.byType(MornyeTabBar), + matching: find.byIcon(movingIcon), + ) + .first; + expect( + (origin - tester.getCenter(originalTabIcon)).distance, + lessThan(0.5), + ); chrome.value = true; + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + expect( + tester + .widget(find.byType(MornyeTabBar)) + .hiddenIconIndex, + index == 3 ? 0 : index, + ); await tester.pumpAndSettle(); + expect( + tester.widget(leading).tooltip, + index == 3 ? 'Home' : label, + ); + await tester.tap(leading); + await tester.pumpAndSettle(); + expect(activeTab.value, index == 3 ? 0 : index); + expect(chrome.value, isFalse); + activeTab.value = index; chrome.expand(); await tester.pumpAndSettle(); expect( @@ -202,7 +247,7 @@ void main() { 'edge icons travel continuously without fading (glass: $blur)', (tester) async { await pumpShell(tester, blur: blur); - final homeButton = find.byKey(const ValueKey('mornye-compact-home')); + final homeButton = find.byKey(const ValueKey('mornye-compact-leading')); final icon = find.descendant( of: homeButton, matching: find.byIcon(Icons.home), @@ -292,11 +337,11 @@ void main() { expect(height, lessThanOrEqualTo(previousHeight + 0.01)); previousHeight = height; firstHomeCenter ??= tester.getCenter( - find.byKey(const ValueKey('mornye-compact-home')), + find.byKey(const ValueKey('mornye-compact-leading')), ); } final homeCenter = tester.getCenter( - find.byKey(const ValueKey('mornye-compact-home')), + find.byKey(const ValueKey('mornye-compact-leading')), ); expect((homeCenter.dy - firstHomeCenter!.dy).abs(), lessThan(20)); expect(find.byTooltip('Home').hitTestable(), findsOneWidget); diff --git a/test/mornye_library_browse_test.dart b/test/mornye_library_browse_test.dart new file mode 100644 index 00000000..4107d5f0 --- /dev/null +++ b/test/mornye_library_browse_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/models/download_item.dart'; +import 'package:spotiflac_android/models/track.dart'; +import 'package:spotiflac_android/providers/download_queue_provider.dart'; +import 'package:spotiflac_android/providers/library_browse_provider.dart'; +import 'package:spotiflac_android/screens/mornye_library_screen.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; + +LibraryBrowseEntry _album(int index) => LibraryBrowseEntry( + source: 'downloaded', + key: 'album-$index', + name: 'Album $index', + artist: 'Artist $index', + samplePath: '', + trackCount: 1, +); + +void main() { + for (final brightness in Brightness.values) { + testWidgets('Library separates browsing and downloads ($brightness)', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final opened = []; + final requests = []; + final queue = DownloadQueueLookup.fromItems([ + for (final status in [DownloadStatus.queued, DownloadStatus.completed]) + DownloadItem( + id: status.name, + service: 'example', + status: status, + createdAt: DateTime(2026), + track: Track( + id: status.name, + name: 'Track', + artistName: 'Artist', + albumName: 'Album', + duration: 100, + ), + ), + ]); + await tester.pumpWidget( + ProviderScope( + overrides: [ + downloadQueueLookupProvider.overrideWithValue(queue), + libraryBrowseProvider.overrideWith((ref, request) async { + requests.add(request); + return [_album(0), _album(1)]; + }), + ], + child: MaterialApp( + theme: MornyeTheme.build(brightness), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MornyeLibraryScreen(onOpenSection: opened.add), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Recently Added'), findsOneWidget); + expect(find.text('1'), findsOneWidget); + expect(find.text('Playlists'), findsOneWidget); + expect(find.text('Songs'), findsOneWidget); + final grid = tester.widget(find.byType(SliverGrid)); + expect( + (grid.gridDelegate as SliverGridDelegateWithFixedCrossAxisCount) + .crossAxisCount, + 2, + ); + await tester.tap(find.text('Downloads')); + await tester.tap(find.text('Songs')); + await tester.tap(find.text('Playlists')); + expect(opened, ['downloads', 'all', 'playlists']); + + await tester.tap(find.text('Artists')); + await tester.pumpAndSettle(); + expect(requests.last.artists, isTrue); + await tester.tap(find.text('Artist 0')); + await tester.pumpAndSettle(); + expect(requests.last.artists, isFalse); + expect(requests.last.artist, 'Artist 0'); + expect(tester.takeException(), isNull); + }); + } + + testWidgets('Library search is local and album pages load on scroll', ( + tester, + ) async { + tester.view.physicalSize = const Size(390, 844); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.reset); + final requests = []; + await tester.pumpWidget( + ProviderScope( + overrides: [ + libraryBrowseProvider.overrideWith((ref, request) async { + requests.add(request); + return List.generate(request.limit, _album); + }), + ], + child: MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: MornyeLibraryScreen( + page: MornyeLibraryPage.albums, + onOpenSection: (_) {}, + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + final scroll = tester.widget( + find.byType(CustomScrollView), + ); + scroll.controller!.jumpTo(scroll.controller!.position.maxScrollExtent); + await tester.pumpAndSettle(); + expect(requests.last.limit, 80); + scroll.controller!.jumpTo(0); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Search your library')); + await tester.pumpAndSettle(); + expect(tester.testTextInput.isVisible, isTrue); + await tester.enterText(find.byType(TextField), 'My album'); + await tester.pump(const Duration(milliseconds: 350)); + await tester.pumpAndSettle(); + expect(requests.last.search, 'My album'); + expect(requests.last.limit, 40); + expect(tester.takeException(), isNull); + }); +} diff --git a/test/re_enrich_result_test.dart b/test/re_enrich_result_test.dart new file mode 100644 index 00000000..b156bf47 --- /dev/null +++ b/test/re_enrich_result_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/l10n/l10n.dart'; +import 'package:spotiflac_android/utils/re_enrich_result.dart'; + +void main() { + for (final locale in ['en', 'id']) { + test( + '$locale distinguishes updated, instrumental, and unavailable lyrics', + () async { + final l10n = await AppLocalizations.delegate.load(Locale(locale)); + for (final entry in { + 'updated': l10n.trackReEnrichLyricsUpdated, + 'instrumental': l10n.trackInstrumental, + 'not_found': l10n.trackLyricsNotAvailable, + 'preserved': l10n.trackReEnrichLyricsPreserved, + 'disabled': l10n.trackReEnrichLyricsDisabled, + }.entries) { + final message = reEnrichCompletionMessage(l10n, { + 'success': true, + 'lyrics_status': entry.key, + }); + expect(message, '${l10n.trackReEnrichMetadataSaved}\n${entry.value}'); + if (entry.key != 'updated') { + expect(message, isNot(contains(l10n.trackReEnrichLyricsUpdated))); + } + } + expect(reEnrichCompletionMessage(l10n, {}), l10n.trackReEnrichSuccess); + expect( + reEnrichCompletionMessage(l10n, {'lyrics_status': 'not_requested'}), + l10n.trackReEnrichSuccess, + ); + }, + ); + + test( + '$locale batch does not label every saved file as lyrics updated', + () async { + final l10n = await AppLocalizations.delegate.load(Locale(locale)); + final summary = ReEnrichLyricsSummary(); + for (final status in ['updated', 'instrumental', 'not_found']) { + summary.add({'lyrics_status': status}); + } + expect( + summary.message(l10n, 3, 3), + [ + '${l10n.trackReEnrichMetadataSaved} (3/3)', + '${l10n.trackReEnrichLyricsUpdated} (1)', + '${l10n.trackInstrumental} (1)', + '${l10n.trackLyricsNotAvailable} (1)', + ].join('\n'), + ); + expect( + summary.message(l10n, 3, 4), + startsWith(l10n.trackReEnrichSuccessWithFailures(3, 4, 1)), + ); + }, + ); + } +} diff --git a/test/track_metadata_lyrics_test.dart b/test/track_metadata_lyrics_test.dart index 6c131fb8..6a99de7b 100644 --- a/test/track_metadata_lyrics_test.dart +++ b/test/track_metadata_lyrics_test.dart @@ -15,7 +15,13 @@ void main() { final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; - for (final scenario in ['fallback', 'local', 'empty', 'unavailable']) { + for (final scenario in [ + 'fallback', + 'local', + 'instrumental', + 'empty', + 'unavailable', + ]) { testWidgets('metadata reads local lyrics: $scenario', (tester) async { SharedPreferences.setMockInitialValues({}); const path = 'content://library/document/song.flac'; @@ -34,7 +40,11 @@ void main() { throw PlatformException(code: 'backend_unavailable'); } return jsonEncode({ - 'lyrics': scenario == 'local' ? lyrics : '', + 'lyrics': scenario == 'instrumental' + ? '[instrumental:true]' + : scenario == 'local' + ? lyrics + : '', 'source': scenario == 'local' ? 'Embedded' : '', }); case 'readFileMetadata': @@ -68,10 +78,19 @@ void main() { ), ); await tester.pumpAndSettle(); - expect(metadataReads, scenario == 'local' ? 0 : 1); + expect( + metadataReads, + ['local', 'instrumental'].contains(scenario) ? 0 : 1, + ); expect( find.text('A locally stored lyric line'), - scenario == 'empty' ? findsNothing : findsOneWidget, + ['empty', 'instrumental'].contains(scenario) + ? findsNothing + : findsOneWidget, + ); + expect( + find.text('Instrumental track'), + scenario == 'instrumental' ? findsOneWidget : findsNothing, ); expect( find.text('No lyrics found in this file'),