feat(metadata): remove ReplayGain from individual and selected tracks

This commit is contained in:
zarzet committed 2026-09-25 17:12:21 +07:00
1 parent c24132cb9b
commit 9d530cea9e
19 files changed
+835 -171

No files matched your search

+113 -91
View File
@@ -21,102 +21,124 @@ void main() {
for (final mornye in [false, true]) {
for (final downloaded in [false, true]) {
testWidgets(
'ReplayGain survives hiding the album selection (Mornye: $mornye, downloaded: $downloaded)',
(tester) async {
SharedPreferences.setMockInitialValues({});
var attempts = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'safCopyToTemp') attempts++;
// Simulate an inaccessible file. Reaching this call and showing
// the result proves confirmation actually starts the operation.
return null;
});
addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
const path = 'content://library/document/track.flac';
final item = DownloadHistoryItem(
id: 'track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: path,
service: 'provider-a',
downloadedAt: DateTime(2026),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
downloadedAlbumTracksProvider(
const DownloadedAlbumTracksRequest(
albumName: 'Album',
artistName: 'Artist',
for (final remove in [false, true]) {
testWidgets(
'ReplayGain survives hiding the album selection (Mornye: $mornye, downloaded: $downloaded, remove: $remove)',
(tester) async {
SharedPreferences.setMockInitialValues({});
var attempts = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'safCopyToTemp') attempts++;
// Simulate an inaccessible file. Reaching this call and showing
// the result proves confirmation actually starts the operation.
return null;
});
addTearDown(
() => messenger.setMockMethodCallHandler(channel, null),
);
const path = 'content://library/document/track.flac';
final item = DownloadHistoryItem(
id: 'track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: path,
service: 'provider-a',
downloadedAt: DateTime(2026),
);
await tester.pumpWidget(
ProviderScope(
overrides: [
downloadedAlbumTracksProvider(
const DownloadedAlbumTracksRequest(
albumName: 'Album',
artistName: 'Artist',
),
).overrideWith((ref) async => [item]),
],
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.light)
: AppTheme.light(),
localizationsDelegates:
AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: SelectionOverlayHost(
child: downloaded
? const DownloadedAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
)
: LocalAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
tracks: [
LocalLibraryItem(
id: 'track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: path,
scannedAt: DateTime(2026),
),
],
),
),
).overrideWith((ref) async => [item]),
],
child: MaterialApp(
theme: mornye
? MornyeTheme.build(Brightness.light)
: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: SelectionOverlayHost(
child: downloaded
? const DownloadedAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
)
: LocalAlbumScreen(
albumName: 'Album',
artistName: 'Artist',
tracks: [
LocalLibraryItem(
id: 'track',
trackName: 'Track',
artistName: 'Artist',
albumName: 'Album',
filePath: path,
scannedAt: DateTime(2026),
),
],
),
),
),
),
);
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)),
);
Future<void> openConfirmation() async {
await tester.tap(find.text(l10n.selectionReplayGainCount(1)));
);
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)),
);
Future<void> openConfirmation() async {
final action = find.text(
remove
? l10n.selectionRemoveReplayGainCount(1)
: l10n.selectionReplayGainCount(1),
);
await tester.ensureVisible(action);
await tester.tap(action);
await tester.pumpAndSettle();
expect(find.byType(SelectionBottomBar), findsNothing);
expect(find.byType(AppAlertDialog), findsOneWidget);
}
await openConfirmation();
await tester.tap(find.text(l10n.dialogCancel));
await tester.pumpAndSettle();
expect(attempts, 0);
expect(find.byType(SelectionBottomBar), findsOneWidget);
await openConfirmation();
await tester.tap(
find.descendant(
of: find.byType(AppDialogAction),
matching: find.text(
remove
? l10n.trackRemoveReplayGain
: l10n.replayGainBatchConfirmTitle,
),
),
);
await tester.pumpAndSettle();
expect(attempts, 1);
expect(
find.text(
remove
? l10n.replayGainRemoveBatchSuccess(0, 1)
: l10n.replayGainBatchSuccess(0, 1),
),
findsOneWidget,
);
expect(find.byType(SelectionBottomBar), findsNothing);
expect(find.byType(AppAlertDialog), findsOneWidget);
}
await openConfirmation();
await tester.tap(find.text(l10n.dialogCancel));
await tester.pumpAndSettle();
expect(attempts, 0);
expect(find.byType(SelectionBottomBar), findsOneWidget);
await openConfirmation();
await tester.tap(
find.descendant(
of: find.byType(AppDialogAction),
matching: find.text(l10n.replayGainBatchConfirmTitle),
),
);
await tester.pumpAndSettle();
expect(attempts, 1);
expect(find.text(l10n.replayGainBatchSuccess(0, 1)), findsOneWidget);
expect(find.byType(SelectionBottomBar), findsNothing);
expect(tester.takeException(), isNull);
},
);
expect(tester.takeException(), isNull);
},
);
}
}
}
}
+74
View File
@@ -194,4 +194,78 @@ void main() {
expect(metadata['replaygain_track_peak'], '1.258925');
expect(calls, ['editFileMetadata', 'readFileMetadata']);
});
test('removal clears both scopes and verifies before saving SAF', () async {
metadata['title'] = 'Preserved title';
expect(
await ReplayGainService.removeFromFile('content://music/document/42'),
isTrue,
);
expect(editedFields, {
'replaygain_track_gain': '',
'replaygain_track_peak': '',
'replaygain_album_gain': '',
'replaygain_album_peak': '',
});
expect(metadata['title'], 'Preserved title');
expect(calls, [
'safCopyToTemp',
'editFileMetadata',
'readFileMetadata',
'writeTempToSaf',
]);
expect(await File(tempPath).exists(), isFalse);
});
test('removal refuses to save when the gain tags remain', () async {
applyEdits = false;
expect(
await ReplayGainService.removeFromFile('content://music/document/42'),
isFalse,
);
expect(calls, ['safCopyToTemp', 'editFileMetadata', 'readFileMetadata']);
expect(await File(tempPath).exists(), isFalse);
});
test('removal does not treat a fallback instruction as success', () async {
method = 'ffmpeg';
expect(
await ReplayGainService.removeFromFile('content://music/document/42'),
isFalse,
);
expect(calls, ['safCopyToTemp', 'editFileMetadata']);
expect(await File(tempPath).exists(), isFalse);
});
test(
'removal reports SAF save failure and cleans its temporary copy',
() async {
saveSaf = false;
expect(
await ReplayGainService.removeFromFile('content://music/document/42'),
isFalse,
);
expect(calls.last, 'writeTempToSaf');
expect(await File(tempPath).exists(), isFalse);
},
);
test('removal rejects an empty metadata response', () async {
metadata = {};
expect(
await ReplayGainService.removeFromFile('content://music/document/42'),
isFalse,
);
expect(calls, ['safCopyToTemp', 'editFileMetadata', 'readFileMetadata']);
});
test('removal is idempotent for an untagged local file', () async {
method = 'native';
metadata = {'audio_codec': 'flac'};
expect(
await ReplayGainService.removeFromFile('${directory.path}/song.flac'),
isTrue,
);
expect(calls, ['editFileMetadata', 'readFileMetadata']);
});
}
+47
View File
@@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/providers/download_history_provider.dart';
import 'package:spotiflac_android/screens/track_metadata_screen.dart';
import 'package:spotiflac_android/widgets/app_alert_dialog.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -28,10 +29,26 @@ void main() {
'replaygain_album_peak': '1.234567',
}
: {'replaygain_track_gain': ' ', 'replaygain_album_peak': null};
var edits = 0;
messenger.setMockMethodCallHandler(channel, (call) async {
if (call.method == 'editFileMetadata') {
edits++;
final args = call.arguments as Map;
final fields = Map<String, String>.from(
jsonDecode(args['metadata_json'] as String) as Map,
);
metadata.addAll(fields);
return jsonEncode({'success': true, 'method': 'native'});
}
return switch (call.method) {
'safStat' => jsonEncode({'exists': true, 'size': 100}),
'readAudioMetadata' => jsonEncode(metadata),
'readFileMetadata' => jsonEncode({
...metadata,
'audio_codec': 'flac',
}),
'safCopyToTemp' => 'temporary-song.flac',
'writeTempToSaf' => jsonEncode({'success': true}),
'getLyricsLRCWithSource' => jsonEncode({'lyrics': '', 'source': ''}),
'getSafFileModTimes' => '{}',
_ => null,
@@ -74,6 +91,36 @@ void main() {
]) {
expect(find.text(text), hasTags ? findsOneWidget : findsNothing);
}
Future<void> openRemoval() async {
await tester.tap(find.byIcon(Icons.more_vert));
await tester.pumpAndSettle();
await tester.ensureVisible(find.text('Remove ReplayGain'));
await tester.tap(find.text('Remove ReplayGain'));
await tester.pumpAndSettle();
expect(find.byType(AppAlertDialog), findsOneWidget);
}
await openRemoval();
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(edits, 0);
if (hasTags) expect(find.text('-6.20 dB'), findsOneWidget);
await openRemoval();
await tester.tap(
find.descendant(
of: find.byType(AppDialogAction),
matching: find.text('Remove ReplayGain'),
),
);
await tester.runAsync(() async {
await Future<void>.delayed(const Duration(milliseconds: 100));
});
await tester.pumpAndSettle();
expect(edits, 1);
expect(find.text('ReplayGain tags removed'), findsOneWidget);
expect(find.text('ReplayGain Track Gain'), findsNothing);
expect(find.text('ReplayGain Album Gain'), findsNothing);
expect(tester.takeException(), isNull);
});
}