feat(album): replace selection sharing with metadata re-enrichment

This commit is contained in:
zarzet committed 2026-09-30 23:51:54 +07:00
1 parent ce13e29b71
commit eed56d4cbb
3 files changed
+292 -47

No files matched your search

+72 -41
View File
@@ -6,10 +6,11 @@ import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/theme/cover_palette.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:share_plus/share_plus.dart';
import 'package:spotiflac_android/services/cover_cache_manager.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/batch_track_actions.dart';
import 'package:spotiflac_android/services/library_database.dart';
import 'package:spotiflac_android/services/local_track_batch_actions.dart';
import 'package:spotiflac_android/models/unified_library_item.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/utils/adaptive_layout.dart';
@@ -18,6 +19,9 @@ import 'package:spotiflac_android/utils/confirm_and_delete_tracks.dart';
import 'package:spotiflac_android/utils/cover_art_utils.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/image_cache_utils.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/lyrics_metadata_helper.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
import 'package:spotiflac_android/providers/download_queue_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/providers/playback_provider.dart';
@@ -657,44 +661,71 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
);
}
Future<void> _shareSelected(List<DownloadHistoryItem> allTracks) async {
final tracksById = {for (final t in allTracks) t.id: t};
final safUris = <String>[];
final filesToShare = <XFile>[];
for (final id in selectedIds) {
final item = tracksById[id];
if (item == null) continue;
final path = item.filePath;
if (isContentUri(path)) {
if (await fileExists(path)) safUris.add(path);
} else if (await fileExists(path)) {
filesToShare.add(XFile(path));
}
}
if (safUris.isEmpty && filesToShare.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.selectionShareNoFiles)),
);
}
return;
}
if (safUris.isNotEmpty) {
try {
if (safUris.length == 1) {
await PlatformBridge.shareContentUri(safUris.first);
} else {
await PlatformBridge.shareMultipleContentUris(safUris);
Future<void> _reEnrichSelected(List<DownloadHistoryItem> allTracks) async {
final selected = allTracks
.where((track) => selectedIds.contains(track.id))
.toList(growable: false);
final history = ref.read(downloadHistoryProvider.notifier);
await reEnrichLocalTracks(
context,
ref,
[
for (final track in selected)
LocalLibraryItem.fromJson({
...track.toJson(),
'scannedAt': track.downloadedAt.toIso8601String(),
}),
],
isActive: () => mounted,
onSelectionHide: () async {
setState(() => isSelectionMode = false);
await Future<void>.delayed(const Duration(milliseconds: 300));
},
onSelectionRestore: () => setState(() => isSelectionMode = true),
onComplete: exitSelectionMode,
refreshLibrary: () async {
// Downloaded albums read history, not the local-scan index. Read back
// the saved tags so failed or unselected fields cannot change history.
for (final track in selected) {
try {
await DownloadedEmbeddedCoverResolver.invalidate(track.filePath);
final metadata = await PlatformBridge.readDisplayAudioMetadata(
track.filePath,
);
if (metadata['error'] != null) continue;
await history.updateMetadataForItem(
id: track.id,
trackName: metadata['title'] as String? ?? track.trackName,
artistName: metadata['artist'] as String? ?? track.artistName,
albumName: metadata['album'] as String? ?? track.albumName,
albumArtist: metadata['album_artist'] as String?,
isrc: metadata['isrc'] as String?,
trackNumber: readPositiveInt(metadata['track_number']),
totalTracks: readPositiveInt(metadata['total_tracks']),
discNumber: readPositiveInt(metadata['disc_number']),
totalDiscs: readPositiveInt(metadata['total_discs']),
releaseDate: metadata['date'] as String?,
genre: metadata['genre'] as String?,
composer: metadata['composer'] as String?,
label: metadata['label'] as String?,
copyright: metadata['copyright'] as String?,
explicit: parseExplicitFlag(metadata['explicit']),
hasLyrics:
metadata.containsKey('hasLyrics') ||
metadata.containsKey('lyrics')
? metadata['hasLyrics'] == true ||
hasUsableLyricsContent(
metadata['lyrics']?.toString() ?? '',
)
: null,
);
} catch (error) {
debugPrint('Could not refresh re-enriched download: $error');
}
}
} catch (_) {}
}
if (filesToShare.isNotEmpty) {
await SharePlus.instance.share(ShareParams(files: filesToShare));
}
_onEmbeddedCoverChanged();
},
);
}
List<UnifiedLibraryItem> _selectedUnifiedItems(
@@ -737,10 +768,10 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
(constraints.maxWidth - spacing * (columns - 1)) / columns;
final actions = <Widget>[
SelectionActionButton(
icon: Icons.share_outlined,
label: context.l10n.selectionShareCount(selectedCount),
icon: Icons.auto_fix_high_outlined,
label: '${context.l10n.trackReEnrich} ($selectedCount)',
onPressed: selectedCount > 0
? () => _shareSelected(tracks)
? () => _reEnrichSelected(tracks)
: null,
colorScheme: colorScheme,
),
+11 -6
View File
@@ -145,6 +145,7 @@ Future<void> reEnrichLocalTracks(
required Future<void> Function() onSelectionHide,
required VoidCallback onSelectionRestore,
required VoidCallback onComplete,
Future<void> Function()? refreshLibrary,
}) async {
if (selected.isEmpty) return;
// Capture a stable route context before a caller removes its overlay.
@@ -243,14 +244,18 @@ Future<void> reEnrichLocalTracks(
if (!context.mounted || !isActive()) return;
if (!cancelled) BatchProgressDialog.dismiss(context);
try {
if (!ref.read(localLibraryProvider).isScanning) {
await ref.read(localLibraryProvider.notifier).scanAllSources();
} else {
if (refreshLibrary != null) {
await refreshLibrary();
} else {
try {
if (!ref.read(localLibraryProvider).isScanning) {
await ref.read(localLibraryProvider.notifier).scanAllSources();
} else {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
} catch (_) {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
} catch (_) {
await ref.read(localLibraryProvider.notifier).reloadFromStorage();
}
if (!context.mounted || !isActive()) return;
+209
View File
@@ -0,0 +1,209 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/models/settings.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/providers/settings_provider.dart';
import 'package:spotiflac_android/screens/downloaded_album_screen.dart';
import 'package:spotiflac_android/services/downloaded_embedded_cover_resolver.dart';
import 'package:spotiflac_android/theme/app_theme.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/widgets/selection_bottom_bar.dart';
class _Settings extends SettingsNotifier {
@override
AppSettings build() => const AppSettings();
@override
Future<void> syncLyricsSettingsToBackend({AppSettings? settings}) async {}
}
class _History extends DownloadHistoryNotifier {
final updates = <String, String?>{};
@override
DownloadHistoryState build() => DownloadHistoryState();
@override
Future<void> updateMetadataForItem({
required String id,
required String trackName,
required String artistName,
required String albumName,
String? albumArtist,
String? isrc,
int? trackNumber,
int? totalTracks,
int? discNumber,
int? totalDiscs,
String? releaseDate,
String? genre,
String? composer,
String? label,
String? copyright,
bool? explicit,
bool? hasLyrics,
int? lyricsMetadataScanVersion,
}) async {
updates[id] = isrc;
}
}
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('com.zarz.spotiflac/backend');
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
for (final mornye in [false, true]) {
testWidgets(
'downloaded album re-enrich restores selection on cancel and updates only selected files ($mornye)',
(tester) async {
SharedPreferences.setMockInitialValues({});
final cache = Directory.systemTemp.createTempSync('album_reenrich_');
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(
cache,
);
addTearDown(() async {
await tester.pumpWidget(const SizedBox.shrink());
await tester.runAsync(() async {
await DownloadedEmbeddedCoverResolver.resetMemoryStateForTesting();
DownloadedEmbeddedCoverResolver.setPersistentCacheDirectoryForTesting(
null,
);
await cache.delete(recursive: true);
});
});
const path = 'content://library/document/track.flac';
final requests = <Map<String, dynamic>>[];
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'reEnrichFile') {
final request = Map<String, dynamic>.from(
jsonDecode((call.arguments as Map)['request_json'] as String)
as Map,
);
requests.add(request);
return request['preview_only'] == true
? {
'method': 'preview',
'enriched_metadata': {'isrc': 'USABC2600001'},
}
: {'method': 'native'};
}
if (call.method == 'readAudioMetadata') {
return {
'trackName': 'Track',
'artistName': 'Artist',
'albumName': 'Album',
'isrc': 'USABC2600001',
'hasLyrics': true,
};
}
return null;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
final history = _History();
final navigator = GlobalKey<NavigatorState>();
await tester.pumpWidget(
ProviderScope(
overrides: [
settingsProvider.overrideWith(_Settings.new),
downloadHistoryProvider.overrideWith(() => history),
downloadedAlbumTracksProvider(
const DownloadedAlbumTracksRequest(
albumName: 'Album',
artistName: 'Artist',
),
).overrideWith(
(ref) async => [
for (final id in ['track', 'other'])
DownloadHistoryItem(
id: id,
trackName: id == 'track' ? 'Track' : 'Other',
artistName: 'Artist',
albumName: 'Album',
filePath: id == 'track' ? path : '/music/other.flac',
service: 'example-provider',
downloadedAt: DateTime(2026),
duration: 180,
),
],
),
],
child: MaterialApp(
navigatorKey: navigator,
theme: mornye
? MornyeTheme.build(Brightness.light)
: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const SelectionOverlayHost(
child: DownloadedAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
),
),
),
),
);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(find.text('Track'), 200);
await tester.longPress(find.text('Track'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(
tester.element(find.byType(SelectionBottomBar)),
);
expect(find.text(l10n.selectionShareCount(1)), findsNothing);
Future<void> openReEnrich() async {
await tester.tap(find.text('${l10n.trackReEnrich} (1)'));
await tester.pumpAndSettle();
expect(find.byType(SelectionBottomBar), findsNothing);
expect(find.text('Review changes'), findsOneWidget);
}
await openReEnrich();
navigator.currentState!.pop();
await tester.pumpAndSettle();
expect(find.byType(SelectionBottomBar), findsOneWidget);
expect(requests, isEmpty);
await openReEnrich();
await tester.ensureVisible(find.text('Review changes'));
await tester.pumpAndSettle();
await tester.tap(find.text('Review changes'));
await tester.pumpAndSettle();
expect(requests, hasLength(1));
expect(requests.single['file_path'], path);
expect(requests.single['duration_ms'], 180000);
await tester.ensureVisible(find.text('Apply changes'));
await tester.pumpAndSettle();
await tester.tap(find.text('Apply changes'));
// The history refresh also invalidates the disk-backed cover cache.
for (
var attempt = 0;
attempt < 50 && history.updates.isEmpty;
attempt++
) {
await tester.pump(const Duration(milliseconds: 100));
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 10));
});
}
await tester.pumpAndSettle();
expect(requests, hasLength(2));
expect(requests.last['file_path'], path);
expect(history.updates, {'track': 'USABC2600001'});
expect(find.byType(SelectionBottomBar), findsNothing);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox.shrink());
},
);
}
}