mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
216 lines
6.5 KiB
Dart
216 lines
6.5 KiB
Dart
import 'dart:io';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:spotiflac_android/services/ffmpeg_service.dart';
|
|
import 'package:spotiflac_android/services/music_player_service.dart';
|
|
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.
|
|
///
|
|
/// Computes EBU R128 loudness via FFmpeg and writes gain tags using the native
|
|
/// metadata editors where supported. Opus uses R128_* rather than legacy tags.
|
|
///
|
|
/// Handles SAF content:// URIs transparently by working on a temporary copy
|
|
/// and writing it back to the original document.
|
|
class ReplayGainService {
|
|
ReplayGainService._();
|
|
|
|
static final _log = AppLogger('ReplayGain');
|
|
|
|
static const _nativeExtensions = <String>{
|
|
'.flac',
|
|
'.m4a',
|
|
'.mp4',
|
|
'.m4b',
|
|
'.ape',
|
|
'.wv',
|
|
'.mpc',
|
|
'.wav',
|
|
'.aiff',
|
|
'.aif',
|
|
'.aifc',
|
|
'.mp3',
|
|
'.opus',
|
|
'.ogg',
|
|
};
|
|
|
|
static bool _isNativeWritableFormat(String path) {
|
|
final lower = path.toLowerCase();
|
|
return _nativeExtensions.any(lower.endsWith);
|
|
}
|
|
|
|
/// Scans [filePath] for loudness and writes track ReplayGain tags in place.
|
|
///
|
|
/// Returns `true` when tags were successfully written, `false` otherwise
|
|
/// (scan failed, write failed, or SAF write-back failed).
|
|
static Future<bool> applyToFile(
|
|
String filePath, {
|
|
@visibleForTesting Future<ReplayGainResult?> Function(String)? scan,
|
|
void Function()? onUnsupportedDecoder,
|
|
}) async =>
|
|
await scanAndApplyToFile(
|
|
filePath,
|
|
scan: scan,
|
|
onUnsupportedDecoder: onUnsupportedDecoder,
|
|
) !=
|
|
null;
|
|
|
|
/// Returns the scan for album aggregation only after a verified save.
|
|
static Future<ReplayGainResult?> scanAndApplyToFile(
|
|
String filePath, {
|
|
@visibleForTesting Future<ReplayGainResult?> Function(String)? scan,
|
|
void Function()? onUnsupportedDecoder,
|
|
}) async {
|
|
ReplayGainResult? scanned;
|
|
final written = await _updateFile(filePath, (workingPath) async {
|
|
final rg = scan != null
|
|
? await scan(workingPath)
|
|
: await FFmpegService.scanReplayGain(
|
|
workingPath,
|
|
onUnsupportedDecoder: onUnsupportedDecoder,
|
|
);
|
|
if (rg == null) {
|
|
_log.w('ReplayGain scan returned no result for $workingPath');
|
|
return false;
|
|
}
|
|
scanned = rg;
|
|
return _writeLocalTags(
|
|
workingPath,
|
|
rg.trackGain,
|
|
rg.trackPeak,
|
|
album: false,
|
|
);
|
|
});
|
|
return written ? scanned : null;
|
|
}
|
|
|
|
static Future<bool> writeTrackTags(
|
|
String filePath,
|
|
String gain,
|
|
String peak,
|
|
) => _updateFile(
|
|
filePath,
|
|
(path) => _writeLocalTags(path, gain, peak, album: false),
|
|
);
|
|
|
|
static Future<bool> writeAlbumTags(
|
|
String filePath,
|
|
String gain,
|
|
String peak,
|
|
) => _updateFile(
|
|
filePath,
|
|
(path) => _writeLocalTags(path, gain, peak, album: true),
|
|
);
|
|
|
|
static Future<bool> _writeLocalTags(
|
|
String path,
|
|
String gain,
|
|
String peak, {
|
|
required bool album,
|
|
}) async {
|
|
final scope = album ? 'album' : 'track';
|
|
var written = false;
|
|
if (_isNativeWritableFormat(path)) {
|
|
final result = await PlatformBridge.editFileMetadata(path, {
|
|
'replaygain_${scope}_gain': gain,
|
|
'replaygain_${scope}_peak': peak,
|
|
});
|
|
written =
|
|
result['success'] == true &&
|
|
result['error'] == null &&
|
|
result['method'] is String &&
|
|
(result['method'] == 'native' ||
|
|
(result['method'] as String).startsWith('native_'));
|
|
if (!written) {
|
|
_log.w('Native $scope ReplayGain write did not complete: $result');
|
|
}
|
|
}
|
|
|
|
if (!written) {
|
|
// Remuxing all streams rejects attached Opus artwork; mapping only audio
|
|
// would discard it. Keep the original if its native editor cannot handle
|
|
// the file, rather than silently losing artwork or reporting a no-op.
|
|
final lower = path.toLowerCase();
|
|
if (lower.endsWith('.opus') || lower.endsWith('.ogg')) return false;
|
|
written = album
|
|
? await FFmpegService.writeAlbumReplayGainTags(path, gain, peak)
|
|
: await FFmpegService.writeTrackReplayGainTags(path, gain, peak);
|
|
}
|
|
if (!written) return false;
|
|
|
|
final metadata = await PlatformBridge.readFileMetadata(path);
|
|
final expectedGain = _gainDb(gain);
|
|
final actualGain = _gainDb(metadata['replaygain_${scope}_gain']);
|
|
final expectedPeak = double.tryParse(peak);
|
|
final actualPeak = double.tryParse(
|
|
metadata['replaygain_${scope}_peak']?.toString() ?? '',
|
|
);
|
|
final isOpus = metadata['audio_codec'] == 'opus';
|
|
final verified =
|
|
metadata['error'] == null &&
|
|
expectedGain != null &&
|
|
actualGain != null &&
|
|
(actualGain - expectedGain).abs() <= 0.01 &&
|
|
(isOpus ||
|
|
(expectedPeak != null &&
|
|
actualPeak != null &&
|
|
(actualPeak - expectedPeak).abs() <= 0.000001));
|
|
if (!verified) {
|
|
_log.w('$scope ReplayGain verification failed after writing $path');
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
static double? _gainDb(Object? value) => double.tryParse(
|
|
(value?.toString() ?? '').replaceFirst(RegExp(r'\s*dB\s*$'), '').trim(),
|
|
);
|
|
|
|
static Future<bool> _updateFile(
|
|
String filePath,
|
|
Future<bool> Function(String) update,
|
|
) async {
|
|
if (filePath.isEmpty) return false;
|
|
|
|
final isSaf = isContentUri(filePath);
|
|
var workingPath = filePath;
|
|
String? safTempPath;
|
|
|
|
try {
|
|
if (isSaf) {
|
|
safTempPath = await PlatformBridge.copyContentUriToTemp(filePath);
|
|
if (safTempPath == null || safTempPath.isEmpty) {
|
|
_log.w('Failed to copy SAF file to temp for ReplayGain scan');
|
|
return false;
|
|
}
|
|
workingPath = safTempPath;
|
|
}
|
|
|
|
if (!await update(workingPath)) return false;
|
|
|
|
if (isSaf) {
|
|
final ok = await PlatformBridge.writeTempToSaf(workingPath, filePath);
|
|
if (!ok) {
|
|
_log.w('Failed to write ReplayGain temp file back to SAF document');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
refreshPlaybackNormalization(filePath);
|
|
_log.i('ReplayGain tags written and verified: $filePath');
|
|
return true;
|
|
} catch (e) {
|
|
_log.e('Failed to apply ReplayGain', e);
|
|
return false;
|
|
} finally {
|
|
if (safTempPath != null) {
|
|
try {
|
|
await File(safTempPath).delete();
|
|
} catch (_) {}
|
|
}
|
|
}
|
|
}
|
|
}
|