mirror of
https://github.com/NuvioMedia/NuvioMobile.git
synced 2026-07-26 17:12:16 +00:00
Compare commits
18 commits
31d6272091
...
a0a47de629
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0a47de629 | ||
|
|
978c775954 | ||
|
|
2465d4e6b2 | ||
|
|
a495222f12 | ||
|
|
a3e309fef9 | ||
|
|
83bc15441b | ||
|
|
bfd8c3483e | ||
|
|
240cde9db3 | ||
|
|
80e7373aa2 | ||
|
|
32fbe672a5 | ||
|
|
86e35fd2fa | ||
|
|
645a5e58a7 | ||
|
|
dc003852e1 | ||
|
|
220bef6d1a | ||
|
|
c075178ff8 | ||
|
|
65a8162cbb | ||
|
|
3afd58c3c1 | ||
|
|
123549a1a5 |
10 changed files with 202 additions and 7 deletions
2
MPVKit
2
MPVKit
|
|
@ -1 +1 @@
|
|||
Subproject commit d5cf091c80368bbbc1bbf2d195fbc55d926df888
|
||||
Subproject commit 97923266e43d52bbca33a911fd6a7f9a1bcf35cb
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
|
|||
)
|
||||
.playerSurfaceDragGestures(
|
||||
gestureController = gestureController,
|
||||
playerController = playerController,
|
||||
layoutSize = layoutSize,
|
||||
sideGestureSystemEdgeExclusionPx = sideGestureSystemEdgeExclusionPx,
|
||||
playerControlsLockedState = gestureCallbacks.playerControlsLocked,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue