From a1cec6de51bc6d3fd0df06be317efb126e9176cd Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:16:08 +0700 Subject: [PATCH] fix(library): open track metadata from search results --- lib/providers/library_search_provider.dart | 25 ++++ lib/widgets/library_search_results.dart | 47 ++++--- test/library_search_results_test.dart | 139 +++++++++++++++++++++ 3 files changed, 195 insertions(+), 16 deletions(-) diff --git a/lib/providers/library_search_provider.dart b/lib/providers/library_search_provider.dart index 044ed720..546bdcee 100644 --- a/lib/providers/library_search_provider.dart +++ b/lib/providers/library_search_provider.dart @@ -1,9 +1,11 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/providers/library_collections_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'; +import 'package:spotiflac_android/services/history_database.dart'; import 'package:spotiflac_android/services/library_search.dart'; typedef LibrarySearchRequest = ({ @@ -13,6 +15,29 @@ typedef LibrarySearchRequest = ({ int offset, }); +/// Resolve the selected record by its source and ID, without matching titles +/// that may also belong to another edition or remix. +final librarySearchTrackProvider = FutureProvider.autoDispose + .family(( + ref, + key, + ) async { + if (key.source == 'local') { + final row = await LibraryDatabase.instance.getById(key.id); + return row == null + ? null + : UnifiedLibraryItem.fromLocalLibrary( + LocalLibraryItem.fromJson(row), + ); + } + final row = await HistoryDatabase.instance.getById(key.id); + return row == null + ? null + : UnifiedLibraryItem.fromDownloadHistory( + DownloadHistoryItem.fromJson(row), + ); + }); + final librarySearchProvider = FutureProvider.autoDispose .family, LibrarySearchRequest>((ref, request) async { final query = LibrarySearchQuery(request.query); diff --git a/lib/widgets/library_search_results.dart b/lib/widgets/library_search_results.dart index ae34ca23..4e4e5c6e 100644 --- a/lib/widgets/library_search_results.dart +++ b/lib/widgets/library_search_results.dart @@ -1,16 +1,16 @@ 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_history_provider.dart'; import 'package:spotiflac_android/providers/library_search_provider.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/playback_provider.dart'; import 'package:spotiflac_android/screens/downloaded_album_screen.dart'; import 'package:spotiflac_android/screens/library_tracks_folder_screen.dart'; import 'package:spotiflac_android/screens/local_album_screen.dart'; -import 'package:spotiflac_android/services/history_database.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/library_search.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/app_choice_chip.dart'; import 'package:spotiflac_android/widgets/cached_cover_image.dart'; import 'package:spotiflac_android/widgets/track_card.dart'; @@ -55,18 +55,29 @@ class _LibrarySearchResultsState extends ConsumerState { _pages = 1; }); - Future _open(LibrarySearchHit hit) async { + Future _open(LibrarySearchHit hit, {bool play = false}) async { FocusScope.of(context).unfocus(); try { switch (hit.kind) { case LibrarySearchKind.songs: - final row = hit.source == 'local' - ? await LibraryDatabase.instance.getById(hit.id) - : await HistoryDatabase.instance.getById(hit.id); - if (!mounted || row == null) return; - final media = hit.source == 'local' - ? playableFromLocal(LocalLibraryItem.fromJson(row)) - : playableFromHistory(DownloadHistoryItem.fromJson(row)); + final item = await ref.read( + librarySearchTrackProvider((source: hit.source, id: hit.id)).future, + ); + if (!mounted || item == null) return; + if (!play) { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => TrackMetadataScreen( + item: item.historyItem, + localItem: item.localItem, + ), + ), + ); + return; + } + final media = item.localItem != null + ? playableFromLocal(item.localItem!) + : playableFromHistory(item.historyItem!); await ref .read(playbackProvider.notifier) .playMediaQueue( @@ -139,7 +150,9 @@ class _LibrarySearchResultsState extends ConsumerState { ); return TrackCard( key: ValueKey('${hit.kind.name}:${hit.source}:${hit.id}'), - style: TrackCardStyle.flat, + style: hit.kind == LibrarySearchKind.songs && !context.isMornye + ? TrackCardStyle.filled + : TrackCardStyle.flat, leading: ClipRRect( borderRadius: BorderRadius.circular( hit.kind == LibrarySearchKind.artists ? 28 : 8, @@ -153,11 +166,13 @@ class _LibrarySearchResultsState extends ConsumerState { ), title: hit.title, subtitle: Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis), - trailing: Icon( - hit.kind == LibrarySearchKind.songs - ? Icons.play_arrow_rounded - : Icons.chevron_right, - ), + trailing: hit.kind == LibrarySearchKind.songs + ? IconButton( + tooltip: context.l10n.tooltipPlay, + icon: const Icon(Icons.play_arrow_rounded), + onPressed: () => _open(hit, play: true), + ) + : const Icon(Icons.chevron_right), onTap: () => _open(hit), ); } diff --git a/test/library_search_results_test.dart b/test/library_search_results_test.dart index 893c3f82..e96bcc35 100644 --- a/test/library_search_results_test.dart +++ b/test/library_search_results_test.dart @@ -1,13 +1,37 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/l10n/app_localizations.dart'; +import 'package:spotiflac_android/models/unified_library_item.dart'; +import 'package:spotiflac_android/providers/download_history_provider.dart'; import 'package:spotiflac_android/providers/library_search_provider.dart'; +import 'package:spotiflac_android/providers/playback_provider.dart'; +import 'package:spotiflac_android/screens/track_metadata_screen.dart'; +import 'package:spotiflac_android/services/library_database.dart'; import 'package:spotiflac_android/services/library_search.dart'; +import 'package:spotiflac_android/services/music_player_service.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; import 'package:spotiflac_android/widgets/library_search_results.dart'; +import 'package:spotiflac_android/widgets/track_card.dart'; + +class _PlaybackRecorder extends PlaybackController { + final paths = []; + + @override + Future playMediaQueue( + Iterable queue, { + required int startIndex, + required String externalPath, + }) async { + paths.add(externalPath); + } +} LibrarySearchHit _hit( LibrarySearchKind kind, @@ -20,6 +44,121 @@ LibrarySearchHit _hit( ); void main() { + for (final mornye in [false, true]) { + for (final local in [false, true]) { + testWidgets( + 'search opens metadata; only Play starts audio ($mornye/$local)', + (tester) async { + SharedPreferences.setMockInitialValues({}); + FlutterSecureStorage.setMockInitialValues({}); + const channel = MethodChannel('com.zarz.spotiflac/backend'); + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + channel, + (call) async => switch (call.method) { + 'safStat' => jsonEncode({'exists': true, 'size': 100}), + 'readAudioMetadata' || 'readFileMetadata' => '{}', + 'getLyricsLRCWithSource' => jsonEncode({ + 'lyrics': '', + 'source': '', + }), + 'getSafFileModTimes' => '{}', + _ => null, + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null), + ); + const path = 'content://library/document/song.flac'; + final item = local + ? UnifiedLibraryItem.fromLocalLibrary( + LocalLibraryItem( + id: 'chosen', + trackName: 'Song', + artistName: 'Artist', + albumName: 'Album', + filePath: path, + scannedAt: DateTime(2026), + ), + ) + : UnifiedLibraryItem.fromDownloadHistory( + DownloadHistoryItem( + id: 'chosen', + trackName: 'Song', + artistName: 'Artist', + albumName: 'Album', + filePath: path, + service: 'provider-a', + downloadedAt: DateTime(2026), + ), + ); + final player = _PlaybackRecorder(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + playbackProvider.overrideWith(() => player), + librarySearchProvider.overrideWith( + (ref, request) async => + request.kind == LibrarySearchKind.songs + ? [ + LibrarySearchHit( + kind: LibrarySearchKind.songs, + id: 'chosen', + title: 'Song', + source: local ? 'local' : 'history', + ), + ] + : [], + ), + librarySearchTrackProvider.overrideWith((ref, key) async { + expect(key, ( + source: local ? 'local' : 'history', + id: 'chosen', + )); + return item; + }), + ], + child: MaterialApp( + theme: mornye + ? MornyeTheme.build(Brightness.dark) + : ThemeData(), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: CustomScrollView( + slivers: [ + LibrarySearchResults(query: 'Song', onOpenArtist: (_) {}), + ], + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + expect( + tester.widget(find.byType(TrackCard)).style, + mornye ? TrackCardStyle.flat : TrackCardStyle.filled, + ); + await tester.tap(find.text('Song')); + await tester.pumpAndSettle(); + final details = tester.widget( + find.byType(TrackMetadataScreen), + ); + expect(details.item, item.historyItem); + expect(details.localItem, item.localItem); + expect(player.paths, isEmpty); + tester.state(find.byType(Navigator).first).pop(); + await tester.pumpAndSettle(); + await tester.tap(find.byTooltip('Play')); + await tester.pumpAndSettle(); + expect(player.paths, [path]); + expect(find.byType(TrackMetadataScreen), findsNothing); + expect(tester.takeException(), isNull); + }, + ); + } + } + for (final mornye in [false, true]) { testWidgets('Library shows every result type and paginates ($mornye)', ( tester,