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

+31
View File
@@ -259,6 +259,37 @@
"@trackReplayGainFailed": {
"description": "Snackbar message when ReplayGain scan/write fails"
},
"trackRemoveReplayGain": "Remove ReplayGain",
"trackRemoveReplayGainSuccess": "ReplayGain tags removed",
"trackRemoveReplayGainFailed": "Failed to remove ReplayGain tags",
"replayGainRemoving": "Removing ReplayGain...",
"replayGainRemoveConfirmMessage": "Remove track and album ReplayGain tags from {count} track(s)? Audio, artwork, and other metadata will be preserved.",
"@replayGainRemoveConfirmMessage": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"selectionRemoveReplayGainCount": "Remove ReplayGain ({count})",
"@selectionRemoveReplayGainCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"replayGainRemoveBatchSuccess": "ReplayGain removed from {success} of {total} tracks",
"@replayGainRemoveBatchSuccess": {
"placeholders": {
"success": {
"type": "int"
},
"total": {
"type": "int"
}
}
},
"selectionReplayGainCount": "ReplayGain ({count})",
"@selectionReplayGainCount": {
"description": "Batch selection action button label for ReplayGain",
+7
View File
@@ -6051,6 +6051,13 @@
"extensionsNoDownloadProvider": "Tidak ada ekstensi dengan provider unduhan",
"libraryFilterSortGenreAsc": "Genre (A-Z)",
"trackReplayGainFailed": "Gagal menambahkan tag ReplayGain",
"trackRemoveReplayGain": "Hapus ReplayGain",
"trackRemoveReplayGainSuccess": "Tag ReplayGain dihapus",
"trackRemoveReplayGainFailed": "Gagal menghapus tag ReplayGain",
"replayGainRemoving": "Menghapus ReplayGain...",
"replayGainRemoveConfirmMessage": "Hapus tag ReplayGain lagu dan album dari {count} lagu? Audio, sampul, dan metadata lainnya tetap tersimpan.",
"selectionRemoveReplayGainCount": "Hapus ReplayGain ({count})",
"replayGainRemoveBatchSuccess": "ReplayGain dihapus dari {success} dari {total} lagu",
"collectionPlaylistRemoveCover": "Remove cover image",
"storeEmptyNoResults": "Tidak ada ekstensi ditemukan",
"logIssueTrackNotFoundDescription": "Some tracks could not be found on download services",
+18 -12
View File
@@ -763,18 +763,24 @@ class _DownloadedAlbumScreenState extends ConsumerState<DownloadedAlbumScreen>
: null,
colorScheme: colorScheme,
),
SelectionActionButton(
icon: Icons.graphic_eq,
label: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => runBatchReplayGain(
this.context,
_selectedUnifiedItems(tracks),
onExitSelectionMode: exitSelectionMode,
)
: null,
colorScheme: colorScheme,
),
for (final remove in [false, true])
SelectionActionButton(
icon: remove ? Icons.remove_circle_outline : Icons.graphic_eq,
label: remove
? context.l10n.selectionRemoveReplayGainCount(
selectedCount,
)
: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => runBatchReplayGain(
this.context,
_selectedUnifiedItems(tracks),
onExitSelectionMode: exitSelectionMode,
remove: remove,
)
: null,
colorScheme: colorScheme,
),
];
return Wrap(
+21 -14
View File
@@ -572,20 +572,27 @@ class _LocalAlbumScreenState extends ConsumerState<LocalAlbumScreen>
),
);
actions.add(
SelectionActionButton(
icon: Icons.graphic_eq,
label: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => runBatchReplayGain(
this.context,
_selectedUnifiedItems(tracks),
onExitSelectionMode: exitSelectionMode,
)
: null,
colorScheme: colorScheme,
),
);
for (final remove in [false, true]) {
actions.add(
SelectionActionButton(
icon: remove ? Icons.remove_circle_outline : Icons.graphic_eq,
label: remove
? context.l10n.selectionRemoveReplayGainCount(
selectedCount,
)
: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => runBatchReplayGain(
this.context,
_selectedUnifiedItems(tracks),
onExitSelectionMode: exitSelectionMode,
remove: remove,
)
: null,
colorScheme: colorScheme,
),
);
}
return Wrap(
spacing: spacing,
+5 -1
View File
@@ -119,11 +119,15 @@ extension _QueueTabBatchActions on _QueueTabState {
);
}
Future<void> _runBatchReplayGain(List<UnifiedLibraryItem> allItems) {
Future<void> _runBatchReplayGain(
List<UnifiedLibraryItem> allItems, {
bool remove = false,
}) {
return runBatchReplayGain(
context,
_selectedItemsFromAll(allItems),
onExitSelectionMode: _exitSelectionMode,
remove: remove,
onConfirmOpen: () {
_suppressSelectionOverlay = true;
_hideSelectionOverlay();
+16 -10
View File
@@ -75,16 +75,22 @@ extension _QueueTabItemWidgets on _QueueTabState {
),
);
actions.add(
SelectionActionButton(
icon: Icons.graphic_eq,
label: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => _runBatchReplayGain(unifiedItems)
: null,
colorScheme: colorScheme,
),
);
for (final remove in [false, true]) {
actions.add(
SelectionActionButton(
icon: remove ? Icons.remove_circle_outline : Icons.graphic_eq,
label: remove
? context.l10n.selectionRemoveReplayGainCount(
selectedCount,
)
: context.l10n.selectionReplayGainCount(selectedCount),
onPressed: selectedCount > 0
? () => _runBatchReplayGain(unifiedItems, remove: remove)
: null,
colorScheme: colorScheme,
),
);
}
return Wrap(
spacing: spacing,
+42 -7
View File
@@ -191,7 +191,32 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
return normalized;
}
Future<void> _rescanReplayGain() async {
Future<void> _removeReplayGain() async {
if (!_fileExists) return;
final sourcePath = cleanFilePath;
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AppAlertDialog(
title: Text(ctx.l10n.trackRemoveReplayGain),
content: Text(ctx.l10n.replayGainRemoveConfirmMessage(1)),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(ctx, false),
child: Text(ctx.l10n.dialogCancel),
),
AppDialogAction(
filled: true,
onPressed: () => Navigator.pop(ctx, true),
child: Text(ctx.l10n.trackRemoveReplayGain),
),
],
),
);
if (confirmed != true || !mounted || sourcePath != cleanFilePath) return;
await _updateReplayGain(remove: true);
}
Future<void> _updateReplayGain({bool remove = false}) async {
if (!_fileExists) return;
final sourcePath = cleanFilePath;
final generation = _metadataLoadGeneration;
@@ -199,15 +224,21 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
messenger.clearSnackBars();
messenger.showSnackBar(
SnackBar(
content: Text(context.l10n.trackReplayGainScanning),
content: Text(
remove
? context.l10n.replayGainRemoving
: context.l10n.trackReplayGainScanning,
),
duration: const Duration(seconds: 30),
),
);
bool ok = false;
try {
ok = await ReplayGainService.applyToFile(sourcePath);
ok = remove
? await ReplayGainService.removeFromFile(sourcePath)
: await ReplayGainService.applyToFile(sourcePath);
} catch (e) {
_log.w('ReplayGain rescan failed: $e');
_log.w('ReplayGain update failed: $e');
}
if (!mounted) return;
if (ok &&
@@ -221,9 +252,13 @@ extension _TrackMetadataConvertAndCueSplit on _TrackMetadataScreenState {
messenger.showSnackBar(
SnackBar(
content: Text(
ok
? context.l10n.trackReplayGainSuccess
: context.l10n.trackReplayGainFailed,
remove
? (ok
? context.l10n.trackRemoveReplayGainSuccess
: context.l10n.trackRemoveReplayGainFailed)
: (ok
? context.l10n.trackReplayGainSuccess
: context.l10n.trackReplayGainFailed),
),
),
);
+7 -1
View File
@@ -72,7 +72,13 @@ extension _TrackMetadataMenu on _TrackMetadataScreenState {
_MetadataOption(
icon: Icons.graphic_eq,
label: l10n.trackReplayGain,
onTap: () => _rescanReplayGain(),
onTap: () => _updateReplayGain(),
),
if (_fileExists && !_isCueFile)
_MetadataOption(
icon: Icons.remove_circle_outline,
label: l10n.trackRemoveReplayGain,
onTap: () => _removeReplayGain(),
),
if (_fileExists && _isCueFile)
_MetadataOption(
+27 -10
View File
@@ -536,7 +536,7 @@ Future<void> _performBatchConversion(
}
}
/// Batch-scans loudness and writes ReplayGain tags to [selectedItems].
/// Adds or removes ReplayGain tags for [selectedItems].
///
/// [onConfirmOpen] / [onConfirmClosed] let the caller hide and restore any
/// selection UI around the confirmation dialog; [onConfirmClosed] receives
@@ -545,6 +545,7 @@ Future<void> runBatchReplayGain(
BuildContext context,
List<UnifiedLibraryItem> selectedItems, {
required VoidCallback onExitSelectionMode,
bool remove = false,
VoidCallback? onConfirmOpen,
Future<void> Function(bool confirmed)? onConfirmClosed,
}) async {
@@ -555,9 +556,15 @@ Future<void> runBatchReplayGain(
final confirmed = await showAppDialog<bool>(
context: context,
builder: (ctx) => AppAlertDialog(
title: Text(ctx.l10n.replayGainBatchConfirmTitle),
title: Text(
remove
? ctx.l10n.trackRemoveReplayGain
: ctx.l10n.replayGainBatchConfirmTitle,
),
content: Text(
ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length),
remove
? ctx.l10n.replayGainRemoveConfirmMessage(selectedItems.length)
: ctx.l10n.replayGainBatchConfirmMessage(selectedItems.length),
),
actions: [
AppDialogAction(
@@ -568,7 +575,11 @@ Future<void> runBatchReplayGain(
filled: true,
isDefault: true,
onPressed: () => Navigator.pop(ctx, true),
child: Text(ctx.l10n.replayGainBatchConfirmTitle),
child: Text(
remove
? ctx.l10n.trackRemoveReplayGain
: ctx.l10n.replayGainBatchConfirmTitle,
),
),
],
),
@@ -585,7 +596,9 @@ Future<void> runBatchReplayGain(
BatchProgressDialog.show(
context: context,
title: context.l10n.replayGainBatchAnalyzing,
title: remove
? context.l10n.replayGainRemoving
: context.l10n.replayGainBatchAnalyzing,
total: total,
icon: Icons.graphic_eq,
onCancel: () {
@@ -599,10 +612,12 @@ Future<void> runBatchReplayGain(
final item = selectedItems[i];
BatchProgressDialog.update(current: i + 1, detail: item.trackName);
try {
final ok = await ReplayGainService.applyToFile(
item.filePath,
onUnsupportedDecoder: () => unsupportedDecoder = true,
);
final ok = remove
? await ReplayGainService.removeFromFile(item.filePath)
: await ReplayGainService.applyToFile(
item.filePath,
onUnsupportedDecoder: () => unsupportedDecoder = true,
);
if (ok) successCount++;
} catch (_) {}
}
@@ -618,7 +633,9 @@ Future<void> runBatchReplayGain(
SnackBar(
content: Text(
[
context.l10n.replayGainBatchSuccess(successCount, total),
remove
? context.l10n.replayGainRemoveBatchSuccess(successCount, total)
: context.l10n.replayGainBatchSuccess(successCount, total),
if (unsupportedDecoder) context.l10n.replayGainUnsupportedDecoder,
].join('\n'),
),
+31 -4
View File
@@ -7,7 +7,7 @@ import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/utils/file_access.dart';
import 'package:spotiflac_android/utils/logger.dart';
/// Standalone ReplayGain (re)scanning for existing audio files.
/// ReplayGain scanning and tag removal for existing audio files.
///
/// Computes EBU R128 loudness via FFmpeg and writes gain tags using the native
/// metadata editors where supported. Opus uses R128_* rather than legacy tags.
@@ -104,6 +104,33 @@ class ReplayGainService {
(path) => _writeLocalTags(path, gain, peak, album: true),
);
/// Removes track/album tags, including Opus R128 and M4A Sound Check tags.
/// Native editors copy the audio payload unchanged and publish atomically.
static Future<bool> removeFromFile(String filePath) =>
_updateFile(filePath, (path) async {
if (!_isNativeWritableFormat(path)) return false;
const fields = {
'replaygain_track_gain': '',
'replaygain_track_peak': '',
'replaygain_album_gain': '',
'replaygain_album_peak': '',
};
final result = await PlatformBridge.editFileMetadata(path, fields);
final method = result['method'];
if (result['success'] != true ||
result['error'] != null ||
method is! String ||
!(method == 'native' || method.startsWith('native_'))) {
return false;
}
final metadata = await PlatformBridge.readFileMetadata(path);
return metadata['error'] == null &&
metadata['audio_codec'] != null &&
fields.keys.every(
(key) => (metadata[key]?.toString() ?? '').trim().isEmpty,
);
});
static Future<bool> _writeLocalTags(
String path,
String gain,
@@ -182,7 +209,7 @@ class ReplayGainService {
if (isSaf) {
safTempPath = await PlatformBridge.copyContentUriToTemp(filePath);
if (safTempPath == null || safTempPath.isEmpty) {
_log.w('Failed to copy SAF file to temp for ReplayGain scan');
_log.w('Failed to copy SAF file to temp for ReplayGain update');
return false;
}
workingPath = safTempPath;
@@ -199,10 +226,10 @@ class ReplayGainService {
}
refreshPlaybackNormalization(filePath);
_log.i('ReplayGain tags written and verified: $filePath');
_log.i('ReplayGain tags updated and verified: $filePath');
return true;
} catch (e) {
_log.e('Failed to apply ReplayGain', e);
_log.e('Failed to update ReplayGain', e);
return false;
} finally {
if (safTempPath != null) {