fix(library): open track metadata from search results

This commit is contained in:
zarzet committed 2026-09-30 20:16:08 +07:00
1 parent e025abeec8
commit a1cec6de51
3 files changed
+195 -16

No files matched your search

@@ -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<UnifiedLibraryItem?, ({String source, String id})>((
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<List<LibrarySearchHit>, LibrarySearchRequest>((ref, request) async {
final query = LibrarySearchQuery(request.query);
+31 -16
View File
@@ -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<LibrarySearchResults> {
_pages = 1;
});
Future<void> _open(LibrarySearchHit hit) async {
Future<void> _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<void>(
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<LibrarySearchResults> {
);
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<LibrarySearchResults> {
),
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),
);
}
+139
View File
@@ -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 = <String>[];
@override
Future<void> playMediaQueue(
Iterable<PlayableMedia> 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<TrackCard>(find.byType(TrackCard)).style,
mornye ? TrackCardStyle.flat : TrackCardStyle.filled,
);
await tester.tap(find.text('Song'));
await tester.pumpAndSettle();
final details = tester.widget<TrackMetadataScreen>(
find.byType(TrackMetadataScreen),
);
expect(details.item, item.historyItem);
expect(details.localItem, item.localItem);
expect(player.paths, isEmpty);
tester.state<NavigatorState>(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,