mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-23 01:50:44 +02:00
feat: add ReplayGain scanning, APEv2 tag support, and fix metadata bugs
ReplayGain (track + album): - Scan track loudness via FFmpeg ebur128 filter (-18 LUFS reference) - Duration-weighted power-mean for album gain computation - Support for FLAC (native Vorbis), MP3 (ID3v2 TXXX), Opus, M4A - Album RG auto-finalizes when all album tracks complete - Retryable gate: blocks finalization while failed/skipped items exist - SAF support: lossy album RG writes via temp file + writeTempToSaf - New embedReplayGain setting (off by default) with UI toggle APEv2 tag support: - Full APEv2 reader/writer with header+items+footer format - Merge-based editing with override keys for explicit deletions - Binary cover art embedding (Cover Art (Front) item) - Library scanner support for .ape/.wv/.mpc files - ReplayGain fields in APE read/write/edit pipeline Bug fixes (26): - setArtistComments wiping fields on empty string value - APEv2 rewrite corrupting files with ID3v1 trailer - APE edit replacing entire tag instead of merging - ReplayGain lost on manual MP3/Opus/M4A metadata edit - Editor metadata save losing custom tags (preserveMetadata) - Album RG accumulator not cleaned on queue mutation - Album gain using unweighted mean instead of power-mean - writeAlbumReplayGainTags return value silently ignored - SAF album RG writing to deleted temp path - Cancelled tracks polluting album gain computation - APE ReplayGain not wired end-to-end - APE field deletion not working in merge - APE cover edit was a no-op - Album RG duplicate entries on retry - APE apeKeysFromFields missing track/disc/lyrics mappings - Album RG entries purged by removeItem before computation - FFmpeg converters discarding empty metadata values - _appendVorbisArtistEntries skipping empty value (null vs empty) - Album RG write-back fails for SAF lossy files - Album RG partial finalization on failed tracks - FLAC ClearEmpty flag destroying tags on partial callers - clearCompleted not retriggering album RG checks - ReadFileMetadata MP3/Ogg missing label and copyright - Cover embed on CUE split destroying split artist tags - Album RG gain format inconsistent (missing + prefix) - FLAC reader/editor missing tag aliases (ALBUMARTIST, LABEL, etc.) - dart:math log shadowed by logger.dart export
This commit is contained in:
@@ -412,6 +412,24 @@ abstract class AppLocalizations {
|
||||
/// **'Download highest resolution cover art'**
|
||||
String get optionsMaxQualityCoverSubtitle;
|
||||
|
||||
/// Title for ReplayGain setting toggle
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'ReplayGain'**
|
||||
String get optionsReplayGain;
|
||||
|
||||
/// Subtitle when ReplayGain is enabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Scan loudness and embed ReplayGain tags (EBU R128)'**
|
||||
String get optionsReplayGainSubtitleOn;
|
||||
|
||||
/// Subtitle when ReplayGain is disabled
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Disabled: no loudness normalization tags'**
|
||||
String get optionsReplayGainSubtitleOff;
|
||||
|
||||
/// Setting title for how artist metadata is written into files
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
||||
@@ -165,6 +165,17 @@ class AppLocalizationsDe extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Cover in höchster Auflösung herunterladen';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -161,6 +161,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -161,6 +161,17 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -163,6 +163,17 @@ class AppLocalizationsFr extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -161,6 +161,17 @@ class AppLocalizationsHi extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -165,6 +165,17 @@ class AppLocalizationsId extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Unduh cover art resolusi tertinggi';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -159,6 +159,17 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get optionsMaxQualityCoverSubtitle => '最高解像度のカバーアートをダウンロード';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -155,6 +155,17 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get optionsMaxQualityCoverSubtitle => '최고 품질의 커버 이미지를 다운로드';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -161,6 +161,17 @@ class AppLocalizationsNl extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -161,6 +161,17 @@ class AppLocalizationsPt extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -166,6 +166,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Скачивать обложку в макс. разрешении';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -164,6 +164,17 @@ class AppLocalizationsTr extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'En yüksek kalitedeki albüm kapaklarını indir';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -161,6 +161,17 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
String get optionsMaxQualityCoverSubtitle =>
|
||||
'Download highest resolution cover art';
|
||||
|
||||
@override
|
||||
String get optionsReplayGain => 'ReplayGain';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOn =>
|
||||
'Scan loudness and embed ReplayGain tags (EBU R128)';
|
||||
|
||||
@override
|
||||
String get optionsReplayGainSubtitleOff =>
|
||||
'Disabled: no loudness normalization tags';
|
||||
|
||||
@override
|
||||
String get optionsArtistTagMode => 'Artist Tag Mode';
|
||||
|
||||
|
||||
@@ -198,6 +198,18 @@
|
||||
"@optionsMaxQualityCoverSubtitle": {
|
||||
"description": "Subtitle for max quality cover"
|
||||
},
|
||||
"optionsReplayGain": "ReplayGain",
|
||||
"@optionsReplayGain": {
|
||||
"description": "Title for ReplayGain setting toggle"
|
||||
},
|
||||
"optionsReplayGainSubtitleOn": "Scan loudness and embed ReplayGain tags (EBU R128)",
|
||||
"@optionsReplayGainSubtitleOn": {
|
||||
"description": "Subtitle when ReplayGain is enabled"
|
||||
},
|
||||
"optionsReplayGainSubtitleOff": "Disabled: no loudness normalization tags",
|
||||
"@optionsReplayGainSubtitleOff": {
|
||||
"description": "Subtitle when ReplayGain is disabled"
|
||||
},
|
||||
"optionsArtistTagMode": "Artist Tag Mode",
|
||||
"@optionsArtistTagMode": {
|
||||
"description": "Setting title for how artist metadata is written into files"
|
||||
|
||||
@@ -16,6 +16,7 @@ class AppSettings {
|
||||
final String
|
||||
artistTagMode; // 'joined' or 'split_vorbis' for Vorbis-based formats
|
||||
final bool embedLyrics;
|
||||
final bool embedReplayGain; // Calculate and embed ReplayGain tags
|
||||
final bool maxQualityCover;
|
||||
final bool isFirstLaunch;
|
||||
final int concurrentDownloads;
|
||||
@@ -90,6 +91,7 @@ class AppSettings {
|
||||
this.embedMetadata = true,
|
||||
this.artistTagMode = artistTagModeJoined,
|
||||
this.embedLyrics = true,
|
||||
this.embedReplayGain = false,
|
||||
this.maxQualityCover = true,
|
||||
this.isFirstLaunch = true,
|
||||
this.concurrentDownloads = 1,
|
||||
@@ -151,6 +153,7 @@ class AppSettings {
|
||||
bool? embedMetadata,
|
||||
String? artistTagMode,
|
||||
bool? embedLyrics,
|
||||
bool? embedReplayGain,
|
||||
bool? maxQualityCover,
|
||||
bool? isFirstLaunch,
|
||||
int? concurrentDownloads,
|
||||
@@ -207,6 +210,7 @@ class AppSettings {
|
||||
embedMetadata: embedMetadata ?? this.embedMetadata,
|
||||
artistTagMode: artistTagMode ?? this.artistTagMode,
|
||||
embedLyrics: embedLyrics ?? this.embedLyrics,
|
||||
embedReplayGain: embedReplayGain ?? this.embedReplayGain,
|
||||
maxQualityCover: maxQualityCover ?? this.maxQualityCover,
|
||||
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
|
||||
concurrentDownloads: concurrentDownloads ?? this.concurrentDownloads,
|
||||
|
||||
@@ -17,6 +17,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
|
||||
embedMetadata: json['embedMetadata'] as bool? ?? true,
|
||||
artistTagMode: json['artistTagMode'] as String? ?? artistTagModeJoined,
|
||||
embedLyrics: json['embedLyrics'] as bool? ?? true,
|
||||
embedReplayGain: json['embedReplayGain'] as bool? ?? false,
|
||||
maxQualityCover: json['maxQualityCover'] as bool? ?? true,
|
||||
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
|
||||
concurrentDownloads: (json['concurrentDownloads'] as num?)?.toInt() ?? 1,
|
||||
@@ -86,6 +87,7 @@ Map<String, dynamic> _$AppSettingsToJson(
|
||||
'embedMetadata': instance.embedMetadata,
|
||||
'artistTagMode': instance.artistTagMode,
|
||||
'embedLyrics': instance.embedLyrics,
|
||||
'embedReplayGain': instance.embedReplayGain,
|
||||
'maxQualityCover': instance.maxQualityCover,
|
||||
'isFirstLaunch': instance.isFirstLaunch,
|
||||
'concurrentDownloads': instance.concurrentDownloads,
|
||||
|
||||
@@ -17,7 +17,7 @@ import 'package:spotiflac_android/services/download_request_payload.dart';
|
||||
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
||||
import 'package:spotiflac_android/services/notification_service.dart';
|
||||
import 'package:spotiflac_android/services/history_database.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart' hide log;
|
||||
import 'package:spotiflac_android/utils/file_access.dart';
|
||||
import 'package:spotiflac_android/utils/string_utils.dart';
|
||||
import 'package:spotiflac_android/utils/artist_utils.dart';
|
||||
@@ -27,6 +27,9 @@ final _historyLog = AppLogger('DownloadHistory');
|
||||
|
||||
final _invalidFolderChars = RegExp(r'[<>:"/\\|?*]');
|
||||
final _trailingDotsRegex = RegExp(r'\.+$');
|
||||
|
||||
/// log10 helper using dart:math's natural log.
|
||||
double _log10(num x) => log(x) / ln10;
|
||||
final _yearRegex = RegExp(r'^(\d{4})');
|
||||
const _defaultOutputFolderName = 'SpotiFLAC';
|
||||
const _defaultAndroidMusicSubpath = 'Music/$_defaultOutputFolderName';
|
||||
@@ -1222,6 +1225,11 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
final Set<String> _locallyCancelledItemIds = {};
|
||||
final Set<String> _pausePendingItemIds = {};
|
||||
|
||||
// Album ReplayGain accumulator: keyed by album identifier.
|
||||
// Stores per-track loudness data until all album tracks are done,
|
||||
// then computes and writes album gain/peak to every track in the album.
|
||||
final Map<String, _AlbumRgAccumulator> _albumRgData = {};
|
||||
|
||||
double _normalizeProgressForUi(double value) {
|
||||
final clamped = value.clamp(0.0, 1.0).toDouble();
|
||||
if (clamped <= 0) return 0;
|
||||
@@ -2453,6 +2461,9 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
item.status == DownloadStatus.queued ||
|
||||
item.status == DownloadStatus.downloading ||
|
||||
item.status == DownloadStatus.finalizing;
|
||||
final wasFailed =
|
||||
item.status == DownloadStatus.failed ||
|
||||
item.status == DownloadStatus.skipped;
|
||||
|
||||
if (isActive) {
|
||||
_pausePendingItemIds.remove(id);
|
||||
@@ -2462,15 +2473,55 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_locallyCancelledItemIds.remove(id);
|
||||
}
|
||||
|
||||
// Clean accumulator entry for non-completed items.
|
||||
if (item.status != DownloadStatus.completed) {
|
||||
final key = _albumRgKey(item.track);
|
||||
final accumulator = _albumRgData[key];
|
||||
if (accumulator != null) {
|
||||
accumulator.entries.removeWhere((e) => e.trackId == item.track.id);
|
||||
if (accumulator.entries.isEmpty) {
|
||||
_albumRgData.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final items = state.items.where((entry) => entry.id != id).toList();
|
||||
final currentDownload = state.currentDownload?.id == id
|
||||
? null
|
||||
: state.currentDownload;
|
||||
state = state.copyWith(items: items, currentDownload: currentDownload);
|
||||
_saveQueueToStorage();
|
||||
|
||||
// Dismissing a failed/skipped item may unblock album RG.
|
||||
if (wasFailed) {
|
||||
_retriggerAlbumRgChecks();
|
||||
}
|
||||
}
|
||||
|
||||
void clearCompleted() {
|
||||
// Purge accumulator entries for failed/skipped items being removed.
|
||||
final removedItems = state.items.where(
|
||||
(item) =>
|
||||
item.status == DownloadStatus.completed ||
|
||||
item.status == DownloadStatus.failed ||
|
||||
item.status == DownloadStatus.skipped,
|
||||
);
|
||||
bool hadFailedOrSkipped = false;
|
||||
for (final item in removedItems) {
|
||||
if (item.status == DownloadStatus.failed ||
|
||||
item.status == DownloadStatus.skipped) {
|
||||
hadFailedOrSkipped = true;
|
||||
final key = _albumRgKey(item.track);
|
||||
final accumulator = _albumRgData[key];
|
||||
if (accumulator != null) {
|
||||
accumulator.entries.removeWhere((e) => e.trackId == item.track.id);
|
||||
if (accumulator.entries.isEmpty) {
|
||||
_albumRgData.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final items = state.items
|
||||
.where(
|
||||
(item) =>
|
||||
@@ -2482,6 +2533,10 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage();
|
||||
|
||||
if (hadFailedOrSkipped) {
|
||||
_retriggerAlbumRgChecks();
|
||||
}
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
@@ -2507,6 +2562,7 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
state = state.copyWith(items: [], isPaused: false, currentDownload: null);
|
||||
_notificationService.cancelDownloadNotification();
|
||||
_saveQueueToStorage();
|
||||
_albumRgData.clear();
|
||||
if (!wasProcessing) {
|
||||
_locallyCancelledItemIds.clear();
|
||||
}
|
||||
@@ -2572,6 +2628,17 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
_log.i('Retrying item: ${item.track.name} (id: $id)');
|
||||
_locallyCancelledItemIds.remove(id);
|
||||
|
||||
// Purge stale ReplayGain entry for this track so a re-scan doesn't
|
||||
// produce duplicate entries that bias album gain.
|
||||
final rgKey = _albumRgKey(item.track);
|
||||
final rgAcc = _albumRgData[rgKey];
|
||||
if (rgAcc != null) {
|
||||
rgAcc.entries.removeWhere((e) => e.trackId == item.track.id);
|
||||
if (rgAcc.entries.isEmpty) {
|
||||
_albumRgData.remove(rgKey);
|
||||
}
|
||||
}
|
||||
|
||||
final items = state.items.map((i) {
|
||||
if (i.id == id) {
|
||||
return i.copyWith(
|
||||
@@ -2594,10 +2661,30 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
void removeItem(String id) {
|
||||
final removedItem = state.items.where((item) => item.id == id).firstOrNull;
|
||||
_locallyCancelledItemIds.remove(id);
|
||||
final items = state.items.where((item) => item.id != id).toList();
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage();
|
||||
|
||||
// Clean stale album RG entries when a track is removed from the queue.
|
||||
// Only purge for items that were NOT completed — completed items' RG data
|
||||
// must survive removal because album gain is computed after the last track
|
||||
// finishes, by which time earlier completed tracks have been removed.
|
||||
if (removedItem != null && removedItem.status != DownloadStatus.completed) {
|
||||
final key = _albumRgKey(removedItem.track);
|
||||
final accumulator = _albumRgData[key];
|
||||
if (accumulator != null) {
|
||||
accumulator.entries.removeWhere(
|
||||
(e) => e.trackId == removedItem.track.id,
|
||||
);
|
||||
if (accumulator.entries.isEmpty) {
|
||||
_albumRgData.remove(key);
|
||||
}
|
||||
}
|
||||
// Removing a failed/skipped item may unblock album RG for the album.
|
||||
_retriggerAlbumRgChecks();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> exportFailedDownloads() async {
|
||||
@@ -2673,12 +2760,32 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
|
||||
void clearFailedDownloads() {
|
||||
// Purge accumulator entries for failed items before removing them.
|
||||
final failedItems = state.items
|
||||
.where((item) => item.status == DownloadStatus.failed)
|
||||
.toList();
|
||||
for (final item in failedItems) {
|
||||
final key = _albumRgKey(item.track);
|
||||
final accumulator = _albumRgData[key];
|
||||
if (accumulator != null) {
|
||||
accumulator.entries.removeWhere((e) => e.trackId == item.track.id);
|
||||
if (accumulator.entries.isEmpty) {
|
||||
_albumRgData.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final items = state.items
|
||||
.where((item) => item.status != DownloadStatus.failed)
|
||||
.toList();
|
||||
state = state.copyWith(items: items);
|
||||
_saveQueueToStorage();
|
||||
_log.d('Cleared failed downloads from queue');
|
||||
|
||||
// Removing failed items may unblock album RG for affected albums.
|
||||
if (failedItems.isNotEmpty) {
|
||||
_retriggerAlbumRgChecks();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runPostProcessingHooks(String filePath, Track track) async {
|
||||
@@ -2734,6 +2841,287 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Album ReplayGain: accumulate per-track data, compute & write album gain
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a stable key for grouping tracks by album.
|
||||
String _albumRgKey(Track track) {
|
||||
if (track.albumId != null && track.albumId!.isNotEmpty) {
|
||||
return 'id:${track.albumId}';
|
||||
}
|
||||
return 'name:${track.albumName}|${track.albumArtist ?? ''}';
|
||||
}
|
||||
|
||||
/// Store a track's ReplayGain scan result for later album gain computation.
|
||||
void _storeTrackReplayGainForAlbum(
|
||||
Track track,
|
||||
String filePath,
|
||||
ReplayGainResult rg,
|
||||
) {
|
||||
final key = _albumRgKey(track);
|
||||
_albumRgData.putIfAbsent(key, () => _AlbumRgAccumulator());
|
||||
// Remove any stale entry for this track (e.g. from a previous failed
|
||||
// attempt that was retried). Without this, the same track can accumulate
|
||||
// multiple entries and bias the album loudness calculation.
|
||||
_albumRgData[key]!.entries.removeWhere((e) => e.trackId == track.id);
|
||||
_albumRgData[key]!.entries.add(
|
||||
_AlbumRgTrackEntry(
|
||||
filePath: filePath,
|
||||
trackId: track.id,
|
||||
integratedLufs: rg.integratedLufs,
|
||||
truePeakLinear: rg.truePeakLinear,
|
||||
durationSecs: track.duration.toDouble(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Replace the temp path stored in the accumulator with the final output
|
||||
/// path. For SAF downloads the embed happens on a temp file which is later
|
||||
/// deleted — this ensures the album-gain writer targets the real file.
|
||||
void _updateAlbumRgFilePath(Track track, String finalPath) {
|
||||
final key = _albumRgKey(track);
|
||||
final accumulator = _albumRgData[key];
|
||||
if (accumulator == null) return;
|
||||
// Find the entry for this track and update its file path in-place.
|
||||
for (final entry in accumulator.entries) {
|
||||
if (entry.trackId == track.id) {
|
||||
entry.filePath = finalPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After a track completes, check whether all tracks from the same album
|
||||
/// in the current queue are done. If so, compute album gain and write it
|
||||
/// to every track's file.
|
||||
Future<void> _checkAndWriteAlbumReplayGain(Track track) async {
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (!settings.embedReplayGain) return;
|
||||
|
||||
final key = _albumRgKey(track);
|
||||
final accumulator = _albumRgData[key];
|
||||
if (accumulator == null || accumulator.entries.isEmpty) return;
|
||||
|
||||
// Find queue items for this album that are STILL in the queue.
|
||||
// Completed tracks may have already been removed by removeItem(), so
|
||||
// their absence means they finished successfully (not that they're
|
||||
// still pending).
|
||||
final albumItemsInQueue = state.items
|
||||
.where((item) => _albumRgKey(item.track) == key)
|
||||
.toList();
|
||||
|
||||
// If any item is still in-flight, the album isn't complete yet.
|
||||
final pending = albumItemsInQueue.where(
|
||||
(item) =>
|
||||
item.status == DownloadStatus.queued ||
|
||||
item.status == DownloadStatus.downloading ||
|
||||
item.status == DownloadStatus.finalizing,
|
||||
);
|
||||
if (pending.isNotEmpty) return; // still in progress
|
||||
|
||||
// If any item is failed/skipped, the user might retry it later.
|
||||
// Don't finalize album RG with partial data — wait until all album
|
||||
// tracks are either completed (and possibly removed) or retried.
|
||||
final retryable = albumItemsInQueue.where(
|
||||
(item) =>
|
||||
item.status == DownloadStatus.failed ||
|
||||
item.status == DownloadStatus.skipped,
|
||||
);
|
||||
if (retryable.isNotEmpty) return; // still retryable
|
||||
|
||||
// The accumulator entries represent successfully scanned tracks. Entries
|
||||
// are only added after a successful ReplayGain scan, removed on retry or
|
||||
// when a non-completed item is removed from the queue, so every entry
|
||||
// here corresponds to a track that completed (or is about to complete)
|
||||
// its download.
|
||||
final validEntries = accumulator.entries.toList();
|
||||
|
||||
// Single-track albums: album gain == track gain, no extra write needed.
|
||||
if (validEntries.length <= 1) {
|
||||
_albumRgData.remove(key);
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute album gain using duration-weighted power-mean of LUFS values.
|
||||
// album_loudness = 10 * log10( Σ(10^(Li/10) * di) / Σ(di) )
|
||||
// This weights longer tracks more, matching "whole program" loudness.
|
||||
double sumWeightedPower = 0;
|
||||
double sumDuration = 0;
|
||||
double maxPeak = 0;
|
||||
for (final entry in validEntries) {
|
||||
final weight = entry.durationSecs > 0 ? entry.durationSecs : 1.0;
|
||||
sumWeightedPower += pow(10, entry.integratedLufs / 10.0) * weight;
|
||||
sumDuration += weight;
|
||||
if (entry.truePeakLinear > maxPeak) {
|
||||
maxPeak = entry.truePeakLinear;
|
||||
}
|
||||
}
|
||||
final albumLufs = 10.0 * _log10(sumWeightedPower / sumDuration);
|
||||
const replayGainReferenceLufs = -18.0;
|
||||
final albumGainDb = replayGainReferenceLufs - albumLufs;
|
||||
|
||||
final albumGain =
|
||||
'${albumGainDb >= 0 ? "+" : ""}${albumGainDb.toStringAsFixed(2)} dB';
|
||||
final albumPeak = maxPeak.toStringAsFixed(6);
|
||||
|
||||
_log.i(
|
||||
'Album ReplayGain for "$key": gain=$albumGain, peak=$albumPeak (${validEntries.length} tracks, album LUFS=${albumLufs.toStringAsFixed(1)})',
|
||||
);
|
||||
|
||||
// Write album gain to every completed track file.
|
||||
for (final entry in validEntries) {
|
||||
try {
|
||||
await _writeAlbumReplayGain(entry.filePath, albumGain, albumPeak);
|
||||
} catch (e) {
|
||||
_log.w('Failed to write album ReplayGain to ${entry.filePath}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_albumRgData.remove(key);
|
||||
}
|
||||
|
||||
/// Write album ReplayGain tags to a single file.
|
||||
Future<void> _writeAlbumReplayGain(
|
||||
String filePath,
|
||||
String albumGain,
|
||||
String albumPeak,
|
||||
) async {
|
||||
final lower = filePath.toLowerCase();
|
||||
if (lower.endsWith('.flac') ||
|
||||
lower.endsWith('.ape') ||
|
||||
lower.endsWith('.wv') ||
|
||||
lower.endsWith('.mpc')) {
|
||||
// Native writer — only touches the provided fields, preserves the rest.
|
||||
await PlatformBridge.editFileMetadata(filePath, {
|
||||
'replaygain_album_gain': albumGain,
|
||||
'replaygain_album_peak': albumPeak,
|
||||
});
|
||||
} else if (isContentUri(filePath)) {
|
||||
// SAF content:// URI — FFmpeg can read it but can't write back directly.
|
||||
// Get the temp output from FFmpeg, then copy it to the SAF URI.
|
||||
String? tempPath;
|
||||
final ok = await FFmpegService.writeAlbumReplayGainTags(
|
||||
filePath,
|
||||
albumGain,
|
||||
albumPeak,
|
||||
returnTempPath: true,
|
||||
onTempReady: (path) => tempPath = path,
|
||||
);
|
||||
if (ok && tempPath != null) {
|
||||
try {
|
||||
final safOk = await PlatformBridge.writeTempToSaf(
|
||||
tempPath!,
|
||||
filePath,
|
||||
);
|
||||
if (!safOk) {
|
||||
_log.w('SAF write-back failed for album RG: $filePath');
|
||||
}
|
||||
} finally {
|
||||
// Clean up temp file regardless of SAF result.
|
||||
try {
|
||||
final tmp = File(tempPath!);
|
||||
if (await tmp.exists()) await tmp.delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
} else {
|
||||
_log.w('FFmpeg album ReplayGain write failed for SAF: $filePath');
|
||||
}
|
||||
} else {
|
||||
// Local MP3 / Opus — use FFmpeg copy-with-metadata approach.
|
||||
final ok = await FFmpegService.writeAlbumReplayGainTags(
|
||||
filePath,
|
||||
albumGain,
|
||||
albumPeak,
|
||||
);
|
||||
if (!ok) {
|
||||
_log.w('FFmpeg album ReplayGain write failed for: $filePath');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-check album ReplayGain for all albums that still have accumulator data.
|
||||
/// Called after removing/dismissing a failed or skipped item, which may
|
||||
/// unblock an album that was waiting for retryable items to be resolved.
|
||||
void _retriggerAlbumRgChecks() {
|
||||
if (_albumRgData.isEmpty) return;
|
||||
final settings = ref.read(settingsProvider);
|
||||
if (!settings.embedReplayGain) return;
|
||||
|
||||
// Snapshot the keys — _checkAndWriteAlbumReplayGain may mutate the map.
|
||||
final keys = _albumRgData.keys.toList();
|
||||
for (final key in keys) {
|
||||
final acc = _albumRgData[key];
|
||||
if (acc == null || acc.entries.isEmpty) continue;
|
||||
// Use the first entry's trackId to find a representative track.
|
||||
// _checkAndWriteAlbumReplayGain only needs it for _albumRgKey(), so any
|
||||
// track from the album works.
|
||||
final albumItems = state.items
|
||||
.where((item) => _albumRgKey(item.track) == key)
|
||||
.toList();
|
||||
// If there are no items left in queue for this album but we have
|
||||
// accumulator data, all items were completed and removed. Use a
|
||||
// synthetic call — we need a Track to call the check, but the items
|
||||
// are gone. For this case, directly check conditions inline.
|
||||
if (albumItems.isEmpty) {
|
||||
// All items removed → no pending/retryable. Trigger computation.
|
||||
if (acc.entries.length > 1) {
|
||||
_computeAndWriteAlbumRg(key, acc);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// If any representative item is available, use its track.
|
||||
final representative = albumItems.first;
|
||||
_checkAndWriteAlbumReplayGain(representative.track);
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute album RG and write it — extracted from _checkAndWriteAlbumReplayGain
|
||||
/// for use when no queue items remain (all completed and removed).
|
||||
Future<void> _computeAndWriteAlbumRg(
|
||||
String key,
|
||||
_AlbumRgAccumulator accumulator,
|
||||
) async {
|
||||
final validEntries = accumulator.entries.toList();
|
||||
if (validEntries.length <= 1) {
|
||||
_albumRgData.remove(key);
|
||||
return;
|
||||
}
|
||||
|
||||
double sumWeightedPower = 0;
|
||||
double sumDuration = 0;
|
||||
double maxPeak = 0;
|
||||
for (final entry in validEntries) {
|
||||
final weight = entry.durationSecs > 0 ? entry.durationSecs : 1.0;
|
||||
sumWeightedPower += pow(10, entry.integratedLufs / 10.0) * weight;
|
||||
sumDuration += weight;
|
||||
if (entry.truePeakLinear > maxPeak) {
|
||||
maxPeak = entry.truePeakLinear;
|
||||
}
|
||||
}
|
||||
final albumLufs = 10.0 * _log10(sumWeightedPower / sumDuration);
|
||||
const replayGainReferenceLufs = -18.0;
|
||||
final albumGainDb = replayGainReferenceLufs - albumLufs;
|
||||
|
||||
final albumGain =
|
||||
'${albumGainDb >= 0 ? "+" : ""}${albumGainDb.toStringAsFixed(2)} dB';
|
||||
final albumPeak = maxPeak.toStringAsFixed(6);
|
||||
|
||||
_log.i(
|
||||
'Album ReplayGain for "$key": gain=$albumGain, peak=$albumPeak (${validEntries.length} tracks, album LUFS=${albumLufs.toStringAsFixed(1)})',
|
||||
);
|
||||
|
||||
for (final entry in validEntries) {
|
||||
try {
|
||||
await _writeAlbumReplayGain(entry.filePath, albumGain, albumPeak);
|
||||
} catch (e) {
|
||||
_log.w('Failed to write album ReplayGain to ${entry.filePath}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
_albumRgData.remove(key);
|
||||
}
|
||||
|
||||
/// Deezer CDN cover size pattern: /WxH-0-0-0-0.jpg
|
||||
static final _deezerSizeRegex = RegExp(r'/(\d+)x(\d+)-\d+-\d+-\d+-\d+\.jpg$');
|
||||
|
||||
@@ -3027,6 +3415,28 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
}
|
||||
}
|
||||
|
||||
// ReplayGain: scan loudness and embed tags via native FLAC writer.
|
||||
if (settings.embedReplayGain) {
|
||||
try {
|
||||
final rgResult = await FFmpegService.scanReplayGain(flacPath);
|
||||
if (rgResult != null) {
|
||||
await PlatformBridge.editFileMetadata(flacPath, {
|
||||
'replaygain_track_gain': rgResult.trackGain,
|
||||
'replaygain_track_peak': rgResult.trackPeak,
|
||||
});
|
||||
_log.d(
|
||||
'ReplayGain tags embedded: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}',
|
||||
);
|
||||
// Store for album gain computation after all album tracks complete.
|
||||
_storeTrackReplayGainForAlbum(track, flacPath, rgResult);
|
||||
} else {
|
||||
_log.w('ReplayGain scan returned no result');
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to scan/embed ReplayGain: $e');
|
||||
}
|
||||
}
|
||||
|
||||
if (coverPath != null) {
|
||||
try {
|
||||
final coverFile = File(coverPath);
|
||||
@@ -3181,6 +3591,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.d('Embedding tags to MP3: $metadata');
|
||||
|
||||
// ReplayGain: scan loudness and add to metadata before FFmpeg embed.
|
||||
if (settings.embedReplayGain) {
|
||||
try {
|
||||
final rgResult = await FFmpegService.scanReplayGain(mp3Path);
|
||||
if (rgResult != null) {
|
||||
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
|
||||
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
|
||||
_log.d(
|
||||
'ReplayGain added to MP3 metadata: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}',
|
||||
);
|
||||
// Store for album gain computation after all album tracks complete.
|
||||
_storeTrackReplayGainForAlbum(track, mp3Path, rgResult);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to scan ReplayGain for MP3: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final result = await FFmpegService.embedMetadataToMp3(
|
||||
mp3Path: mp3Path,
|
||||
coverPath: coverPath != null && await File(coverPath).exists()
|
||||
@@ -3345,6 +3773,24 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
|
||||
_log.d('Embedding tags to Opus: $metadata');
|
||||
|
||||
// ReplayGain: scan loudness and add to metadata before FFmpeg embed.
|
||||
if (settings.embedReplayGain) {
|
||||
try {
|
||||
final rgResult = await FFmpegService.scanReplayGain(opusPath);
|
||||
if (rgResult != null) {
|
||||
metadata['REPLAYGAIN_TRACK_GAIN'] = rgResult.trackGain;
|
||||
metadata['REPLAYGAIN_TRACK_PEAK'] = rgResult.trackPeak;
|
||||
_log.d(
|
||||
'ReplayGain added to Opus metadata: gain=${rgResult.trackGain}, peak=${rgResult.trackPeak}',
|
||||
);
|
||||
// Store for album gain computation after all album tracks complete.
|
||||
_storeTrackReplayGainForAlbum(track, opusPath, rgResult);
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to scan ReplayGain for Opus: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final result = await FFmpegService.embedMetadataToOpus(
|
||||
opusPath: opusPath,
|
||||
coverPath: coverPath != null && await File(coverPath).exists()
|
||||
@@ -5098,6 +5544,23 @@ class DownloadQueueNotifier extends Notifier<DownloadQueueState> {
|
||||
await _runPostProcessingHooks(filePath, trackToDownload);
|
||||
}
|
||||
|
||||
// Album ReplayGain: update the accumulator path to the final file
|
||||
// location. For SAF downloads the metadata was embedded on a temp
|
||||
// copy, so the stored path still points there. Replace it with the
|
||||
// actual output path (SAF content URI or local path) so the later
|
||||
// album-gain writer targets the correct file.
|
||||
if (filePath != null) {
|
||||
_updateAlbumRgFilePath(trackToDownload, filePath);
|
||||
}
|
||||
|
||||
// Album ReplayGain: check if all album tracks are now complete and,
|
||||
// if so, compute and write album gain/peak to every track file.
|
||||
try {
|
||||
await _checkAndWriteAlbumReplayGain(trackToDownload);
|
||||
} catch (e) {
|
||||
_log.w('Album ReplayGain check failed: $e');
|
||||
}
|
||||
|
||||
_completedInSession++;
|
||||
|
||||
final historyNotifier = ref.read(downloadHistoryProvider.notifier);
|
||||
@@ -5426,3 +5889,27 @@ final downloadQueueLookupProvider = Provider<DownloadQueueLookup>((ref) {
|
||||
final items = ref.watch(downloadQueueProvider.select((s) => s.items));
|
||||
return DownloadQueueLookup.fromItems(items);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Album ReplayGain helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _AlbumRgTrackEntry {
|
||||
String filePath;
|
||||
final String trackId;
|
||||
final double integratedLufs;
|
||||
final double truePeakLinear;
|
||||
final double durationSecs;
|
||||
|
||||
_AlbumRgTrackEntry({
|
||||
required this.filePath,
|
||||
required this.trackId,
|
||||
required this.integratedLufs,
|
||||
required this.truePeakLinear,
|
||||
required this.durationSecs,
|
||||
});
|
||||
}
|
||||
|
||||
class _AlbumRgAccumulator {
|
||||
final List<_AlbumRgTrackEntry> entries = [];
|
||||
}
|
||||
|
||||
@@ -232,6 +232,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEmbedReplayGain(bool enabled) {
|
||||
state = state.copyWith(embedReplayGain: enabled);
|
||||
_saveSettings();
|
||||
}
|
||||
|
||||
void setEmbedMetadata(bool enabled) {
|
||||
state = state.copyWith(embedMetadata: enabled);
|
||||
_saveSettings();
|
||||
|
||||
@@ -135,6 +135,18 @@ class OptionsSettingsPage extends ConsumerWidget {
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setMaxQualityCover(v),
|
||||
),
|
||||
SettingsSwitchItem(
|
||||
icon: Icons.graphic_eq,
|
||||
title: context.l10n.optionsReplayGain,
|
||||
subtitle: settings.embedReplayGain
|
||||
? context.l10n.optionsReplayGainSubtitleOn
|
||||
: context.l10n.optionsReplayGainSubtitleOff,
|
||||
value: settings.embedReplayGain,
|
||||
enabled: settings.embedMetadata,
|
||||
onChanged: (v) => ref
|
||||
.read(settingsProvider.notifier)
|
||||
.setEmbedReplayGain(v),
|
||||
showDivider: false,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -3401,18 +3401,13 @@ class _TrackMetadataScreenState extends ConsumerState<TrackMetadataScreen> {
|
||||
for (final path in finalOutputPaths) {
|
||||
if (path.toLowerCase().endsWith('.flac')) {
|
||||
try {
|
||||
final metadata = await PlatformBridge.readFileMetadata(path);
|
||||
if (metadata['error'] == null) {
|
||||
final fields = <String, String>{'cover_path': coverPath};
|
||||
for (final entry in metadata.entries) {
|
||||
if (entry.key == 'error' || entry.value == null) continue;
|
||||
final v = entry.value.toString().trim();
|
||||
if (v.isNotEmpty) {
|
||||
fields[entry.key] = v;
|
||||
}
|
||||
}
|
||||
await PlatformBridge.editFileMetadata(path, fields);
|
||||
}
|
||||
// Only send the cover_path field — EditFlacFields uses
|
||||
// field-presence semantics, so omitting artist/album_artist
|
||||
// means those keys won't be rewritten. This preserves any
|
||||
// existing split artist Vorbis Comments.
|
||||
await PlatformBridge.editFileMetadata(path, {
|
||||
'cover_path': coverPath,
|
||||
});
|
||||
} catch (e) {
|
||||
_log.w('Failed to embed cover to split track: $e');
|
||||
}
|
||||
@@ -4950,48 +4945,32 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
final isOpus = lower.endsWith('.opus') || lower.endsWith('.ogg');
|
||||
final isM4A = lower.endsWith('.m4a') || lower.endsWith('.aac');
|
||||
|
||||
final vorbisMap = <String, String>{};
|
||||
if (metadata['title']?.isNotEmpty == true) {
|
||||
vorbisMap['TITLE'] = metadata['title']!;
|
||||
}
|
||||
if (metadata['artist']?.isNotEmpty == true) {
|
||||
vorbisMap['ARTIST'] = metadata['artist']!;
|
||||
}
|
||||
if (metadata['album']?.isNotEmpty == true) {
|
||||
vorbisMap['ALBUM'] = metadata['album']!;
|
||||
}
|
||||
if (metadata['album_artist']?.isNotEmpty == true) {
|
||||
vorbisMap['ALBUMARTIST'] = metadata['album_artist']!;
|
||||
}
|
||||
if (metadata['date']?.isNotEmpty == true) {
|
||||
vorbisMap['DATE'] = metadata['date']!;
|
||||
}
|
||||
if (metadata['track_number']?.isNotEmpty == true &&
|
||||
metadata['track_number'] != '0') {
|
||||
vorbisMap['TRACKNUMBER'] = metadata['track_number']!;
|
||||
}
|
||||
if (metadata['disc_number']?.isNotEmpty == true &&
|
||||
metadata['disc_number'] != '0') {
|
||||
vorbisMap['DISCNUMBER'] = metadata['disc_number']!;
|
||||
}
|
||||
if (metadata['genre']?.isNotEmpty == true) {
|
||||
vorbisMap['GENRE'] = metadata['genre']!;
|
||||
}
|
||||
if (metadata['isrc']?.isNotEmpty == true) {
|
||||
vorbisMap['ISRC'] = metadata['isrc']!;
|
||||
}
|
||||
if (metadata['label']?.isNotEmpty == true) {
|
||||
vorbisMap['ORGANIZATION'] = metadata['label']!;
|
||||
}
|
||||
if (metadata['copyright']?.isNotEmpty == true) {
|
||||
vorbisMap['COPYRIGHT'] = metadata['copyright']!;
|
||||
}
|
||||
if (metadata['composer']?.isNotEmpty == true) {
|
||||
vorbisMap['COMPOSER'] = metadata['composer']!;
|
||||
}
|
||||
if (metadata['comment']?.isNotEmpty == true) {
|
||||
vorbisMap['COMMENT'] = metadata['comment']!;
|
||||
}
|
||||
// Always include all known fields so -map_metadata 0 + explicit
|
||||
// -metadata flags can both preserve custom tags AND clear fields
|
||||
// the user emptied.
|
||||
final vorbisMap = <String, String>{
|
||||
'TITLE': metadata['title'] ?? '',
|
||||
'ARTIST': metadata['artist'] ?? '',
|
||||
'ALBUM': metadata['album'] ?? '',
|
||||
'ALBUMARTIST': metadata['album_artist'] ?? '',
|
||||
'DATE': metadata['date'] ?? '',
|
||||
'TRACKNUMBER':
|
||||
(metadata['track_number']?.isNotEmpty == true &&
|
||||
metadata['track_number'] != '0')
|
||||
? metadata['track_number']!
|
||||
: '',
|
||||
'DISCNUMBER':
|
||||
(metadata['disc_number']?.isNotEmpty == true &&
|
||||
metadata['disc_number'] != '0')
|
||||
? metadata['disc_number']!
|
||||
: '',
|
||||
'GENRE': metadata['genre'] ?? '',
|
||||
'ISRC': metadata['isrc'] ?? '',
|
||||
'ORGANIZATION': metadata['label'] ?? '',
|
||||
'COPYRIGHT': metadata['copyright'] ?? '',
|
||||
'COMPOSER': metadata['composer'] ?? '',
|
||||
'COMMENT': metadata['comment'] ?? '',
|
||||
};
|
||||
try {
|
||||
final existingMetadata = await PlatformBridge.readFileMetadata(
|
||||
ffmpegTarget,
|
||||
@@ -5001,8 +4980,25 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
vorbisMap['LYRICS'] = existingLyrics;
|
||||
vorbisMap['UNSYNCEDLYRICS'] = existingLyrics;
|
||||
}
|
||||
// Preserve ReplayGain tags if present — these are computed once
|
||||
// during download and should survive manual metadata edits.
|
||||
final rgFields = <String, String>{
|
||||
'REPLAYGAIN_TRACK_GAIN':
|
||||
existingMetadata['replaygain_track_gain']?.toString() ?? '',
|
||||
'REPLAYGAIN_TRACK_PEAK':
|
||||
existingMetadata['replaygain_track_peak']?.toString() ?? '',
|
||||
'REPLAYGAIN_ALBUM_GAIN':
|
||||
existingMetadata['replaygain_album_gain']?.toString() ?? '',
|
||||
'REPLAYGAIN_ALBUM_PEAK':
|
||||
existingMetadata['replaygain_album_peak']?.toString() ?? '',
|
||||
};
|
||||
rgFields.forEach((key, value) {
|
||||
if (value.isNotEmpty) {
|
||||
vorbisMap[key] = value;
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
// Lyrics preservation is best-effort.
|
||||
// Lyrics/ReplayGain preservation is best-effort.
|
||||
}
|
||||
|
||||
String? existingCoverPath = _selectedCoverPath ?? _currentCoverPath;
|
||||
@@ -5036,12 +5032,14 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
mp3Path: ffmpegTarget,
|
||||
coverPath: existingCoverPath,
|
||||
metadata: vorbisMap,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isM4A) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToM4a(
|
||||
m4aPath: ffmpegTarget,
|
||||
coverPath: existingCoverPath,
|
||||
metadata: vorbisMap,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
} else if (isOpus) {
|
||||
ffmpegResult = await FFmpegService.embedMetadataToOpus(
|
||||
@@ -5049,6 +5047,7 @@ class _EditMetadataSheetState extends State<_EditMetadataSheet> {
|
||||
coverPath: existingCoverPath,
|
||||
metadata: vorbisMap,
|
||||
artistTagMode: widget.artistTagMode,
|
||||
preserveMetadata: true,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit.dart';
|
||||
import 'package:ffmpeg_kit_flutter_new_full/ffmpeg_kit_config.dart';
|
||||
@@ -884,6 +885,159 @@ class FFmpegService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan an audio file for EBU R128 loudness and compute ReplayGain values.
|
||||
///
|
||||
/// Uses the FFmpeg `ebur128` audio filter to measure integrated loudness (LUFS)
|
||||
/// and true peak. ReplayGain reference level is -18 LUFS (≈ 89 dB SPL).
|
||||
///
|
||||
/// Returns a [ReplayGainResult] on success, or null if the scan fails.
|
||||
static Future<ReplayGainResult?> scanReplayGain(String filePath) async {
|
||||
// Run FFmpeg with ebur128 filter + astats for true peak.
|
||||
// -nostats suppresses the interactive progress line.
|
||||
// ebur128=peak=true prints integrated loudness + true peak.
|
||||
final command =
|
||||
'-nostats -i "$filePath" -filter_complex ebur128=peak=true -f null -';
|
||||
|
||||
_log.d(
|
||||
'Scanning ReplayGain for: ${filePath.split(Platform.pathSeparator).last}',
|
||||
);
|
||||
final result = await _execute(command);
|
||||
|
||||
// FFmpeg writes ebur128 stats to stderr, which ends up in the output.
|
||||
// Even on "failure" return code, the output may contain valid data
|
||||
// because -f null always "fails" on some FFmpeg builds.
|
||||
final output = result.output;
|
||||
|
||||
// Parse integrated loudness: "I: -14.0 LUFS"
|
||||
final integratedMatch = RegExp(
|
||||
r'I:\s+(-?\d+\.?\d*)\s+LUFS',
|
||||
).allMatches(output);
|
||||
if (integratedMatch.isEmpty) {
|
||||
_log.w('ReplayGain scan: could not parse integrated loudness');
|
||||
return null;
|
||||
}
|
||||
// Take the last match (the summary, not per-segment values)
|
||||
final integratedLufs = double.tryParse(integratedMatch.last.group(1) ?? '');
|
||||
if (integratedLufs == null) {
|
||||
_log.w('ReplayGain scan: invalid integrated loudness value');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse true peak: "Peak: 0.9 dBFS" or "True peak:\n Peak: -0.3 dBFS"
|
||||
// The ebur128 filter with peak=true outputs per-channel true peak.
|
||||
// We want the highest (maximum) true peak across all channels.
|
||||
double? truePeakDbfs;
|
||||
final peakMatches = RegExp(
|
||||
r'Peak:\s+(-?\d+\.?\d*)\s+dBFS',
|
||||
).allMatches(output);
|
||||
for (final m in peakMatches) {
|
||||
final val = double.tryParse(m.group(1) ?? '');
|
||||
if (val != null) {
|
||||
if (truePeakDbfs == null || val > truePeakDbfs) {
|
||||
truePeakDbfs = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReplayGain reference level: -18 LUFS
|
||||
const replayGainReferenceLufs = -18.0;
|
||||
final gainDb = replayGainReferenceLufs - integratedLufs;
|
||||
|
||||
// Convert true peak from dBFS to linear ratio.
|
||||
// If no true peak was found, fall back to 1.0 (0 dBFS).
|
||||
double peakLinear;
|
||||
if (truePeakDbfs != null) {
|
||||
// 10^(dBFS/20) converts dBFS to linear amplitude
|
||||
peakLinear = math.pow(10, truePeakDbfs / 20.0).toDouble();
|
||||
} else {
|
||||
peakLinear = 1.0;
|
||||
}
|
||||
|
||||
// Format to standard ReplayGain precision
|
||||
final trackGain =
|
||||
'${gainDb >= 0 ? "+" : ""}${gainDb.toStringAsFixed(2)} dB';
|
||||
final trackPeak = peakLinear.toStringAsFixed(6);
|
||||
|
||||
_log.i(
|
||||
'ReplayGain scan result: gain=$trackGain, peak=$trackPeak (integrated=${integratedLufs.toStringAsFixed(1)} LUFS)',
|
||||
);
|
||||
|
||||
return ReplayGainResult(
|
||||
trackGain: trackGain,
|
||||
trackPeak: trackPeak,
|
||||
integratedLufs: integratedLufs,
|
||||
truePeakLinear: peakLinear,
|
||||
);
|
||||
}
|
||||
|
||||
/// Write album ReplayGain tags to a non-FLAC file (MP3/Opus) using FFmpeg.
|
||||
/// Preserves all existing metadata and adds/overwrites album gain fields.
|
||||
/// Write album ReplayGain tags to a file via FFmpeg.
|
||||
///
|
||||
/// For local files, replaces the file in-place and returns `true`.
|
||||
/// When [returnTempPath] is `true` (for SAF content:// URIs), the method
|
||||
/// skips the file replacement and returns the temp output path as a String
|
||||
/// via [tempOutputPath]. The caller is responsible for writing the temp
|
||||
/// file to the SAF URI and cleaning it up.
|
||||
static Future<bool> writeAlbumReplayGainTags(
|
||||
String filePath,
|
||||
String albumGain,
|
||||
String albumPeak, {
|
||||
bool returnTempPath = false,
|
||||
void Function(String tempPath)? onTempReady,
|
||||
}) async {
|
||||
final ext = filePath.contains('.')
|
||||
? '.${filePath.split('.').last}'
|
||||
: '.tmp';
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, ext);
|
||||
|
||||
final sanitizedGain = albumGain.replaceAll('"', '\\"');
|
||||
final sanitizedPeak = albumPeak.replaceAll('"', '\\"');
|
||||
|
||||
// -map_metadata 0 preserves all existing metadata from the input.
|
||||
// -metadata flags add/overwrite only the specified keys.
|
||||
final command =
|
||||
'-i "$filePath" -map 0 -c copy -map_metadata 0 '
|
||||
'-metadata REPLAYGAIN_ALBUM_GAIN="$sanitizedGain" '
|
||||
'-metadata REPLAYGAIN_ALBUM_PEAK="$sanitizedPeak" '
|
||||
'"$tempOutput" -y';
|
||||
|
||||
_log.d('Writing album ReplayGain tags via FFmpeg');
|
||||
final result = await _execute(command);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) {
|
||||
if (returnTempPath) {
|
||||
// Caller will handle SAF write-back and cleanup.
|
||||
onTempReady?.call(tempOutput);
|
||||
return true;
|
||||
}
|
||||
final originalFile = File(filePath);
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(filePath);
|
||||
await tempFile.delete();
|
||||
_log.d('Album ReplayGain tags written successfully');
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to replace file with album ReplayGain: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup temp file on failure
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) await tempFile.delete();
|
||||
} catch (_) {}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<String?> embedMetadata({
|
||||
required String flacPath,
|
||||
String? coverPath,
|
||||
@@ -1574,7 +1728,6 @@ class FFmpegService {
|
||||
for (final entry in metadata.entries) {
|
||||
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final value = entry.value;
|
||||
if (value.trim().isEmpty) continue;
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
@@ -1628,6 +1781,19 @@ class FFmpegService {
|
||||
vorbis['LYRICS'] = value;
|
||||
vorbis['UNSYNCEDLYRICS'] = value;
|
||||
break;
|
||||
// ReplayGain fields
|
||||
case 'REPLAYGAINTRACKGAIN':
|
||||
vorbis['REPLAYGAIN_TRACK_GAIN'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINTRACKPEAK':
|
||||
vorbis['REPLAYGAIN_TRACK_PEAK'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMGAIN':
|
||||
vorbis['REPLAYGAIN_ALBUM_GAIN'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMPEAK':
|
||||
vorbis['REPLAYGAIN_ALBUM_PEAK'] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1699,8 +1865,12 @@ class FFmpegService {
|
||||
String? rawValue, {
|
||||
String artistTagMode = artistTagModeJoined,
|
||||
}) {
|
||||
final value = rawValue?.trim() ?? '';
|
||||
if (rawValue == null) return;
|
||||
final value = rawValue.trim();
|
||||
if (value.isEmpty) {
|
||||
// Emit an empty entry so that with preserveMetadata the old tag is
|
||||
// overridden (cleared) by FFmpeg's `-metadata key=""`.
|
||||
entries.add(MapEntry(key, ''));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1721,7 +1891,6 @@ class FFmpegService {
|
||||
for (final entry in metadata.entries) {
|
||||
final key = entry.key.toUpperCase().replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final value = entry.value;
|
||||
if (value.trim().isEmpty) continue;
|
||||
|
||||
switch (key) {
|
||||
case 'TITLE':
|
||||
@@ -1773,6 +1942,19 @@ class FFmpegService {
|
||||
case 'UNSYNCEDLYRICS':
|
||||
m4aMap['lyrics'] = value;
|
||||
break;
|
||||
// ReplayGain as iTunes freeform atoms (com.apple.iTunes:replaygain_*)
|
||||
case 'REPLAYGAINTRACKGAIN':
|
||||
m4aMap['REPLAYGAIN_TRACK_GAIN'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINTRACKPEAK':
|
||||
m4aMap['REPLAYGAIN_TRACK_PEAK'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMGAIN':
|
||||
m4aMap['REPLAYGAIN_ALBUM_GAIN'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMPEAK':
|
||||
m4aMap['REPLAYGAIN_ALBUM_PEAK'] = value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1788,9 +1970,6 @@ class FFmpegService {
|
||||
final key = entry.key.toUpperCase();
|
||||
final normalizedKey = key.replaceAll(RegExp(r'[^A-Z0-9]'), '');
|
||||
final value = entry.value;
|
||||
if (value.trim().isEmpty) {
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (normalizedKey) {
|
||||
case 'TITLE':
|
||||
@@ -1836,6 +2015,20 @@ class FFmpegService {
|
||||
case 'COMMENT':
|
||||
id3Map['comment'] = value;
|
||||
break;
|
||||
// ReplayGain as TXXX user-defined frames
|
||||
// FFmpeg writes these as TXXX frames automatically with uppercase keys
|
||||
case 'REPLAYGAINTRACKGAIN':
|
||||
id3Map['REPLAYGAIN_TRACK_GAIN'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINTRACKPEAK':
|
||||
id3Map['REPLAYGAIN_TRACK_PEAK'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMGAIN':
|
||||
id3Map['REPLAYGAIN_ALBUM_GAIN'] = value;
|
||||
break;
|
||||
case 'REPLAYGAINALBUMPEAK':
|
||||
id3Map['REPLAYGAIN_ALBUM_PEAK'] = value;
|
||||
break;
|
||||
default:
|
||||
id3Map[key.toLowerCase()] = value;
|
||||
}
|
||||
@@ -2021,3 +2214,29 @@ class LiveDecryptedStreamResult {
|
||||
required this.session,
|
||||
});
|
||||
}
|
||||
|
||||
/// Result of an EBU R128 loudness scan, used to compute ReplayGain tags.
|
||||
class ReplayGainResult {
|
||||
/// Track gain in dB, e.g. "-6.50 dB"
|
||||
final String trackGain;
|
||||
|
||||
/// Track peak as a linear ratio, e.g. "0.988831"
|
||||
final String trackPeak;
|
||||
|
||||
/// Raw integrated loudness in LUFS (needed for album gain computation)
|
||||
final double integratedLufs;
|
||||
|
||||
/// Raw true peak as linear ratio (needed for album peak computation)
|
||||
final double truePeakLinear;
|
||||
|
||||
const ReplayGainResult({
|
||||
required this.trackGain,
|
||||
required this.trackPeak,
|
||||
required this.integratedLufs,
|
||||
required this.truePeakLinear,
|
||||
});
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'ReplayGainResult(trackGain: $trackGain, trackPeak: $trackPeak)';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user