From 123549a1a5e273ef331f64e57cc614afe355b20b Mon Sep 17 00:00:00 2001 From: Luqman Fadlli Date: Wed, 3 Jun 2026 17:41:39 +0700 Subject: [PATCH 1/6] feat: Add 200% in-player volume boost for Android and iOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../app/features/player/PlayerEngine.android.kt | 17 +++++++++++++++++ .../nuvio/app/features/player/PlayerEngine.kt | 8 ++++++++ .../nuvio/app/features/player/PlayerScreen.kt | 14 +++++++++----- .../app/features/player/NuvioPlayerBridge.kt | 2 ++ .../app/features/player/PlayerEngine.ios.kt | 17 +++++++++++++++++ iosApp/iosApp/Player/MPVPlayerBridge.swift | 16 ++++++++++++++++ 6 files changed, 69 insertions(+), 5 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 9d2c97bd5..c26a3548c 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -393,6 +393,23 @@ actual fun PlatformPlayerSurface( exoPlayer.setPlaybackSpeed(speed) } + override fun currentPlayerVolume(): PlayerAudioLevel { + val current = exoPlayer.volume.coerceIn(0f, 2f) + return PlayerAudioLevel( + fraction = current, + isMuted = current <= 0.001f, + ) + } + + override fun setPlayerVolume(level: Float): PlayerAudioLevel { + val target = level.coerceIn(0f, 2f) + exoPlayer.volume = target + return PlayerAudioLevel( + fraction = target, + isMuted = target <= 0.001f, + ) + } + override fun getAudioTracks(): List = exoPlayer.extractAudioTracks(context) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 72a3b86fe..3bea23cd5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -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 getAudioTracks(): List fun getSubtitleTracks(): List fun selectAudioTrack(index: Int) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index fc9487bdb..e93e2f3ff 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -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 PlayerMaxVolumeBoost = 2f private const val PlayerSeekProgressSyncDebounceMs = 700L private const val P2pInitialPreloadTargetBytes = 5_242_880L /** Hard ceiling for next-episode stream search to prevent hanging forever. */ @@ -993,7 +994,7 @@ fun PlayerScreen( } fun showVolumeFeedback(level: PlayerAudioLevel) { - val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt() + val percentage = (level.fraction.coerceIn(0f, PlayerMaxVolumeBoost) * 100f).roundToInt() showGestureFeedback( GestureFeedbackState( messageRes = if (level.isMuted) { @@ -2344,7 +2345,7 @@ fun PlayerScreen( }, ) } - .pointerInput(gestureController, layoutSize) { + .pointerInput(gestureController, playerController, layoutSize) { awaitEachGesture { val down = awaitFirstDown() if (playerControlsLockedState.value) { @@ -2371,7 +2372,7 @@ fun PlayerScreen( null } val initialVolume = if (region == PlayerSideGesture.Volume) { - controller?.currentVolume() + playerController?.currentPlayerVolume() ?: controller?.currentVolume() } else { null } @@ -2456,8 +2457,11 @@ fun PlayerScreen( PlayerGestureMode.Volume -> { val gestureDeltaFraction = (-totalDy / height) * PlayerVerticalGestureSensitivity - controller?.setVolume((initialVolume?.fraction ?: 0f) + gestureDeltaFraction) - ?.let(showVolumeFeedbackState.value) + val target = ((initialVolume?.fraction ?: 1f) + gestureDeltaFraction) + .coerceIn(0f, PlayerMaxVolumeBoost) + val level = playerController?.setPlayerVolume(target) + ?: controller?.setVolume(target.coerceIn(0f, 1f)) + level?.let(showVolumeFeedbackState.value) } null -> Unit diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 423c7ba75..3402b8acc 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -31,6 +31,8 @@ interface NuvioPlayerBridge { gamma: Int, ) fun setPlaybackSpeed(speed: Float) + fun getVolume(): Float + fun setVolume(volume: Float) fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom fun getAudioTrackCount(): Int fun getAudioTrackIndex(at: Int): Int diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 1a980a2b5..33c049bf2 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -87,6 +87,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 getAudioTracks(): List { val count = bridge.getAudioTrackCount() return (0 until count).map { i -> diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 41d492574..ccfad5b17 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -60,6 +60,8 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { ) } func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) } + func getVolume() -> Float { playerVC?.getVolume() ?? 1.0 } + func setVolume(volume: Float) { playerVC?.setVolume(volume) } func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) } // Audio tracks @@ -319,6 +321,8 @@ final class MPVPlayerViewController: UIViewController { checkError(mpv_set_option_string(mpv, "hwdec", "auto")) checkError(mpv_set_option_string(mpv, "audio-channels", "stereo")) 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, "vulkan-swap-mode", "fifo")) checkError(mpv_set_option_string(mpv, "vulkan-queue-count", "1")) checkError(mpv_set_option_string(mpv, "vulkan-async-compute", "no")) @@ -517,6 +521,18 @@ final class MPVPlayerViewController: UIViewController { mpv_set_property(mpv, "speed", MPV_FORMAT_DOUBLE, &s) } + 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))) + } + + 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)) + } + func setResize(_ mode: Int) { guard mpv != nil else { return } switch mode { From 3afd58c3c184fd716780f40ce811be6e908d5c11 Mon Sep 17 00:00:00 2001 From: Luqman Fadlli Date: Thu, 4 Jun 2026 05:26:10 +0700 Subject: [PATCH 2/6] Add hybrid system volume and software boost control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../features/player/PlayerEngine.android.kt | 70 ++++++++++++++++++- .../nuvio/app/features/player/PlayerScreen.kt | 64 +++++++++++++++-- iosApp/iosApp/Player/MPVPlayerBridge.swift | 26 +++++-- 3 files changed, 147 insertions(+), 13 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index c26a3548c..40da775b6 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -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(null) } var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf(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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index e93e2f3ff..391301ea4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -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 diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index ccfad5b17..020ba1cce 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -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) { From 220bef6d1a63130cf45b90f21707d9adf5fc13cb Mon Sep 17 00:00:00 2001 From: Luqman Fadlli <75630095+luqmanfadlli@users.noreply.github.com> Date: Sat, 6 Jun 2026 08:06:04 +0700 Subject: [PATCH 3/6] 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. --- .../com/nuvio/app/features/player/PlayerEngine.android.kt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 40da775b6..ed9a9acb5 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -940,18 +940,10 @@ private class SubtitleOffsetRenderersFactory( 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() } From dc003852e10208974e5265d000cfe2f742fcb888 Mon Sep 17 00:00:00 2001 From: Luqman Fadlli <75630095+luqmanfadlli@users.noreply.github.com> Date: Sat, 6 Jun 2026 09:23:47 +0700 Subject: [PATCH 4/6] Fix unclosed setVolume scope in MPV player bridge --- iosApp/iosApp/Player/MPVPlayerBridge.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 1ba6cc2ac..f07a1b425 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -550,6 +550,7 @@ final class MPVPlayerViewController: UIViewController { 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 } From 86e35fd2fa2d12b8e295e6165657c75a41caf9c5 Mon Sep 17 00:00:00 2001 From: Luqman Fadlli Date: Sun, 7 Jun 2026 06:59:15 +0700 Subject: [PATCH 5/6] 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. --- .../features/player/PlayerEngine.android.kt | 6 ++- .../PlayerScreenRuntimeGestureActions.kt | 7 ++- .../features/player/PlayerScreenRuntimeUi.kt | 1 + .../features/player/PlayerScreenSupport.kt | 44 +++++++++++++++++++ .../features/player/PlayerSurfaceGestures.kt | 17 +++++-- 5 files changed, 67 insertions(+), 8 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index ed9a9acb5..af974ad8a 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -43,8 +43,6 @@ 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 @@ -71,6 +69,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" @@ -930,6 +930,7 @@ private class VolumeBoostAudioProcessor : BaseAudioProcessor() { } } +@androidx.annotation.OptIn(UnstableApi::class) private class SubtitleOffsetRenderersFactory( context: Context, private val subtitleDelayUsProvider: () -> Long, @@ -947,6 +948,7 @@ private class SubtitleOffsetRenderersFactory( .setAudioProcessors(arrayOf(volumeBoostAudioProcessor)) .build() } + override fun buildTextRenderers( context: Context, output: TextOutput, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt index 5d025f5ed..0e1d419af 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt @@ -133,7 +133,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) { @@ -143,7 +145,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, ), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 6319676f4..78f0ea592 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -98,6 +98,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { ) .playerSurfaceDragGestures( gestureController = gestureController, + playerController = playerController, layoutSize = layoutSize, sideGestureSystemEdgeExclusionPx = sideGestureSystemEdgeExclusionPx, playerControlsLockedState = gestureCallbacks.playerControlsLocked, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt index 83c7944ad..6fe68577c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt @@ -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 @@ -57,3 +59,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 + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt index de46194de..019da7cee 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt @@ -40,6 +40,7 @@ internal fun Modifier.playerSurfaceTapGestures( internal fun Modifier.playerSurfaceDragGestures( gestureController: PlayerGestureController?, + playerController: PlayerEngineController?, layoutSize: IntSize, sideGestureSystemEdgeExclusionPx: Float, playerControlsLockedState: State, @@ -54,7 +55,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) { @@ -87,7 +88,10 @@ internal fun Modifier.playerSurfaceDragGestures( null } val initialVolume = if (region == PlayerSideGesture.Volume) { - controller?.currentVolume() + currentCombinedVolumeLevel( + systemVolume = controller?.currentVolume(), + softwareVolume = playerController?.currentPlayerVolume(), + ) } else { null } @@ -181,8 +185,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() From 80e7373aa20d3d3eb6e99c820a071e2eaf8d1893 Mon Sep 17 00:00:00 2001 From: Luqman Fadlli Date: Sun, 7 Jun 2026 20:38:46 +0700 Subject: [PATCH 6/6] resolve MPVKit submodule --- MPVKit | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MPVKit b/MPVKit index 2e81303b7..97923266e 160000 --- a/MPVKit +++ b/MPVKit @@ -1 +1 @@ -Subproject commit 2e81303b74762424f4187c905d532a716c8e1ff5 +Subproject commit 97923266e43d52bbca33a911fd6a7f9a1bcf35cb