mirror of
https://github.com/NuvioMedia/NuvioMobile.git
synced 2026-07-27 01:22:18 +00:00
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:
parent
123549a1a5
commit
3afd58c3c1
3 changed files with 147 additions and 13 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue