diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioNormalizationEffect.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioNormalizationEffect.kt new file mode 100644 index 00000000..c1cc9327 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/AudioNormalizationEffect.kt @@ -0,0 +1,173 @@ +package com.edde746.plezy.exoplayer + +import android.media.audiofx.DynamicsProcessing +import android.media.audiofx.LoudnessEnhancer +import android.os.Build +import androidx.annotation.RequiresApi + +/** + * Session-bound loudness normalization approximating mpv's + * `loudnorm=I=-14:TP=-3:LRA=4` filter (#1289). + * + * API 28+: DynamicsProcessing with a single full-range compressor band as a + * slow AGC plus a limiter for the true-peak ceiling. API 25-27, or when the + * device lacks the DynamicsProcessing effect HAL: LoudnessEnhancer (fixed + * makeup gain with built-in limiting). + * + * Effects bind to the audio session, so they survive AudioTrack re-creation + * (seeks, format changes) while the session id is stable. They only process + * PCM mixer streams — the caller must force decoded PCM output and disable + * tunneling while enabled. DynamicsProcessing parameters are per-channel, so + * the effect is re-created when the output channel count changes (dialogue + * lives in the center channel of 5.1 PCM). + */ +class AudioNormalizationEffect(private val log: (String, String, String) -> Unit) { + + private companion object { + const val EFFECT_PRIORITY = 0 + + // DynamicsProcessing — tuned for movie mixes whose dialogue sits around + // -27 LUFS: compress above threshold, make up toward I=-14, limit at TP=-3. + const val FRAME_DURATION_MS = 10f + const val MBC_CUTOFF_HZ = 20_000f + const val MBC_ATTACK_MS = 30f + const val MBC_RELEASE_MS = 300f + const val MBC_RATIO = 4f + const val MBC_THRESHOLD_DB = -34f + const val MBC_KNEE_DB = 6f + const val MBC_NOISE_GATE_DB = -90f // effectively disabled + const val MBC_EXPANDER_RATIO = 1f // no downward expansion + const val MBC_POST_GAIN_DB = 15f + const val LIMITER_ATTACK_MS = 1f + const val LIMITER_RELEASE_MS = 60f + const val LIMITER_RATIO = 10f + const val LIMITER_THRESHOLD_DB = -3f + const val DEFAULT_CHANNEL_COUNT = 2 + const val MAX_CHANNEL_COUNT = 8 + + // LoudnessEnhancer fallback: fixed boost, internally limited. + const val LOUDNESS_ENHANCER_GAIN_MB = 900 // +9 dB + } + + private var dynamicsProcessing: DynamicsProcessing? = null + private var loudnessEnhancer: LoudnessEnhancer? = null + private var attachedSessionId = 0 + private var attachedChannelCount = 0 + + val isActive: Boolean get() = dynamicsProcessing != null || loudnessEnhancer != null + + /** For stats/QA: which engine is processing. */ + val describe: String + get() = when { + dynamicsProcessing != null -> "DynamicsProcessing" + loudnessEnhancer != null -> "LoudnessEnhancer" + else -> "off" + } + + /** Attach to [sessionId]; idempotent for an unchanged (session, channels) pair. */ + fun attach(sessionId: Int, channelCount: Int?) { + val channels = (channelCount ?: DEFAULT_CHANNEL_COUNT).coerceIn(1, MAX_CHANNEL_COUNT) + if (sessionId == attachedSessionId && channels == attachedChannelCount && isActive) return + release() + if (sessionId == 0) return // AUDIO_SESSION_ID_UNSET — retry on onAudioSessionIdChanged + attachedSessionId = sessionId + attachedChannelCount = channels + if (Build.VERSION.SDK_INT >= 28 && tryDynamicsProcessing(sessionId, channels)) return + tryLoudnessEnhancer(sessionId) + } + + fun release() { + dynamicsProcessing?.let { effect -> + runCatching { effect.setEnabled(false) } + runCatching { effect.release() } + } + dynamicsProcessing = null + loudnessEnhancer?.let { effect -> + runCatching { effect.setEnabled(false) } + runCatching { effect.release() } + } + loudnessEnhancer = null + attachedSessionId = 0 + attachedChannelCount = 0 + } + + @RequiresApi(28) + private fun tryDynamicsProcessing(sessionId: Int, channelCount: Int): Boolean = try { + val band = DynamicsProcessing.MbcBand( + /* inUse = */ true, + MBC_CUTOFF_HZ, + MBC_ATTACK_MS, + MBC_RELEASE_MS, + MBC_RATIO, + MBC_THRESHOLD_DB, + MBC_KNEE_DB, + MBC_NOISE_GATE_DB, + MBC_EXPANDER_RATIO, + /* preGain = */ 0f, + MBC_POST_GAIN_DB + ) + val mbc = DynamicsProcessing.Mbc(/* inUse = */ true, /* enabled = */ true, /* bandCount = */ 1) + .apply { setBand(0, band) } + val limiter = DynamicsProcessing.Limiter( + /* inUse = */ true, + /* enabled = */ true, + /* linkGroup = */ 0, + LIMITER_ATTACK_MS, + LIMITER_RELEASE_MS, + LIMITER_RATIO, + LIMITER_THRESHOLD_DB, + /* postGain = */ 0f + ) + val channel = DynamicsProcessing.Channel( + /* inputGain = */ 0f, + /* preEqInUse = */ false, /* preEqBandCount = */ 0, + /* mbcInUse = */ true, /* mbcBandCount = */ 1, + /* postEqInUse = */ false, /* postEqBandCount = */ 0, + /* limiterInUse = */ true + ).apply { + setMbc(mbc) + setLimiter(limiter) + } + val config = DynamicsProcessing.Config.Builder( + DynamicsProcessing.VARIANT_FAVOR_FREQUENCY_RESOLUTION, + channelCount, + /* preEqInUse = */ false, /* preEqBandCount = */ 0, + /* mbcInUse = */ true, /* mbcBandCount = */ 1, + /* postEqInUse = */ false, /* postEqBandCount = */ 0, + /* limiterInUse = */ true + ) + .setPreferredFrameDuration(FRAME_DURATION_MS) + .setAllChannelsTo(channel) + .build() + dynamicsProcessing = DynamicsProcessing(EFFECT_PRIORITY, sessionId, config).apply { setEnabled(true) } + log("info", "audio-normalization", "DynamicsProcessing attached (session=$sessionId, channels=$channelCount)") + true + } catch (e: Exception) { + log( + "warn", + "audio-normalization", + "DynamicsProcessing unavailable (${e.javaClass.simpleName}: ${e.message}); trying LoudnessEnhancer" + ) + dynamicsProcessing = null + false + } + + private fun tryLoudnessEnhancer(sessionId: Int) { + try { + loudnessEnhancer = LoudnessEnhancer(sessionId).apply { + setTargetGain(LOUDNESS_ENHANCER_GAIN_MB) + setEnabled(true) + } + log("info", "audio-normalization", "LoudnessEnhancer attached (session=$sessionId, gain=${LOUDNESS_ENHANCER_GAIN_MB}mB)") + } catch (e: Exception) { + loudnessEnhancer = null + attachedSessionId = 0 + attachedChannelCount = 0 + log( + "warn", + "audio-normalization", + "No loudness effect available on this device (${e.javaClass.simpleName}: ${e.message})" + ) + } + } +} diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 194c0e5a..0c540b83 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -96,6 +96,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private const val DECODER_HANG_TIMEOUT_MS = 5000L private const val MAX_AUDIO_RECOVERY_ATTEMPTS = 2 private const val FPS_SAMPLE_COUNT = 8 + private const val AUDIO_BOUNCE_TIMEOUT_MS = 1000L /** Per-frame "video is at X" logcat stream (tag AssFrameCb) for diagnosing * ASS subtitle lag against the libass pipeline's render/swap lines. */ @@ -153,6 +154,14 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private val tunnelingDisabledForCodec: Boolean get() = tunnelingDisabledForAudioCodec || tunnelingDisabledForVideoCodec || tunnelingDisabledForDecodedTrueHdPcm || tunnelingDisabledForAudioRecovery private var currentTunneledPlayback: Boolean = false + + // Loudness normalization (#1289): audiofx effects only process non-tunneled + // PCM mixer streams, so while enabled we block direct/bitstream output and + // disable tunneling. + private var audioNormalizationEnabled: Boolean = false + private val audioNormalization = AudioNormalizationEffect(::emitLog) + private var pendingAudioRendererBounce: Boolean = false + private val audioBounceTimeout = Runnable { completeAudioRendererBounce("audio-normalization bounce timeout") } private var lastSeekable: Boolean? = null @Volatile private var disposing: Boolean = false @@ -915,6 +924,14 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { return } + // Audio renderer bounce (loudness normalization): the playback thread has + // observed the disabled audio renderer — re-enable so selection re-queries + // the sink's direct-output verdict. + if (pendingAudioRendererBounce && tracks.groups.none { it.type == C.TRACK_TYPE_AUDIO && it.isSelected }) { + completeAudioRendererBounce("audio-normalization (audio renderer back on)") + return // skip processing the intermediate no-audio track list + } + if (restorePendingDvTrackSelection(tracks)) return // Log selected video and audio track details @@ -1770,6 +1787,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { private fun shouldBlockDirectAudioOutput(format: Format, reason: String): Boolean { val mimeType = format.sampleMimeType ?: return false + // Loudness normalization needs decoded PCM for the audiofx chain to act on. + if (audioNormalizationEnabled && isEncodedAudioMimeType(mimeType)) return true if (directAudioOutputBlockedAfterFailure.contains(mimeType)) { if (loggedDirectAudioRecoveryBlocks.add("$mimeType|$reason")) { emitLog( @@ -2153,7 +2172,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { val player = exoPlayer ?: return null val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L return tunnelingUserEnabled && (player.playbackParameters.speed == 1f) && !tunnelingDisabledForCodec && - !tunnelingDisabledForAssSubtitles && !audioDelayActive + !tunnelingDisabledForAssSubtitles && !audioDelayActive && !audioNormalizationEnabled } private fun updateCurrentTunnelingState(reason: String, shouldTunnel: Boolean): Boolean { @@ -2161,7 +2180,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { currentTunneledPlayback = shouldTunnel val speed = exoPlayer?.playbackParameters?.speed ?: 1f val audioDelayActive = (renderersFactory?.audioDelayUs?.get() ?: 0L) != 0L - emitLog("info", "tunneling", "Toggling tunneling=$shouldTunnel (reason=$reason, user=$tunnelingUserEnabled, speed=$speed, audioCodecDisabled=$tunnelingDisabledForAudioCodec, videoCodecDisabled=$tunnelingDisabledForVideoCodec, decodedTrueHdPcmDisabled=$tunnelingDisabledForDecodedTrueHdPcm, audioRecoveryDisabled=$tunnelingDisabledForAudioRecovery, assSubtitlesDisabled=$tunnelingDisabledForAssSubtitles, audioDelay=$audioDelayActive)") + emitLog("info", "tunneling", "Toggling tunneling=$shouldTunnel (reason=$reason, user=$tunnelingUserEnabled, speed=$speed, audioCodecDisabled=$tunnelingDisabledForAudioCodec, videoCodecDisabled=$tunnelingDisabledForVideoCodec, decodedTrueHdPcmDisabled=$tunnelingDisabledForDecodedTrueHdPcm, audioRecoveryDisabled=$tunnelingDisabledForAudioRecovery, assSubtitlesDisabled=$tunnelingDisabledForAssSubtitles, audioDelay=$audioDelayActive, audioNormalization=$audioNormalizationEnabled)") return true } @@ -2327,6 +2346,14 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { updateTunnelingState("encoded TrueHD output initialized", forceSelector = true) } } + // Re-key the normalization effect to the actual output channel count + // (DynamicsProcessing parameters are per-channel). + if (audioNormalizationEnabled) attachNormalizationEffect() + } + + override fun onAudioSessionIdChanged(eventTime: AnalyticsListener.EventTime, audioSessionId: Int) { + emitLog("debug", "audio", "Audio session id: $audioSessionId") + if (audioNormalizationEnabled) attachNormalizationEffect() } override fun onAudioTrackReleased( @@ -2556,6 +2583,10 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { tunnelingDisabledForAudioRecovery = false tunnelingDisabledForAssSubtitles = false currentTunneledPlayback = false + // audioNormalizationEnabled persists across opens (user-level state, like + // tunnelingUserEnabled); only the in-flight bounce is abandoned. + pendingAudioRendererBounce = false + handler.removeCallbacks(audioBounceTimeout) pendingStartPositionMs = startPositionMs pendingPlayWhenReady = autoPlay applyTrackSelectorPolicy( @@ -2605,6 +2636,59 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { updateTunnelingState("audio-delay") } + fun setAudioNormalization(enabled: Boolean) { + if (audioNormalizationEnabled == enabled) return + audioNormalizationEnabled = enabled + emitLog("info", "audio-normalization", "Loudness normalization ${if (enabled) "enabled" else "disabled"}") + if (enabled) attachNormalizationEffect() else audioNormalization.release() + + if (exoPlayer == null) return + // A selector-parameter change only re-inits the audio renderer when the + // parameters actually differ (the tunneling flag flipping). Otherwise the + // renderer keeps its bypass/decode path and the sink's new direct-output + // verdict is never consulted — bounce the renderer in that case. + val tunnelingWillFlip = calculateTunnelingEnabled() != currentTunneledPlayback + val outputEncoding = lastAudioTrackConfig?.encoding + val selectedMime = selectedAudioFormat()?.sampleMimeType + val needsBounce = !tunnelingWillFlip && outputEncoding != null && selectedMime != null && + isEncodedAudioMimeType(selectedMime) && + (if (enabled) !isPcmEncoding(outputEncoding) else isPcmEncoding(outputEncoding)) + if (needsBounce) { + startAudioRendererBounce("audio-normalization") + } else { + updateTunnelingState("audio-normalization") + } + } + + private fun attachNormalizationEffect() { + val sessionId = exoPlayer?.audioSessionId ?: C.AUDIO_SESSION_ID_UNSET + if (sessionId == C.AUDIO_SESSION_ID_UNSET) { + emitLog("debug", "audio-normalization", "Audio session id not ready; attach deferred") + return // onAudioSessionIdChanged re-attaches + } + val channels = lastAudioTrackConfig?.channelConfig?.let { Integer.bitCount(it) } + audioNormalization.attach(sessionId, channels) + } + + // Two-phase audio renderer bounce: disable, wait for the playback thread to + // observe it (onTracksChanged with no selected audio), then re-enable so track + // selection re-queries the sink's format support. A synchronous flip-back + // would be coalesced: the invalidation message reads the latest parameters. + private fun startAudioRendererBounce(reason: String) { + if (pendingAudioRendererBounce) return + pendingAudioRendererBounce = true + emitLog("info", "audio-normalization", "Bouncing audio renderer to re-evaluate output path (reason=$reason)") + applyTrackSelectorPolicy(reason = "$reason (audio renderer off)", audioDisabled = true) + handler.postDelayed(audioBounceTimeout, AUDIO_BOUNCE_TIMEOUT_MS) + } + + private fun completeAudioRendererBounce(reason: String) { + if (!pendingAudioRendererBounce) return + pendingAudioRendererBounce = false + handler.removeCallbacks(audioBounceTimeout) + applyTrackSelectorPolicy(reason = reason, audioDisabled = false) + } + fun setSubtitleDelay(seconds: Double) { subtitleDelayUs.set((seconds * 1_000_000).toLong()) } @@ -3072,6 +3156,8 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { "audioOutputTunneling" to audioTrackConfig?.tunneling, "audioOutputOffload" to audioTrackConfig?.offload, "audioOutputBufferSize" to audioTrackConfig?.bufferSize, + "audioNormalization" to audioNormalizationEnabled, + "audioNormalizationEffect" to audioNormalization.describe, "audioLastSinkError" to lastAudioSinkError, "audioRecoveryAttempts" to audioRecoveryAttempts, "audioRecoveryLastAction" to lastAudioRecoveryAction, @@ -3144,6 +3230,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { if (currentTunneledPlayback) return "Active" if (!tunnelingUserEnabled) return "Disabled by user" if (player.playbackParameters.speed != 1f) return "Off (speed ≠ 1×)" + if (audioNormalizationEnabled) return "Off (loudness normalization)" if (tunnelingDisabledForAudioRecovery) return "Off (audio recovery)" if (tunnelingDisabledForDecodedTrueHdPcm) return "Off (decoded TrueHD PCM)" if (tunnelingDisabledForVideoCodec) return "Off (video codec unsupported)" @@ -3180,6 +3267,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { audioFocusManager?.release() audioFocusManager = null + audioNormalization.release() + pendingAudioRendererBounce = false + decoderInitName = null audioDecoderInitName = null lastAudioTrackConfig = null diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index f7ef585e..824b8667 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -164,6 +164,7 @@ class ExoPlayerPlugin : "setBoxFitMode" -> handleSetBoxFitMode(call, result) "setVideoZoom" -> handleSetVideoZoom(call, result) "setDvConversionMode" -> handleSetDvConversionMode(call, result) + "setAudioNormalization" -> handleSetAudioNormalization(call, result) "observeProperty" -> handleObserveProperty(call, result) "setMpvProperty" -> handleSetMpvProperty(call, result) "setLogLevel" -> { @@ -608,6 +609,23 @@ class ExoPlayerPlugin : } ?: result.error("NO_ACTIVITY", "Activity not available", null) } + private fun handleSetAudioNormalization(call: MethodCall, result: MethodChannel.Result) { + val enabled = call.argument("enabled") + if (enabled == null) { + result.error("INVALID_ARGS", "Missing 'enabled'", null) + return + } + if (usingMpvFallback) { + // mpv applies loudnorm via the 'af' property the Dart layer also sends. + result.success(true) + return + } + activity?.runOnUiThread { + playerCore?.setAudioNormalization(enabled) + result.success(true) + } ?: result.error("NO_ACTIVITY", "Activity not available", null) + } + private fun handleSetMpvProperty(call: MethodCall, result: MethodChannel.Result) { val name = call.argument("name") val value = call.argument("value") diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 963ed8d7..1c6c3ba1 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -12,6 +12,7 @@ class PlayerAndroid extends PlayerBase { int? _bufferSizeBytes; bool _tunnelingEnabled = true; String _dvConversionMode = 'auto'; + bool _audioNormalizationEnabled = false; /// The native plugin switched from ExoPlayer to its mpv fallback for this /// session. Sticky for the instance lifetime, mirroring the native flag @@ -259,6 +260,24 @@ class PlayerAndroid extends PlayerBase { } } + @override + Future setAudioNormalization(bool enabled) async { + if (disposed) return; + _audioNormalizationEnabled = enabled; + final initFuture = _initFuture; + if (initialized) { + await invoke('setAudioNormalization', {'enabled': enabled}); + } else if (initFuture != null) { + await initFuture; + if (!disposed && initialized && _audioNormalizationEnabled == enabled) { + await invoke('setAudioNormalization', {'enabled': enabled}); + } + } + // Keep the mpv af property flowing through setMpvProperty so the plugin's + // pendingMpvProperties replay applies loudnorm if exo falls back to mpv. + await super.setAudioNormalization(enabled); + } + @override Future getProperty(String name) async { if (disposed) return null; diff --git a/lib/mpv/player/player.dart b/lib/mpv/player/player.dart index 0f008480..47c6543a 100644 --- a/lib/mpv/player/player.dart +++ b/lib/mpv/player/player.dart @@ -209,6 +209,14 @@ abstract class Player { /// passed through to the audio device without decoding. Future setAudioPassthrough(bool enabled); + /// Enable or disable loudness normalization. + /// + /// mpv backends insert/remove the `loudnorm` audio filter. Android + /// ExoPlayer attaches platform audio effects (DynamicsProcessing on + /// API 28+, LoudnessEnhancer otherwise) and forces decoded non-tunneled + /// PCM output while enabled so the effects can process the stream. + Future setAudioNormalization(bool enabled); + /// Show or hide the video rendering layer. /// /// On macOS, this controls the Metal layer visibility. diff --git a/lib/mpv/player/player_base.dart b/lib/mpv/player/player_base.dart index 00217955..3ceb26cd 100644 --- a/lib/mpv/player/player_base.dart +++ b/lib/mpv/player/player_base.dart @@ -668,6 +668,15 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player { // ignore: no-empty-block - base no-op, overridden by platform subclasses Future setAudioPassthrough(bool enabled) async {} + /// mpv loudnorm targeting streaming-style loudness; mirrored by the + /// Android ExoPlayer effect parameters in AudioNormalizationEffect.kt. + static const _loudnormFilter = 'loudnorm=I=-14:TP=-3:LRA=4'; + + @override + Future setAudioNormalization(bool enabled) async { + await setProperty('af', enabled ? _loudnormFilter : ''); + } + @override // ignore: no-empty-block - base no-op, overridden by platform subclasses Future setLogLevel(String level) async {} diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index b88ed1f9..8b9deb17 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -819,7 +819,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin } if (settingsService.read(SettingsService.audioNormalization)) { - await currentPlayer.setProperty('af', 'loudnorm=I=-14:TP=-3:LRA=4'); + await currentPlayer.setAudioNormalization(true); } if (PlatformDetector.isDesktopOS()) { diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index eaedf00f..c56eaeac 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -556,14 +556,13 @@ class _VideoSettingsSheetState extends State { onAfterWrite: widget.player.setAudioPassthrough, ), - // Audio Normalization (MPV only) - if (widget.player.playerType == 'mpv') - _SettingsToggleItem( - pref: SettingsService.audioNormalization, - icon: Symbols.graphic_eq_rounded, - title: t.videoSettings.audioNormalization, - onAfterWrite: (value) => widget.player.setProperty('af', value ? 'loudnorm=I=-14:TP=-3:LRA=4' : ''), - ), + // Audio Normalization + _SettingsToggleItem( + pref: SettingsService.audioNormalization, + icon: Symbols.graphic_eq_rounded, + title: t.videoSettings.audioNormalization, + onAfterWrite: widget.player.setAudioNormalization, + ), // Shader Preset (MPV only) if (widget.shaderService != null && widget.shaderService!.isSupported)