fix(playback): retain ReplayGain for SAF descriptor leases

Carry the original display name through playback leases to format-aware metadata reads. Cache successful normalization results by source identity, bound the cache, and retry transient read failures.

Add regressions for descriptor hints, album gain fallback, and failed-read retries.
This commit is contained in:
zarzet
2026-09-06 14:04:04 +07:00
parent b32bdbc291
commit 3d35058b02
5 changed files with 155 additions and 36 deletions
+14 -34
View File
@@ -8,6 +8,7 @@ import 'package:audio_session/audio_session.dart'
import 'package:audioplayers/audioplayers.dart';
import 'package:spotiflac_android/services/app_state_database.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/playback_normalization.dart';
import 'package:spotiflac_android/utils/int_utils.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:spotiflac_android/utils/string_utils.dart';
@@ -550,46 +551,23 @@ class MusicPlayerHandler extends BaseAudioHandler
unawaited(_persistSession(position: position));
}
// ReplayGain normalization: resolved path -> volume multiplier.
final Map<String, double> _normalizationVolumeCache = {};
final _normalizationCache = PlaybackNormalizationCache(
readMetadata: PlatformBridge.readFileMetadata,
onReadError: (error) =>
_log.w('Failed to read gain tags for normalization: $error'),
);
/// Volume multiplier from the file's ReplayGain/R128 tags (track gain,
/// album gain fallback; Opus R128 tags are converted to ReplayGain dB by
/// the Go reader). 1.0 when disabled, untagged, or unreadable. Positive
/// gains clamp at 1.0 — setVolume can only attenuate.
Future<double> _normalizationVolumeFor(
String path, {
String? cacheKey,
String? displayName,
}) async {
if (!_playbackNormalizationEnabled) return 1.0;
final effectiveCacheKey = cacheKey ?? path;
final cached = _normalizationVolumeCache[effectiveCacheKey];
if (cached != null) return cached;
var volume = 1.0;
try {
final metadata = await PlatformBridge.readFileMetadata(path);
final gainDb =
_parseGainDb(metadata['replaygain_track_gain']) ??
_parseGainDb(metadata['replaygain_album_gain']);
if (gainDb != null) {
volume = pow(10.0, gainDb / 20.0).toDouble().clamp(0.0, 1.0);
}
} catch (e) {
_log.w('Failed to read gain tags for normalization: $e');
}
if (_normalizationVolumeCache.length > 128) {
_normalizationVolumeCache.clear();
}
_normalizationVolumeCache[effectiveCacheKey] = volume;
return volume;
}
static double? _parseGainDb(Object? raw) {
final text = raw?.toString();
if (text == null || text.isEmpty) return null;
final match = RegExp(r'-?\d+(\.\d+)?').firstMatch(text);
return match == null ? null : double.tryParse(match.group(0)!);
return _normalizationCache.volumeFor(
path,
cacheKey: cacheKey,
displayName: displayName,
);
}
/// Re-applies normalization to the playing track when the setting flips.
@@ -616,6 +594,7 @@ class MusicPlayerHandler extends BaseAudioHandler
final volume = await _normalizationVolumeFor(
resolved,
cacheKey: media.isContentUri ? media.source : null,
displayName: playbackLease?.displayName,
);
if (playbackLease != null) {
await PlatformBridge.closeContentUriPlaybackLease(playbackLease.token);
@@ -1072,6 +1051,7 @@ class MusicPlayerHandler extends BaseAudioHandler
var normalizationVolume = await _normalizationVolumeFor(
resolved,
cacheKey: media.isContentUri ? media.source : null,
displayName: playbackLease?.displayName,
);
if (!_isCurrentPlayRequest(generation, media)) return;
await _player.setAudioContext(_musicAudioContext);
+12 -2
View File
@@ -88,7 +88,13 @@ class ContentUriPlaybackLease {
final String path;
final String token;
const ContentUriPlaybackLease({required this.path, required this.token});
final String? displayName;
const ContentUriPlaybackLease({
required this.path,
required this.token,
this.displayName,
});
}
class InstallationState {
@@ -845,7 +851,11 @@ class PlatformBridge {
final path = map['path']?.toString() ?? '';
final token = map['token']?.toString() ?? '';
if (path.isEmpty || token.isEmpty) return null;
return ContentUriPlaybackLease(path: path, token: token);
return ContentUriPlaybackLease(
path: path,
token: token,
displayName: result['display_name'] as String?,
);
}
static Future<void> closeContentUriPlaybackLease(String token) async {
+47
View File
@@ -0,0 +1,47 @@
import 'dart:math';
/// Caches successful tag reads by stable media identity. Descriptor paths are
/// short-lived and require their display-name hint to select the audio parser.
class PlaybackNormalizationCache {
final Future<Map<String, dynamic>> Function(String, {String? displayName})
readMetadata;
final void Function(Object)? onReadError;
final Map<String, double> _volumes = {};
static final _gainNumber = RegExp(r'-?\d+(\.\d+)?');
PlaybackNormalizationCache({required this.readMetadata, this.onReadError});
Future<double> volumeFor(
String path, {
String? cacheKey,
String? displayName,
}) async {
final key = cacheKey ?? path;
final cached = _volumes[key];
if (cached != null) return cached;
try {
final metadata = await readMetadata(path, displayName: displayName);
if (metadata['error'] != null) {
onReadError?.call(metadata['error'] as Object);
return 1.0;
}
final gain =
_gain(metadata['replaygain_track_gain']) ??
_gain(metadata['replaygain_album_gain']);
final volume = gain == null
? 1.0
: pow(10.0, gain / 20.0).toDouble().clamp(0.0, 1.0);
if (_volumes.length >= 128) _volumes.remove(_volumes.keys.first);
_volumes[key] = volume;
return volume;
} catch (error) {
onReadError?.call(error);
return 1.0;
}
}
static double? _gain(Object? value) {
final match = _gainNumber.firstMatch(value?.toString() ?? '');
return match == null ? null : double.tryParse(match.group(0)!);
}
}