mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-08-14 23:20:25 +02:00
feat(lyrics,replaygain): add LyricsPlus provider and ReplayGain batch scanning
LyricsPlus (KPOE): word-by-word synced lyrics with multi-server failover, converted to enhanced LRC. ReplayGain: standalone EBU R128 (re)scan writing REPLAYGAIN_TRACK_* tags via native writers or FFmpeg, with batch action in queue/album screens and SAF support.
This commit is contained in:
@@ -1292,6 +1292,71 @@ class FFmpegService {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Write track ReplayGain tags to a file via FFmpeg, replacing it in place.
|
||||
///
|
||||
/// Used for formats that are not handled by the native tag writers
|
||||
/// (MP3/Opus). All existing streams and metadata are preserved via
|
||||
/// `-map 0 -c copy -map_metadata 0`; only the REPLAYGAIN_TRACK_* fields are
|
||||
/// added/overwritten. Returns `true` when the file was rewritten in place.
|
||||
static Future<bool> writeTrackReplayGainTags(
|
||||
String filePath,
|
||||
String trackGain,
|
||||
String trackPeak,
|
||||
) async {
|
||||
final ext = filePath.contains('.')
|
||||
? '.${filePath.split('.').last}'
|
||||
: '.tmp';
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final tempOutput = _nextTempEmbedPath(tempDir.path, ext);
|
||||
final arguments = <String>[
|
||||
'-v',
|
||||
'error',
|
||||
'-hide_banner',
|
||||
'-i',
|
||||
filePath,
|
||||
'-map',
|
||||
'0',
|
||||
'-c',
|
||||
'copy',
|
||||
'-map_metadata',
|
||||
'0',
|
||||
'-metadata',
|
||||
'REPLAYGAIN_TRACK_GAIN=$trackGain',
|
||||
'-metadata',
|
||||
'REPLAYGAIN_TRACK_PEAK=$trackPeak',
|
||||
tempOutput,
|
||||
'-y',
|
||||
];
|
||||
|
||||
_log.d('Writing track ReplayGain tags via FFmpeg');
|
||||
final result = await _executeWithArguments(arguments);
|
||||
|
||||
if (result.success) {
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) {
|
||||
final originalFile = File(filePath);
|
||||
if (await originalFile.exists()) {
|
||||
await originalFile.delete();
|
||||
}
|
||||
await tempFile.copy(filePath);
|
||||
await tempFile.delete();
|
||||
_log.d('Track ReplayGain tags written successfully');
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
_log.w('Failed to replace file with track ReplayGain: $e');
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
final tempFile = File(tempOutput);
|
||||
if (await tempFile.exists()) await tempFile.delete();
|
||||
} catch (_) {}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static Future<String?> embedMetadata({
|
||||
required String flacPath,
|
||||
String? coverPath,
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:spotiflac_android/services/ffmpeg_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 REPLAYGAIN_TRACK_* tags
|
||||
/// back into the file in place:
|
||||
/// - FLAC / M4A / MP4 / APE / WV / MPC -> native tag writer (PlatformBridge)
|
||||
/// - MP3 / Opus / OGG / others -> FFmpeg copy-with-metadata
|
||||
///
|
||||
/// 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',
|
||||
};
|
||||
|
||||
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) 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;
|
||||
}
|
||||
|
||||
final rg = await FFmpegService.scanReplayGain(workingPath);
|
||||
if (rg == null) {
|
||||
_log.w('ReplayGain scan returned no result for $workingPath');
|
||||
return false;
|
||||
}
|
||||
|
||||
bool written;
|
||||
if (_isNativeWritableFormat(workingPath)) {
|
||||
final result = await PlatformBridge.editFileMetadata(workingPath, {
|
||||
'replaygain_track_gain': rg.trackGain,
|
||||
'replaygain_track_peak': rg.trackPeak,
|
||||
});
|
||||
written = result['error'] == null;
|
||||
if (!written) {
|
||||
_log.w('Native ReplayGain write failed: ${result['error']}');
|
||||
}
|
||||
} else {
|
||||
written = await FFmpegService.writeTrackReplayGainTags(
|
||||
workingPath,
|
||||
rg.trackGain,
|
||||
rg.trackPeak,
|
||||
);
|
||||
}
|
||||
|
||||
if (!written) 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 ok;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e) {
|
||||
_log.e('Failed to apply ReplayGain', e);
|
||||
return false;
|
||||
} finally {
|
||||
if (safTempPath != null) {
|
||||
try {
|
||||
await File(safTempPath).delete();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user