Add hybrid system volume and software boost control

Implemented hybrid volume control for the player with support for volume levels up to 200%.

Changes include:
- Added combined volume APIs to the player engine controller.
- Updated player gestures so volume levels from 0–100% control the system/media volume.
- Enabled software boost only when the volume level exceeds 100%.
- Locked system/media volume to 100% while applying software boost from 101–200%.
- Added Android software gain support through the player audio pipeline.
- Added iOS MPV volume boost support through the native player bridge.
- Updated volume feedback to display boosted levels up to 200%.
- Added a distinct boosted-state indicator for volume levels above 100%.
- Preserved fallback behavior to system volume when the player engine is unavailable.

This improves volume behavior by keeping normal volume control intuitive while applying player-level amplification only beyond the standard 100% range.
This commit is contained in:
Luqman Fadlli 2026-06-04 05:26:10 +07:00
parent 123549a1a5
commit 3afd58c3c1
3 changed files with 147 additions and 13 deletions

View file

@ -39,6 +39,12 @@ import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.text.Cue
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.common.audio.AudioProcessor
import androidx.media3.common.audio.BaseAudioProcessor
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import java.nio.ByteBuffer
import java.nio.ByteOrder
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
@ -119,6 +125,7 @@ actual fun PlatformPlayerSurface(
var decoderPriorityOverride by remember(playerSourceKey) { mutableStateOf<Int?>(null) }
var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf<Long?>(null) }
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
val volumeBoostAudioProcessor = remember(playerSourceKey) { VolumeBoostAudioProcessor() }
val extractorsFactory = remember {
DefaultExtractorsFactory()
@ -171,6 +178,7 @@ actual fun PlatformPlayerSurface(
shouldNormalizeCuePositionProvider = {
latestExternalSubtitleMimeType.value == MimeTypes.TEXT_VTT
},
volumeBoostAudioProcessor = volumeBoostAudioProcessor,
)
.setExtensionRendererMode(effectiveDecoderPriority)
.setEnableDecoderFallback(true)
@ -394,7 +402,7 @@ actual fun PlatformPlayerSurface(
}
override fun currentPlayerVolume(): PlayerAudioLevel {
val current = exoPlayer.volume.coerceIn(0f, 2f)
val current = volumeBoostAudioProcessor.gain.coerceIn(0f, 2f)
return PlayerAudioLevel(
fraction = current,
isMuted = current <= 0.001f,
@ -403,7 +411,11 @@ actual fun PlatformPlayerSurface(
override fun setPlayerVolume(level: Float): PlayerAudioLevel {
val target = level.coerceIn(0f, 2f)
exoPlayer.volume = target
// Keep ExoPlayer's own volume at unity and apply gain in the PCM audio processor.
// ExoPlayer#setVolume is effectively a normal 0..1 output volume control on many devices,
// so values above 1 may not create audible amplification.
exoPlayer.volume = 1f
volumeBoostAudioProcessor.gain = target
return PlayerAudioLevel(
fraction = target,
isMuted = target <= 0.001f,
@ -884,11 +896,65 @@ private fun ExoPlayer.logCurrentTracks(context: String) {
}
@androidx.annotation.OptIn(UnstableApi::class)
private class VolumeBoostAudioProcessor : BaseAudioProcessor() {
@Volatile
var gain: Float = 1f
set(value) {
field = value.coerceIn(0f, 2f)
}
override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat {
return if (inputAudioFormat.encoding == C.ENCODING_PCM_16BIT) {
inputAudioFormat
} else {
AudioProcessor.AudioFormat.NOT_SET
}
}
override fun queueInput(inputBuffer: ByteBuffer) {
val inputSize = inputBuffer.remaining()
val outputBuffer = replaceOutputBuffer(inputSize).order(ByteOrder.nativeOrder())
val input = inputBuffer.order(ByteOrder.nativeOrder())
val localGain = gain
while (input.remaining() >= 2) {
val sample = input.short.toInt()
val amplified = (sample * localGain)
.toInt()
.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
outputBuffer.putShort(amplified.toShort())
}
input.position(input.limit())
outputBuffer.flip()
}
}
private class SubtitleOffsetRenderersFactory(
context: Context,
private val subtitleDelayUsProvider: () -> Long,
private val shouldNormalizeCuePositionProvider: () -> Boolean,
private val volumeBoostAudioProcessor: VolumeBoostAudioProcessor,
) : DefaultRenderersFactory(context) {
override fun buildAudioSink(
context: Context,
enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean,
enableOffload: Boolean,
): AudioSink? {
return DefaultAudioSink.Builder(context)
.setEnableFloatOutput(false)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
.setOffloadMode(
if (enableOffload) {
DefaultAudioSink.OFFLOAD_MODE_ENABLED_GAPLESS_REQUIRED
} else {
DefaultAudioSink.OFFLOAD_MODE_DISABLED
}
)
.setAudioProcessors(arrayOf(volumeBoostAudioProcessor))
.build()
}
override fun buildTextRenderers(
context: Context,
output: TextOutput,

View file

@ -105,6 +105,7 @@ private const val PlayerLockedOverlayDurationMs = 2_000L
private const val PlayerLeftGestureBoundary = 0.4f
private const val PlayerRightGestureBoundary = 0.6f
private const val PlayerVerticalGestureSensitivity = 1f
private const val PlayerNormalVolumeCeiling = 1f
private const val PlayerMaxVolumeBoost = 2f
private const val PlayerSeekProgressSyncDebounceMs = 700L
private const val P2pInitialPreloadTargetBytes = 5_242_880L
@ -994,7 +995,9 @@ fun PlayerScreen(
}
fun showVolumeFeedback(level: PlayerAudioLevel) {
val percentage = (level.fraction.coerceIn(0f, PlayerMaxVolumeBoost) * 100f).roundToInt()
val normalized = level.fraction.coerceIn(0f, PlayerMaxVolumeBoost)
val percentage = (normalized * 100f).roundToInt()
val isBoosted = normalized > PlayerNormalVolumeCeiling
showGestureFeedback(
GestureFeedbackState(
messageRes = if (level.isMuted) {
@ -1004,11 +1007,53 @@ fun PlayerScreen(
},
messageArgs = if (level.isMuted) emptyList() else listOf("$percentage%"),
icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume,
isDanger = level.isMuted,
// Use the existing alternate feedback color path for the boosted range.
isDanger = level.isMuted || isBoosted,
),
)
}
fun currentCombinedVolumeLevel(
systemVolume: PlayerAudioLevel?,
softwareVolume: PlayerAudioLevel?,
): PlayerAudioLevel? {
if (systemVolume == null && softwareVolume == null) return null
if (systemVolume == null) return softwareVolume
if (systemVolume.fraction < PlayerNormalVolumeCeiling || softwareVolume == null) {
return systemVolume
}
val boostFraction = softwareVolume.fraction.coerceIn(PlayerNormalVolumeCeiling, PlayerMaxVolumeBoost)
return PlayerAudioLevel(
fraction = boostFraction,
isMuted = boostFraction <= 0.001f,
)
}
fun setCombinedVolumeLevel(
target: Float,
systemController: PlayerGestureController?,
engineController: PlayerEngineController?,
): PlayerAudioLevel? {
val normalized = target.coerceIn(0f, PlayerMaxVolumeBoost)
return if (normalized <= PlayerNormalVolumeCeiling) {
// Normal range: change the device/media volume and keep software gain neutral.
engineController?.setPlayerVolume(PlayerNormalVolumeCeiling)
systemController?.setVolume(normalized)
?: PlayerAudioLevel(
fraction = normalized,
isMuted = normalized <= 0.001f,
)
} else {
// Boost range: keep device/media volume at maximum and apply software gain above 100%.
val systemLevel = systemController?.setVolume(PlayerNormalVolumeCeiling)
val boostedLevel = engineController?.setPlayerVolume(normalized)
boostedLevel?.copy(
fraction = normalized,
isMuted = false,
) ?: systemLevel
}
}
fun togglePlayback() {
if (playbackSnapshot.isPlaying) {
shouldPlay = false
@ -2372,7 +2417,10 @@ fun PlayerScreen(
null
}
val initialVolume = if (region == PlayerSideGesture.Volume) {
playerController?.currentPlayerVolume() ?: controller?.currentVolume()
currentCombinedVolumeLevel(
systemVolume = controller?.currentVolume(),
softwareVolume = playerController?.currentPlayerVolume(),
)
} else {
null
}
@ -2457,11 +2505,13 @@ fun PlayerScreen(
PlayerGestureMode.Volume -> {
val gestureDeltaFraction =
(-totalDy / height) * PlayerVerticalGestureSensitivity
val target = ((initialVolume?.fraction ?: 1f) + gestureDeltaFraction)
val target = ((initialVolume?.fraction ?: 0f) + gestureDeltaFraction)
.coerceIn(0f, PlayerMaxVolumeBoost)
val level = playerController?.setPlayerVolume(target)
?: controller?.setVolume(target.coerceIn(0f, 1f))
level?.let(showVolumeFeedbackState.value)
setCombinedVolumeLevel(
target = target,
systemController = controller,
engineController = playerController,
)?.let(showVolumeFeedbackState.value)
}
null -> Unit

View file

@ -323,6 +323,8 @@ final class MPVPlayerViewController: UIViewController {
checkError(mpv_set_option_string(mpv, "audio-fallback-to-null", "yes"))
checkError(mpv_set_option_string(mpv, "volume-max", "200"))
checkError(mpv_set_option_string(mpv, "volume", "100"))
checkError(mpv_set_option_string(mpv, "volume-gain-max", "6.1"))
checkError(mpv_set_option_string(mpv, "volume-gain", "0"))
checkError(mpv_set_option_string(mpv, "vulkan-swap-mode", "fifo"))
checkError(mpv_set_option_string(mpv, "vulkan-queue-count", "1"))
checkError(mpv_set_option_string(mpv, "vulkan-async-compute", "no"))
@ -523,14 +525,30 @@ final class MPVPlayerViewController: UIViewController {
func getVolume() -> Float {
guard mpv != nil else { return 1.0 }
let volume = getDouble("volume") / 100.0
return Float(max(0.0, min(2.0, volume)))
let baseVolume = getDouble("volume") / 100.0
let gainDb = getDouble("volume-gain")
let gainMultiplier = pow(10.0, gainDb / 20.0)
return Float(max(0.0, min(2.0, baseVolume * gainMultiplier)))
}
func setVolume(_ volume: Float) {
guard mpv != nil else { return }
var value = Double(max(0.0, min(2.0, volume)) * 100.0)
checkError(mpv_set_property(mpv, "volume", MPV_FORMAT_DOUBLE, &value))
let clamped = max(0.0, min(2.0, Double(volume)))
if clamped <= 0.001 {
var mutedVolume = 0.0
var neutralGain = 0.0
checkError(mpv_set_property(mpv, "volume", MPV_FORMAT_DOUBLE, &mutedVolume))
checkError(mpv_set_property(mpv, "volume-gain", MPV_FORMAT_DOUBLE, &neutralGain))
return
}
// Keep mpv's base volume at 100 and apply real software amplification through volume-gain.
// 2.0x equals about +6.02 dB.
var baseVolume = 100.0
var gainDb = 20.0 * log10(clamped)
checkError(mpv_set_property(mpv, "volume", MPV_FORMAT_DOUBLE, &baseVolume))
checkError(mpv_set_property(mpv, "volume-gain", MPV_FORMAT_DOUBLE, &gainDb))
}
func setResize(_ mode: Int) {