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'),