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:
zarzet
2026-06-12 01:59:26 +07:00
parent adea3de737
commit 2a2e2924eb
27 changed files with 1816 additions and 165 deletions
+65
View File
@@ -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,
+104
View File
@@ -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 (_) {}
}
}
}
}