feat(vlc): add audio focus handling

This commit is contained in:
2026-07-21 18:12:39 +02:00
parent 66471967e3
commit e6a06b13a4
7 changed files with 215 additions and 535 deletions
@@ -127,9 +127,20 @@ class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Li
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
if (reason == Player.PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS) {
onAudioFocusChangeListeners.forEach { it("loss") }
}
onIsPlayingChanged(player.isPlaying)
}
override fun onPlaybackSuppressionReasonChanged(playbackSuppressionReason: Int) {
val status =
if (playbackSuppressionReason == Player.PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS)
"lossTransient"
else "gain"
onAudioFocusChangeListeners.forEach { it(status) }
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
stateBoolListeners[BoolProperty.ISPLAYING]?.forEach { it(isPlaying) }
}
@@ -172,6 +183,7 @@ class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Li
onErrorListeners.forEach {
it(error.errorCodeName, error.message ?: "unknown message")
}
playerStatusListeners.forEach { it(PlayerStatus.ERROR) }
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
@@ -7,15 +7,17 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.util.Log
import android.view.SurfaceHolder
import androidx.core.app.NotificationCompat
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
import com.margelo.nitro.NitroModules
import com.margelo.nitro.omni.CastStatus
import com.margelo.nitro.omni.HybridOmniPlayerSpec
import com.margelo.nitro.omni.MixAudioMode
import com.margelo.nitro.omni.PlayerStatus
import com.margelo.nitro.omni.Rendition
import com.margelo.nitro.omni.Source
@@ -55,6 +57,13 @@ class OmniPlayer : HybridOmniPlayerSpec() {
field = value
}
// TODO: cast is not implemented on the native (Android) side yet.
override val castStatus: CastStatus get() = CastStatus.UNSUPPORTED
override fun toggleCastStatus() {
// TODO: implement casting on Android.
}
override fun dispose() {
showNotification = false
super.dispose()
@@ -147,9 +156,8 @@ class OmniPlayer : HybridOmniPlayerSpec() {
set = { value -> player.setPlaybackSpeed(value.toFloat().coerceAtLeast(0f)) }
)
var _muted = false
override var muted by mainThreadProperty(
get = { _muted },
get = { player.volume <= 0f },
set = { value -> if (value) player.mute() else player.unmute() }
)
@@ -170,7 +178,12 @@ class OmniPlayer : HybridOmniPlayerSpec() {
}
override fun setSource(src: Source) {
Log.i("omni", "update source")
val handleAudioFocus = (src.mixAudio ?: MixAudioMode.AUTO) != MixAudioMode.MIXWITHOTHERS
val audioAttributes = AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
.build()
runOnMainThread { player.setAudioAttributes(audioAttributes, handleAudioFocus) }
val source = src.src.firstOrNull()
if (source == null) {
runOnMainThreadSync {
@@ -9,7 +9,7 @@ import com.margelo.nitro.omni.Source
class OmniPlayerFactory(val context: ThemedReactContext) : HybridOmniPlayerFactorySpec() {
override fun createPlayer(props: Source): HybridOmniPlayerSpec {
return OmniPlayer().apply {
source = props
setSource(props)
}
}
}
@@ -3,7 +3,6 @@ package dev.zoriya.omni
import android.app.PictureInPictureParams
import android.graphics.Rect
import android.os.Build
import android.util.Log
import android.util.Rational
import android.view.SurfaceHolder
import android.view.SurfaceView
@@ -16,6 +15,7 @@ import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.uimanager.ThemedReactContext
import com.margelo.nitro.omni.HybridOmniPlayerSpec
import com.margelo.nitro.omni.HybridOmniViewSpec
import com.margelo.nitro.omni.SubtitleAssets
import java.lang.ref.WeakReference
class OmniView(val context: ThemedReactContext) :
@@ -87,6 +87,9 @@ class OmniView(val context: ThemedReactContext) :
override var autoplay: Boolean? = true
override var autoPip: Boolean? = true
// Web-only; the native view uses embedded fonts and renders subtitles via VLC.
override var subtitleAssets: SubtitleAssets? = null
init {
context.addLifecycleEventListener(this)
}
@@ -108,8 +111,6 @@ class OmniView(val context: ThemedReactContext) :
}
override fun afterUpdate() {
Log.e("omniView", "After update called ${System.identityHashCode(this)}")
val curPip = activeView.get()
when {
autoPip == true && curPip == this -> {}
@@ -214,7 +215,6 @@ class OmniView(val context: ThemedReactContext) :
return
}
Log.e("omni", "layout change")
val autoEnterEnabled =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
autoPip == true &&
@@ -2,12 +2,16 @@ package dev.zoriya.omni
import android.annotation.SuppressLint
import android.content.Context
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.Surface
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.TextureView
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import androidx.media3.common.AudioAttributes
import androidx.media3.common.BasePlayer
@@ -43,9 +47,22 @@ import org.videolan.libvlc.interfaces.IMedia.VideoTrack
import org.videolan.libvlc.interfaces.IVLCVout
@SuppressLint("UnsafeOptInUsageError")
class VlcPlayer(ctx: Context) : BasePlayer(), MediaPlayer.EventListener {
class VlcPlayer(ctx: Context) :
BasePlayer(),
MediaPlayer.EventListener,
AudioManager.OnAudioFocusChangeListener {
private val applicationLooper: Looper = Looper.getMainLooper()
private val applicationHandler = Handler(applicationLooper)
private val audioManager =
ContextCompat.getSystemService(ctx.applicationContext, AudioManager::class.java)
private var audioFocusRequest: AudioFocusRequest? = null
private var hasAudioFocus = false
private var handleAudioFocus = false
private var mediaAudioAttributes: AudioAttributes = AudioAttributes.DEFAULT
private var playbackSuppression = PLAYBACK_SUPPRESSION_REASON_NONE
private val listeners = ListenerSet<Player.Listener>(
applicationLooper,
Clock.DEFAULT
@@ -120,6 +137,7 @@ class VlcPlayer(ctx: Context) : BasePlayer(), MediaPlayer.EventListener {
}
MediaPlayer.Event.Playing -> {
if (!requestAudioFocus()) applicationHandler.post { player.pause() }
notifyListeners(
arrayOf(
EVENT_TIMELINE_CHANGED,
@@ -147,19 +165,24 @@ class VlcPlayer(ctx: Context) : BasePlayer(), MediaPlayer.EventListener {
}
MediaPlayer.Event.Stopped -> {
abandonAudioFocus()
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(STATE_IDLE)
}
}
MediaPlayer.Event.EndReached -> {
abandonAudioFocus()
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(STATE_ENDED)
}
}
MediaPlayer.Event.Buffering -> {
cachedBufferedPosition = (event.buffering * getDuration()).toLong()
val dur = getDuration()
if (dur != TIME_UNSET) {
cachedBufferedPosition = (event.buffering / 100.0 * dur).toLong()
}
notifyListeners(arrayOf(EVENT_IS_LOADING_CHANGED, EVENT_PLAYBACK_STATE_CHANGED)) {
it.onIsLoadingChanged(true)
it.onPlaybackStateChanged(STATE_BUFFERING)
@@ -193,7 +216,7 @@ class VlcPlayer(ctx: Context) : BasePlayer(), MediaPlayer.EventListener {
INDEX_UNSET,
INDEX_UNSET
)
it.onPositionDiscontinuity(position, position, DISCONTINUITY_REASON_SEEK)
it.onPositionDiscontinuity(position, position, DISCONTINUITY_REASON_INTERNAL)
}
}
@@ -213,6 +236,78 @@ class VlcPlayer(ctx: Context) : BasePlayer(), MediaPlayer.EventListener {
}
}
override fun onAudioFocusChange(focusChange: Int) {
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
hasAudioFocus = true
if (playbackSuppression != PLAYBACK_SUPPRESSION_REASON_NONE) {
setPlaybackSuppression(PLAYBACK_SUPPRESSION_REASON_NONE)
player.play()
}
}
AudioManager.AUDIOFOCUS_LOSS -> {
hasAudioFocus = false
setPlaybackSuppression(PLAYBACK_SUPPRESSION_REASON_NONE)
player.pause()
notifyListeners(arrayOf(EVENT_PLAY_WHEN_READY_CHANGED, EVENT_IS_PLAYING_CHANGED)) {
it.onPlayWhenReadyChanged(false, PLAY_WHEN_READY_CHANGE_REASON_AUDIO_FOCUS_LOSS)
it.onIsPlayingChanged(false)
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
setPlaybackSuppression(PLAYBACK_SUPPRESSION_REASON_TRANSIENT_AUDIO_FOCUS_LOSS)
player.pause()
}
}
}
private fun setPlaybackSuppression(reason: Int) {
if (playbackSuppression == reason) return
playbackSuppression = reason
notifyListeners(EVENT_PLAYBACK_SUPPRESSION_REASON_CHANGED) {
it.onPlaybackSuppressionReasonChanged(reason)
}
}
private fun requestAudioFocus(): Boolean {
val audioManager = audioManager ?: return true
if (!handleAudioFocus) {
abandonAudioFocus()
return true
}
if (hasAudioFocus) return true
val result = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val request = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(mediaAudioAttributes.platformAudioAttributes)
.setOnAudioFocusChangeListener(this, applicationHandler)
.build()
audioFocusRequest = request
audioManager.requestAudioFocus(request)
} else {
@Suppress("DEPRECATION")
audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
}
hasAudioFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
return hasAudioFocus
}
private fun abandonAudioFocus() {
val audioManager = audioManager ?: return
if (!hasAudioFocus && audioFocusRequest == null) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) }
audioFocusRequest = null
} else {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(this)
}
hasAudioFocus = false
}
override fun getApplicationLooper(): Looper = applicationLooper
override fun addListener(listener: Player.Listener) {
@@ -336,7 +431,7 @@ class VlcPlayer(ctx: Context) : BasePlayer(), MediaPlayer.EventListener {
else -> STATE_READY
}
override fun getPlaybackSuppressionReason(): Int = PLAYBACK_SUPPRESSION_REASON_NONE
override fun getPlaybackSuppressionReason(): Int = playbackSuppression
override fun getPlayerError(): PlaybackException? = playerError
@@ -368,6 +463,7 @@ class VlcPlayer(ctx: Context) : BasePlayer(), MediaPlayer.EventListener {
if (targetIndex != currentMediaItemIndex) {
userInitiatedTransition = true
setMediaItems(mediaItems, targetIndex, positionMs)
player.play()
return
}
@@ -391,6 +487,7 @@ class VlcPlayer(ctx: Context) : BasePlayer(), MediaPlayer.EventListener {
override fun release() {
player.setEventListener(null)
abandonAudioFocus()
player.stop()
clearVideoSurface()
player.release()
@@ -651,20 +748,30 @@ class VlcPlayer(ctx: Context) : BasePlayer(), MediaPlayer.EventListener {
override fun getContentBufferedPosition() = getBufferedPosition()
override fun getAudioAttributes(): AudioAttributes = AudioAttributes.DEFAULT
override fun getAudioAttributes(): AudioAttributes = mediaAudioAttributes
private var volumeBeforeMute: Float = 1f
override fun setVolume(volume: Float) {
player.volume = (volume * 100).toInt()
player.volume = (volume.coerceIn(0f, 1f) * 100).toInt()
notifyVolumeChanged()
}
override fun getVolume(): Float = player.volume / 100f
override fun getVolume(): Float = (player.volume / 100f).coerceIn(0f, 1f)
override fun mute() {
val current = getVolume()
if (current > 0f) volumeBeforeMute = current
player.volume = 0
notifyVolumeChanged()
}
override fun unmute() {
player.volume = 100
setVolume(volumeBeforeMute.takeIf { it > 0f } ?: 1f)
}
private fun notifyVolumeChanged() {
notifyListeners(EVENT_VOLUME_CHANGED) { it.onVolumeChanged(getVolume()) }
}
override fun clearVideoSurface() {
@@ -746,6 +853,16 @@ class VlcPlayer(ctx: Context) : BasePlayer(), MediaPlayer.EventListener {
override fun setDeviceMuted(muted: Boolean, flags: Int) = Unit
override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) =
Unit
override fun setAudioAttributes(audioAttributes: AudioAttributes, handleAudioFocus: Boolean) {
mediaAudioAttributes = audioAttributes
this.handleAudioFocus = handleAudioFocus
if (!handleAudioFocus) {
abandonAudioFocus()
} else if (player.isPlaying && !requestAudioFocus()) {
applicationHandler.post { player.pause() }
}
notifyListeners(EVENT_AUDIO_ATTRIBUTES_CHANGED) {
it.onAudioAttributesChanged(audioAttributes)
}
}
}