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) {