mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 21:29:36 +02:00
feat(metadata): remove ReplayGain from individual and selected tracks
This commit is contained in:
1 parent
c24132cb9b
commit
9d530cea9e
19 files changed
+835
-171
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'),
|
||||
),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user