diff --git a/MPVKit b/MPVKit index d5cf091c8..97923266e 160000 --- a/MPVKit +++ b/MPVKit @@ -1 +1 @@ -Subproject commit d5cf091c80368bbbc1bbf2d195fbc55d926df888 +Subproject commit 97923266e43d52bbca33a911fd6a7f9a1bcf35cb 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 5303fe9e2..0fc074438 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 @@ -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(null) } var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf(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, 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 f8bb49054..83182a078 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 setMuted(muted: Boolean) {} fun getAudioTracks(): List fun getSubtitleTracks(): List 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 f1c50cec6..e0768366f 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 @@ -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, ), ) } 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 d83c22fd1..28a069e77 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 @@ -121,6 +121,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 20970103d..7742925be 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 @@ -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 + } +} 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 01818a71e..aae7d4ec3 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, @@ -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() diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt index 670c23b1c..a8f0271ce 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerBridge.kt @@ -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) 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 9c0b0abe2..3e15f81dc 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 @@ -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) } diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 26cac28ab..5bfc334c7 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -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)