Compare commits

...

18 commits

Author SHA1 Message Date
Luqman Fadlli
a0a47de629
Merge 978c775954 into 755f6ff9b8 2026-07-23 18:56:07 -04:00
Luqman Fadlli
978c775954 Merge remote-tracking branch 'upstream/cmp-rewrite' into feat-volume-boost 2026-07-15 16:24:12 +07:00
Luqman Fadlli
2465d4e6b2 Merge remote-tracking branch 'upstream/cmp-rewrite' into feat-volume-boost 2026-06-25 17:14:22 +07:00
Luqman Fadlli
a495222f12
Merge branch 'NuvioMedia:cmp-rewrite' into feat-volume-boost 2026-06-21 03:50:22 +07:00
Luqman Fadlli
a3e309fef9
Merge branch 'NuvioMedia:cmp-rewrite' into feat-volume-boost 2026-06-19 17:20:16 +07:00
Luqman Fadlli
83bc15441b
Merge branch 'NuvioMedia:cmp-rewrite' into feat-volume-boost 2026-06-12 16:17:26 +07:00
Luqman Fadlli
bfd8c3483e
Merge branch 'NuvioMedia:cmp-rewrite' into feat-volume-boost 2026-06-10 08:16:34 +07:00
Luqman Fadlli
240cde9db3 Merge remote-tracking branch 'upstream/cmp-rewrite' into feat-volume-boost 2026-06-09 11:11:16 +07:00
Luqman Fadlli
80e7373aa2 resolve MPVKit submodule 2026-06-07 20:38:46 +07:00
Luqman Fadlli
32fbe672a5
Merge branch 'NuvioMedia:cmp-rewrite' into feat-volume-boost 2026-06-07 19:05:11 +07:00
Luqman Fadlli
86e35fd2fa Merge remote-tracking branch 'upstream/cmp-rewrite' into feat-volume-boost
This patch starts from the uploaded upstream refactor source and reapplies the working VolumeBoost implementation without reverting upstream MPV audio-output changes.
2026-06-07 06:59:15 +07:00
Luqman Fadlli
645a5e58a7 Merge remote-tracking branch 'upstream/cmp-rewrite' into feat-volume-boost 2026-06-07 06:52:05 +07:00
Luqman Fadlli
dc003852e1
Fix unclosed setVolume scope in MPV player bridge 2026-06-06 09:23:47 +07:00
Luqman Fadlli
220bef6d1a
fix: removing the enableOffload parameter and .setOffloadMode()
Updated `buildAudioSink` signature in `PlayerEngine.android.kt` by removing the `enableOffload` parameter and `.setOffloadMode()` call to comply with recent Media3/ExoPlayer API changes.
2026-06-06 08:06:04 +07:00
Luqman Fadlli
c075178ff8 Merge remote-tracking branch 'upstream/cmp-rewrite' into feat-volume-boost 2026-06-06 03:17:16 +07:00
Luqman Fadlli
65a8162cbb Merge branch 'cmp-rewrite' into feat-volume-boost 2026-06-04 05:37:03 +07:00
Luqman Fadlli
3afd58c3c1 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.
2026-06-04 05:26:10 +07:00
Luqman Fadlli
123549a1a5 feat: Add 200% in-player volume boost for Android and iOS
Implemented in-player volume boost support up to 200% across Android and iOS playback engines.

Changes include:
- Added player-level volume control APIs to PlayerEngineController.
- Routed volume gestures through the active player engine instead of system volume when available.
- Added Android support by mapping boosted volume to ExoPlayer volume from 0.0 to 2.0.
- Added iOS support by exposing volume control through NuvioPlayerBridge.
- Configured MPV volume-max to 200 and mapped shared volume values to MPV volume 0–200.
- Updated player volume feedback to display boosted levels up to 200%.
- Preserved fallback behavior to system volume when the player controller is unavailable.

This keeps brightness and system volume behavior intact while allowing playback-specific volume amplification during video playback.
2026-06-03 17:41:39 +07:00
10 changed files with 202 additions and 7 deletions

2
MPVKit

@ -1 +1 @@
Subproject commit d5cf091c80368bbbc1bbf2d195fbc55d926df888
Subproject commit 97923266e43d52bbca33a911fd6a7f9a1bcf35cb

View file

@ -42,10 +42,14 @@ 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.datasource.DataSource
import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.TransferListener
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.audio.DefaultAudioSink
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
@ -78,6 +82,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.HttpURLConnection
import java.net.URL
import java.nio.ByteBuffer
import java.nio.ByteOrder
private const val TAG = "NuvioPlayer"
private const val PLAYER_DIAGNOSTIC_TAG = "NuvioPlayerDiag"
@ -257,6 +263,7 @@ private fun ExoPlayerSurface(
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 initialMediaItem = remember(playerSourceKey, externalSubtitles) {
val subtitleConfigs = externalSubtitles.mapNotNull { subtitle ->
@ -342,6 +349,7 @@ private fun ExoPlayerSurface(
shouldNormalizeCuePositionProvider = {
latestExternalSubtitleMimeType.value == MimeTypes.TEXT_VTT
},
volumeBoostAudioProcessor = volumeBoostAudioProcessor,
)
.setExtensionRendererMode(effectiveDecoderPriority)
.setEnableDecoderFallback(true)
@ -702,6 +710,27 @@ private fun ExoPlayerSurface(
exoPlayer.setPlaybackSpeed(speed)
}
override fun currentPlayerVolume(): PlayerAudioLevel {
val current = volumeBoostAudioProcessor.gain.coerceIn(0f, 2f)
return PlayerAudioLevel(
fraction = current,
isMuted = current <= 0.001f,
)
}
override fun setPlayerVolume(level: Float): PlayerAudioLevel {
val target = level.coerceIn(0f, 2f)
// 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,
)
}
override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) {
nowPlayingController.updateMetadata(info)
}
@ -1794,12 +1823,60 @@ private fun ExoPlayer.logCurrentTracks(context: String) {
Log.d(TAG, "--- end logCurrentTracks ---")
}
@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()
}
}
@androidx.annotation.OptIn(UnstableApi::class)
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,
): AudioSink? {
return DefaultAudioSink.Builder(context)
.setEnableFloatOutput(false)
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
.setAudioProcessors(arrayOf(volumeBoostAudioProcessor))
.build()
}
override fun buildTextRenderers(
context: Context,
output: TextOutput,

View file

@ -10,6 +10,14 @@ interface PlayerEngineController {
fun seekBy(offsetMs: Long)
fun retry()
fun setPlaybackSpeed(speed: Float)
fun currentPlayerVolume(): PlayerAudioLevel = PlayerAudioLevel(
fraction = 1f,
isMuted = false,
)
fun setPlayerVolume(level: Float): PlayerAudioLevel = PlayerAudioLevel(
fraction = 1f,
isMuted = false,
)
fun setMuted(muted: Boolean) {}
fun getAudioTracks(): List<AudioTrack>
fun getSubtitleTracks(): List<SubtitleTrack>

View file

@ -134,7 +134,9 @@ internal fun PlayerScreenRuntime.showBrightnessFeedback(level: Float) {
}
internal fun PlayerScreenRuntime.showVolumeFeedback(level: PlayerAudioLevel) {
val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt()
val normalized = level.fraction.coerceIn(0f, PlayerMaxVolumeBoost)
val percentage = (normalized * 100f).roundToInt()
val isBoosted = normalized > PlayerNormalVolumeCeiling
showGestureFeedback(
GestureFeedbackState(
messageRes = if (level.isMuted) {
@ -144,7 +146,8 @@ internal fun PlayerScreenRuntime.showVolumeFeedback(level: PlayerAudioLevel) {
},
messageArgs = if (level.isMuted) emptyList() else listOf("$percentage%"),
icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume,
isDanger = level.isMuted,
// Reuse the existing alternate feedback color path to distinguish boosted volume.
isDanger = level.isMuted || isBoosted,
),
)
}

View file

@ -121,6 +121,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
)
.playerSurfaceDragGestures(
gestureController = gestureController,
playerController = playerController,
layoutSize = layoutSize,
sideGestureSystemEdgeExclusionPx = sideGestureSystemEdgeExclusionPx,
playerControlsLockedState = gestureCallbacks.playerControlsLocked,

View file

@ -11,6 +11,8 @@ internal const val PlayerLockedOverlayDurationMs = 2_000L
internal const val PlayerLeftGestureBoundary = 0.4f
internal const val PlayerRightGestureBoundary = 0.6f
internal const val PlayerVerticalGestureSensitivity = 0.65f
internal const val PlayerNormalVolumeCeiling = 1f
internal const val PlayerMaxVolumeBoost = 2f
internal const val PlayerVerticalGestureTouchSlopMultiplier = 3f
internal const val PlayerVerticalGestureMinHeightFraction = 0.06f
internal const val PlayerVerticalGestureDominanceRatio = 1.2f
@ -87,3 +89,45 @@ internal data class PendingPlayerP2pSwitch(
val episode: MetaVideo?,
val isAutoPlay: Boolean,
)
internal 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,
)
}
internal fun setCombinedVolumeLevel(
target: Float,
systemController: PlayerGestureController?,
engineController: PlayerEngineController?,
): PlayerAudioLevel? {
val normalized = target.coerceIn(0f, PlayerMaxVolumeBoost)
return if (normalized <= PlayerNormalVolumeCeiling) {
// Normal range: change 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
}
}

View file

@ -40,6 +40,7 @@ internal fun Modifier.playerSurfaceTapGestures(
internal fun Modifier.playerSurfaceDragGestures(
gestureController: PlayerGestureController?,
playerController: PlayerEngineController?,
layoutSize: IntSize,
sideGestureSystemEdgeExclusionPx: Float,
playerControlsLockedState: State<Boolean>,
@ -55,7 +56,7 @@ internal fun Modifier.playerSurfaceDragGestures(
revealLockedOverlayState: State<() -> Unit>,
commitHorizontalSeekState: State<(Long) -> Unit>,
): Modifier =
pointerInput(gestureController, layoutSize, sideGestureSystemEdgeExclusionPx) {
pointerInput(gestureController, playerController, layoutSize, sideGestureSystemEdgeExclusionPx) {
awaitEachGesture {
val down = awaitFirstDown()
if (playerControlsLockedState.value) {
@ -91,7 +92,10 @@ internal fun Modifier.playerSurfaceDragGestures(
null
}
val initialVolume = if (region == PlayerSideGesture.Volume) {
controller?.currentVolume()
currentCombinedVolumeLevel(
systemVolume = controller?.currentVolume(),
softwareVolume = playerController?.currentPlayerVolume(),
)
} else {
null
}
@ -185,8 +189,13 @@ internal fun Modifier.playerSurfaceDragGestures(
val activeTotalDy = totalDy - verticalGestureActivationDy
val gestureDeltaFraction =
(-activeTotalDy / height) * PlayerVerticalGestureSensitivity
controller?.setVolume((initialVolume?.fraction ?: 0f) + gestureDeltaFraction)
?.let(showVolumeFeedbackState.value)
val target = ((initialVolume?.fraction ?: 0f) + gestureDeltaFraction)
.coerceIn(0f, PlayerMaxVolumeBoost)
setCombinedVolumeLevel(
target = target,
systemController = controller,
engineController = playerController,
)?.let(showVolumeFeedbackState.value)
}
}
change.consume()

View file

@ -43,6 +43,8 @@ interface NuvioPlayerBridge {
)
fun configureAudioOutput(audioOutput: String)
fun setPlaybackSpeed(speed: Float)
fun getVolume(): Float
fun setVolume(volume: Float)
fun setMuted(muted: Boolean)
fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom
fun syncVideoSurfaceLayout(width: Double, height: Double)

View file

@ -130,6 +130,23 @@ actual fun PlatformPlayerSurface(
bridge.setPlaybackSpeed(speed)
}
override fun currentPlayerVolume(): PlayerAudioLevel {
val current = bridge.getVolume().coerceIn(0f, 2f)
return PlayerAudioLevel(
fraction = current,
isMuted = current <= 0.001f,
)
}
override fun setPlayerVolume(level: Float): PlayerAudioLevel {
val target = level.coerceIn(0f, 2f)
bridge.setVolume(target)
return PlayerAudioLevel(
fraction = target,
isMuted = target <= 0.001f,
)
}
override fun setMuted(muted: Boolean) {
bridge.setMuted(muted)
}

View file

@ -102,6 +102,8 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
playerVC?.configureAudioOutput(audioOutput: audioOutput)
}
func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) }
func getVolume() -> Float { playerVC?.getVolume() ?? 1.0 }
func setVolume(volume: Float) { playerVC?.setVolume(volume) }
func setMuted(muted: Bool) { playerVC?.setMuted(muted) }
func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) }
func syncVideoSurfaceLayout(width: Double, height: Double) {
@ -473,6 +475,10 @@ final class MPVPlayerViewController: UIViewController {
checkError(mpv_set_option_string(mpv, "ao", Self.defaultAudioOutput))
checkError(mpv_set_option_string(mpv, "audio-channels", "auto"))
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"))
@ -695,6 +701,34 @@ final class MPVPlayerViewController: UIViewController {
mpv_set_property(mpv, "speed", MPV_FORMAT_DOUBLE, &s)
}
func getVolume() -> Float {
guard mpv != nil else { return 1.0 }
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 }
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 setMuted(_ muted: Bool) {
guard mpv != nil else { return }
setFlag("mute", muted)