Compare commits

...
9 Commits
15 changed files with 259 additions and 117 deletions
+9 -10
View File
@@ -5,7 +5,7 @@ buildscript {
} }
dependencies { dependencies {
classpath "com.android.tools.build:gradle:8.8.0" classpath "com.android.tools.build:gradle:9.2.1"
} }
} }
@@ -143,16 +143,15 @@ dependencies {
// Add a dependency on NitroModules // Add a dependency on NitroModules
implementation project(":react-native-nitro-modules") implementation project(":react-native-nitro-modules")
implementation("org.videolan.android:libvlc-all:4.0.0-eap24") implementation("org.videolan.android:libvlc-all:4.0.0-eap29")
implementation("androidx.media3:media3-common:1.4.1") implementation("androidx.media3:media3-common:1.11.0")
implementation("androidx.media3:media3-session:1.4.1") implementation("androidx.media3:media3-session:1.11.0")
implementation("androidx.media3:media3-ui:1.4.1") implementation("androidx.media3:media3-ui:1.11.0")
implementation 'androidx.media3:media3-exoplayer:1.10.0' implementation 'androidx.media3:media3-exoplayer:1.11.0'
implementation("androidx.media3:media3-exoplayer-hls:1.10.0") implementation("androidx.media3:media3-exoplayer-hls:1.11.0")
implementation("androidx.media3:media3-cast:1.11.0")
implementation("androidx.media3:media3-cast:1.10.0")
implementation("com.google.android.gms:play-services-cast-framework:22.3.1") implementation("com.google.android.gms:play-services-cast-framework:22.3.1")
implementation("androidx.mediarouter:mediarouter:1.7.0") implementation("androidx.mediarouter:mediarouter:1.8.1")
} }
if (isNewArchitectureEnabled()) { if (isNewArchitectureEnabled()) {
@@ -5,12 +5,9 @@ import androidx.media3.common.C
import androidx.media3.common.C.TRACK_TYPE_AUDIO import androidx.media3.common.C.TRACK_TYPE_AUDIO
import androidx.media3.common.C.TRACK_TYPE_TEXT import androidx.media3.common.C.TRACK_TYPE_TEXT
import androidx.media3.common.C.TRACK_TYPE_VIDEO import androidx.media3.common.C.TRACK_TYPE_VIDEO
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.PlaybackParameters import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
import androidx.media3.common.Player.STATE_BUFFERING import androidx.media3.common.Player.STATE_BUFFERING
import androidx.media3.common.Player.STATE_ENDED import androidx.media3.common.Player.STATE_ENDED
import androidx.media3.common.Player.STATE_IDLE import androidx.media3.common.Player.STATE_IDLE
@@ -59,7 +56,6 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
private val castStatusListeners = mutableSetOf<(CastStatus) -> Unit>() private val castStatusListeners = mutableSetOf<(CastStatus) -> Unit>()
private val sourceListeners = mutableSetOf<(Source?) -> Unit>() private val sourceListeners = mutableSetOf<(Source?) -> Unit>()
private var lastMediaItemIndex = 0
private var lastRendition: Rendition? = null private var lastRendition: Rendition? = null
private var lastRenditions: Array<Rendition>? = null private var lastRenditions: Array<Rendition>? = null
private var lastIsAutoQuality: Boolean? = null private var lastIsAutoQuality: Boolean? = null
@@ -73,7 +69,6 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
_player?.removeListener(this) _player?.removeListener(this)
_player = value _player = value
value.addListener(this) value.addListener(this)
lastMediaItemIndex = value.currentMediaItemIndex
lastRendition = null lastRendition = null
lastRenditions = null lastRenditions = null
lastIsAutoQuality = null lastIsAutoQuality = null
@@ -116,6 +111,14 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
sourceListeners.forEach { it(source) } sourceListeners.forEach { it(source) }
} }
fun emitPrev() {
onPrevListeners.forEach { it() }
}
fun emitNext() {
onNextListeners.forEach { it() }
}
private fun selectedTrack(trackType: Int): Track? { private fun selectedTrack(trackType: Int): Track? {
val list = when (trackType) { val list = when (trackType) {
TRACK_TYPE_VIDEO -> tracks.videos TRACK_TYPE_VIDEO -> tracks.videos
@@ -278,20 +281,6 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
playerStatusListeners.forEach { it(PlayerStatus.ERROR) } playerStatusListeners.forEach { it(PlayerStatus.ERROR) }
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (reason != MEDIA_ITEM_TRANSITION_REASON_AUTO && reason != MEDIA_ITEM_TRANSITION_REASON_SEEK) {
lastMediaItemIndex = player.currentMediaItemIndex
return
}
val newIndex = player.currentMediaItemIndex
if (newIndex < lastMediaItemIndex) {
onPrevListeners.forEach { it() }
} else if (newIndex > lastMediaItemIndex) {
onNextListeners.forEach { it() }
}
lastMediaItemIndex = newIndex
}
override fun onPositionDiscontinuity( override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo, oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo, newPosition: Player.PositionInfo,
@@ -0,0 +1,51 @@
package dev.zoriya.omni
import androidx.annotation.OptIn
import androidx.media3.common.ForwardingSimpleBasePlayer
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
@OptIn(UnstableApi::class)
class NavigationPlayer(
player: Player,
private val hasPrev: () -> Boolean,
private val hasNext: () -> Boolean,
private val onPrev: () -> Unit,
private val onNext: () -> Unit,
) : ForwardingSimpleBasePlayer(player) {
override fun getState(): State {
val state = super.getState()
return state.buildUpon()
.setAvailableCommands(
state.availableCommands.buildUpon()
.addIf(Player.COMMAND_SEEK_TO_PREVIOUS, hasPrev())
.addIf(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, hasPrev())
.addIf(Player.COMMAND_SEEK_TO_NEXT, hasNext())
.addIf(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, hasNext())
.build()
)
.build()
}
override fun handleSeek(
mediaItemIndex: Int,
positionMs: Long,
seekCommand: Int,
): ListenableFuture<*> = when (seekCommand) {
Player.COMMAND_SEEK_TO_PREVIOUS,
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> {
onPrev()
Futures.immediateVoidFuture()
}
Player.COMMAND_SEEK_TO_NEXT,
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> {
onNext()
Futures.immediateVoidFuture()
}
else -> super.handleSeek(mediaItemIndex, positionMs, seekCommand)
}
}
@@ -3,6 +3,7 @@ package dev.zoriya.omni
import android.content.Context import android.content.Context
import com.google.android.gms.cast.CastMediaControlIntent import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.framework.CastOptions import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.media.CastMediaOptions
import com.google.android.gms.cast.framework.OptionsProvider import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider import com.google.android.gms.cast.framework.SessionProvider
@@ -11,9 +12,15 @@ class OmniCastOptionsProvider : OptionsProvider {
val appId = OmniPlayer.receiverApplicationId val appId = OmniPlayer.receiverApplicationId
?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID ?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
val mediaOptions = CastMediaOptions.Builder()
.setMediaSessionEnabled(false)
.setNotificationOptions(null)
.build()
return CastOptions.Builder() return CastOptions.Builder()
.setReceiverApplicationId(appId) .setReceiverApplicationId(appId)
.setShowSystemOutputSwitcherOnCastIconClick(true) .setShowSystemOutputSwitcherOnCastIconClick(true)
.setCastMediaOptions(mediaOptions)
.build() .build()
} }
@@ -60,13 +60,18 @@ class OmniCastTrackSelector : CastTrackSelector() {
val overrideMatch = indices.firstOrNull { i -> val overrideMatch = indices.firstOrNull { i ->
overrides.keys.any { it.id == groups[i].id } overrides.keys.any { it.id == groups[i].id }
} }
val match = indices.firstOrNull { i -> val matchesLabel = { i: Int ->
val name: String? = tracks[i].name val name: String? = tracks[i].name
val language: String? = tracks[i].language labels.isNotEmpty() && name != null && name in labels
(labels.isNotEmpty() && name != null && name in labels) ||
(languages.isNotEmpty() && language != null &&
Util.normalizeLanguageCode(language) in languages)
} }
val matchesLanguage = { i: Int ->
val language: String? = tracks[i].language
languages.isNotEmpty() && language != null &&
Util.normalizeLanguageCode(language) in languages
}
val match = indices.firstOrNull { matchesLabel(it) && matchesLanguage(it) }
?: indices.firstOrNull { matchesLabel(it) }
?: indices.firstOrNull { matchesLanguage(it) }
val chosen = when { val chosen = when {
overrideMatch != null -> overrideMatch overrideMatch != null -> overrideMatch
match != null -> match match != null -> match
@@ -24,8 +24,15 @@ class OmniMediaItemConverter : MediaItemConverter {
builder.setMediaMetadata( builder.setMediaMetadata(
androidx.media3.common.MediaMetadata.Builder() androidx.media3.common.MediaMetadata.Builder()
.setTitle(md.getString(CastMediaMetadata.KEY_TITLE)) .setTitle(md.getString(CastMediaMetadata.KEY_TITLE))
.setArtist(md.getString(CastMediaMetadata.KEY_ARTIST)) .setArtist(
.setAlbumTitle(md.getString(CastMediaMetadata.KEY_ALBUM_TITLE)) md.getString(CastMediaMetadata.KEY_ARTIST)
?: md.getString(CastMediaMetadata.KEY_SUBTITLE)
)
.setAlbumTitle(
md.getString(CastMediaMetadata.KEY_ALBUM_TITLE)
?: md.getString(CastMediaMetadata.KEY_STUDIO)
)
.setArtworkUri(md.images.firstOrNull()?.url)
.build() .build()
) )
} }
@@ -1,6 +1,8 @@
package dev.zoriya.omni package dev.zoriya.omni
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.os.Looper
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -13,11 +15,16 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.text.TextRenderer
import com.margelo.nitro.NitroModules import com.margelo.nitro.NitroModules
import com.margelo.nitro.omni.AndroidBackend import com.margelo.nitro.omni.AndroidBackend
import com.margelo.nitro.omni.CastStatus import com.margelo.nitro.omni.CastStatus
@@ -62,9 +69,26 @@ class OmniPlayer(
AndroidBackend.EXOPLAYER -> { AndroidBackend.EXOPLAYER -> {
val http = DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true) val http = DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
httpDataSourceFactory = http httpDataSourceFactory = http
ExoPlayer.Builder(ctx) // karaoke subs make exoplayer crash without this...
val renderersFactory = object : DefaultRenderersFactory(ctx) {
override fun buildTextRenderers(
context: Context,
output: TextOutput,
outputLooper: Looper,
extensionRendererMode: Int,
out: ArrayList<Renderer>,
) {
out.add(
TextRenderer(output, outputLooper).apply {
experimentalSetLegacyDecodingEnabled(true)
}
)
}
}
ExoPlayer.Builder(ctx, renderersFactory)
.setMediaSourceFactory( .setMediaSourceFactory(
DefaultMediaSourceFactory(DefaultDataSource.Factory(ctx, http)) DefaultMediaSourceFactory(DefaultDataSource.Factory(ctx, http))
.experimentalParseSubtitlesDuringExtraction(false)
) )
.build() .build()
} }
@@ -75,8 +99,27 @@ class OmniPlayer(
override val eventMap = EventMap(this) override val eventMap = EventMap(this)
private var castContext: CastContext? = null private var castContext: CastContext? = null
private val castStateListener = private val castStateListener = CastStateListener {
CastStateListener { eventMap.emitCastStatus(computeCastStatus()) } eventMap.emitCastStatus(computeCastStatus())
listenToReceiver()
}
// receivers have no queue to skip in (a queue would make them play things on
// their own), so they ask us to do it when something else - google home, the
// assistant, ... - requests a prev/next. same path as our own buttons.
private fun listenToReceiver() {
val session = castContext?.sessionManager?.currentCastSession ?: return
try {
session.setMessageReceivedCallbacks(OMNI_NAMESPACE) { _, _, message ->
when (JSONObject(message).optString("action")) {
"prev" -> runOnMainThread { eventMap.emitPrev() }
"next" -> runOnMainThread { eventMap.emitNext() }
}
}
} catch (e: Throwable) {
android.util.Log.w("OmniPlayer", "could not listen to the receiver", e)
}
}
val player: Player = runOnMainThreadSync { val player: Player = runOnMainThreadSync {
castOptions?.receiverApplicationId?.let { receiverApplicationId = it } castOptions?.receiverApplicationId?.let { receiverApplicationId = it }
@@ -100,7 +143,13 @@ class OmniPlayer(
.setRemotePlayer(remote) .setRemotePlayer(remote)
.build() .build()
} }
active NavigationPlayer(
active,
hasPrev = { source?.metadata?.hasPrev == true },
hasNext = { source?.metadata?.hasNext == true },
onPrev = { eventMap.emitPrev() },
onNext = { eventMap.emitNext() },
)
} }
override var showNotification: Boolean? = false override var showNotification: Boolean? = false
@@ -116,19 +165,19 @@ class OmniPlayer(
when { when {
shouldShow && !serviceRunning -> { shouldShow && !serviceRunning -> {
val otherIsPlaying = notificationPlayer?.let { other -> val otherIsPlaying = notificationPlayer?.let { other ->
other !== localPlayer && runOnMainThreadSync { other.isPlaying } other !== player && runOnMainThreadSync { other.isPlaying }
} == true } == true
if (otherIsPlaying) { if (otherIsPlaying) {
throw Error("Two players can't display notifications at the same time.") throw Error("Two players can't display notifications at the same time.")
} }
notificationPlayer = localPlayer notificationPlayer = player
ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java)) ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java))
serviceRunning = true serviceRunning = true
} }
!shouldShow && serviceRunning -> { !shouldShow && serviceRunning -> {
ctx.stopService(Intent(ctx, OmniPlayerService::class.java)) ctx.stopService(Intent(ctx, OmniPlayerService::class.java))
if (notificationPlayer == localPlayer) notificationPlayer = null if (notificationPlayer == player) notificationPlayer = null
serviceRunning = false serviceRunning = false
} }
} }
@@ -187,6 +236,19 @@ class OmniPlayer(
super.dispose() super.dispose()
} }
private fun inferSubtitleMimeType(link: String): String? {
val ext = link.toUri().lastPathSegment?.substringAfterLast('.', "")?.lowercase()
return when (ext) {
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
"srt" -> MimeTypes.APPLICATION_SUBRIP
"ass", "ssa" -> MimeTypes.TEXT_SSA
"ttml", "dfxp", "xml" -> MimeTypes.APPLICATION_TTML
"sup" -> MimeTypes.APPLICATION_PGS
"sub" -> MimeTypes.APPLICATION_VOBSUB
else -> null
}
}
private fun buildMediaItem( private fun buildMediaItem(
src: com.margelo.nitro.omni.VideoSrc, src: com.margelo.nitro.omni.VideoSrc,
metadata: com.margelo.nitro.omni.Metadata?, metadata: com.margelo.nitro.omni.Metadata?,
@@ -212,7 +274,10 @@ class OmniPlayer(
.setId(subtitle.id) .setId(subtitle.id)
.setLanguage(subtitle.language) .setLanguage(subtitle.language)
.setLabel(subtitle.label) .setLabel(subtitle.label)
.setMimeType(subtitle.mimeType) // exoplayer requires a mime type to pick a subtitle parser.
// fall back to inferring it from the link's extension so
// consumers don't have to provide one.
.setMimeType(subtitle.mimeType ?: inferSubtitleMimeType(subtitle.link))
.build() .build()
}) })
.setRequestMetadata( .setRequestMetadata(
@@ -258,8 +323,8 @@ class OmniPlayer(
} }
} }
override val hasPrev: Boolean get() = player.hasPreviousMediaItem() override val hasPrev: Boolean get() = source?.metadata?.hasPrev == true
override val hasNext: Boolean get() = player.hasNextMediaItem() override val hasNext: Boolean get() = source?.metadata?.hasNext == true
override val status by mainThreadProperty { override val status by mainThreadProperty {
when (player.playbackState) { when (player.playbackState) {
Player.STATE_IDLE, Player.STATE_IDLE,
@@ -377,15 +442,10 @@ class OmniPlayer(
value.castId, value.castId,
value.castData, value.castData,
) )
val mediaItems = mutableListOf<MediaItem>()
if (value.metadata?.hasPrev == true) mediaItems.add(currentItem)
mediaItems.add(currentItem)
if (value.metadata?.hasNext == true) mediaItems.add(currentItem)
runOnMainThreadSync { runOnMainThreadSync {
val startIndex = if (value.metadata?.hasPrev == true) 1 else 0
val startPositionMs = ((value.startTime ?: 0.0).coerceAtLeast(0.0)) * 1000.0 val startPositionMs = ((value.startTime ?: 0.0).coerceAtLeast(0.0)) * 1000.0
player.setMediaItems(mediaItems, startIndex, startPositionMs.toLong()) player.setMediaItems(listOf(currentItem), 0, startPositionMs.toLong())
player.prepare() player.prepare()
} }
syncNotificationService() syncNotificationService()
@@ -407,11 +467,11 @@ class OmniPlayer(
} }
override fun playPrev() { override fun playPrev() {
runOnMainThreadSync { player.seekToPreviousMediaItem() } runOnMainThreadSync { eventMap.emitPrev() }
} }
override fun playNext() { override fun playNext() {
runOnMainThreadSync { player.seekToNextMediaItem() } runOnMainThreadSync { eventMap.emitNext() }
} }
override fun selectVideo(video: Track) { override fun selectVideo(video: Track) {
@@ -441,15 +501,15 @@ class OmniPlayer(
}?.mediaTrackGroup }?.mediaTrackGroup
if (group != null) { if (group != null) {
params.setOverrideForType(TrackSelectionOverride(group, 0)) params.setOverrideForType(TrackSelectionOverride(group, 0))
}
// also set preferred flags for cast selection
val labels = track.label?.let { arrayOf(it) } ?: emptyArray()
if (type == C.TRACK_TYPE_AUDIO) {
params.setPreferredAudioLanguage(track.language)
.setPreferredAudioLabels(*labels)
} else { } else {
val labels = track.label?.let { arrayOf(it) } ?: emptyArray() params.setPreferredTextLanguage(track.language)
if (type == C.TRACK_TYPE_AUDIO) { .setPreferredTextLabels(*labels)
params.setPreferredAudioLanguage(track.language)
.setPreferredAudioLabels(*labels)
} else {
params.setPreferredTextLanguage(track.language)
.setPreferredTextLabels(*labels)
}
} }
} }
player.trackSelectionParameters = params.build() player.trackSelectionParameters = params.build()
@@ -523,6 +583,10 @@ class OmniPlayer(
const val CAST_ID_EXTRA = "dev.zoriya.omni.castId" const val CAST_ID_EXTRA = "dev.zoriya.omni.castId"
const val CAST_DATA_EXTRA = "dev.zoriya.omni.castData" const val CAST_DATA_EXTRA = "dev.zoriya.omni.castData"
// channel a receiver uses to ask us to play the prev/next media.
// it must declare it too (`customNamespaces` in the caf options).
const val OMNI_NAMESPACE = "urn:x-cast:dev.zoriya.omni"
// Receiver application id passed at runtime via OmniProvider's `cast` // Receiver application id passed at runtime via OmniProvider's `cast`
// prop; read by OmniCastOptionsProvider when the Cast SDK initializes. // prop; read by OmniCastOptionsProvider when the Cast SDK initializes.
var receiverApplicationId: String? = null var receiverApplicationId: String? = null
@@ -244,6 +244,7 @@ class OmniView(val context: ThemedReactContext) :
} }
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) scheduleVideoRebuild()
updatePictureInPictureParams() updatePictureInPictureParams()
} }
@@ -377,22 +378,16 @@ class OmniView(val context: ThemedReactContext) :
movedSurfaceToRootForPip = false movedSurfaceToRootForPip = false
} }
// A surface that is destroyed and recreated while playback runs (the PIP private val rebuildRunnable = Runnable { boundPlayer?.rebuildVideoOutput() }
// reparent) makes VLC drop its video pipeline; it won't rebuild it for the
// new surface on its own, so we must ask it to. The rebuild only succeeds private fun scheduleVideoRebuild() {
// once the (PIP) window has settled at its final size — firing it mid-resize surfaceView.removeCallbacks(rebuildRunnable)
// leaves the vout stopped — so it is debounced off surfaceChanged. surfaceView.postDelayed(rebuildRunnable, 200)
private var pendingVideoRebuild = false
private val rebuildRunnable = Runnable {
if (!pendingVideoRebuild) return@Runnable
pendingVideoRebuild = false
boundPlayer?.rebuildVideoOutput()
} }
override fun surfaceCreated(holder: SurfaceHolder) { override fun surfaceCreated(holder: SurfaceHolder) {
surfaceReady = true surfaceReady = true
boundPlayer?.setVideoView(surfaceView) boundPlayer?.setVideoView(surfaceView)
if (boundPlayer?.localPlayer?.isPlaying == true) pendingVideoRebuild = true
updatePictureInPictureParams() updatePictureInPictureParams()
} }
@@ -403,10 +398,7 @@ class OmniView(val context: ThemedReactContext) :
height: Int height: Int
) { ) {
boundPlayer?.updateVideoLayout(width, height) boundPlayer?.updateVideoLayout(width, height)
if (pendingVideoRebuild) { scheduleVideoRebuild()
surfaceView.removeCallbacks(rebuildRunnable)
surfaceView.postDelayed(rebuildRunnable, 200)
}
} }
override fun surfaceDestroyed(holder: SurfaceHolder) { override fun surfaceDestroyed(holder: SurfaceHolder) {
@@ -46,6 +46,7 @@ import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.interfaces.IMedia.VideoTrack import org.videolan.libvlc.interfaces.IMedia.VideoTrack
import org.videolan.libvlc.interfaces.IVLCVout import org.videolan.libvlc.interfaces.IVLCVout
import java.security.MessageDigest
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
class VlcPlayer(ctx: Context) : class VlcPlayer(ctx: Context) :
@@ -106,6 +107,7 @@ class VlcPlayer(ctx: Context) :
@Volatile @Volatile
private var cachedBufferedPosition: Long = 0L private var cachedBufferedPosition: Long = 0L
private var boundSurfaceView: SurfaceView? = null private var boundSurfaceView: SurfaceView? = null
private var videoOutputStale = false
private var lastVideoSize: VideoSize = VideoSize.UNKNOWN private var lastVideoSize: VideoSize = VideoSize.UNKNOWN
private val availableCommands: Player.Commands = Player.Commands.Builder() private val availableCommands: Player.Commands = Player.Commands.Builder()
@@ -347,6 +349,7 @@ class VlcPlayer(ctx: Context) :
playerError = null playerError = null
player.stop() player.stop()
videoOutputStale = false
mediaItems.getOrNull(targetIndex)?.let { item -> mediaItems.getOrNull(targetIndex)?.let { item ->
val uri = item.localConfiguration?.uri?.toString() val uri = item.localConfiguration?.uri?.toString()
@@ -387,6 +390,20 @@ class VlcPlayer(ctx: Context) :
} }
} }
// vlc keys each slave's tracks by the md5 of its uri ("<md5(uri)>/spu/<n>")
private fun subtitleSlaveForTrackId(trackId: String): MediaItem.SubtitleConfiguration? {
val hash = trackId.substringBefore("/spu/", "")
if (hash.isEmpty()) return null
return mediaItems.getOrNull(currentMediaItemIndex)
?.localConfiguration?.subtitleConfigurations
?.firstOrNull { md5(it.uri.toString()) == hash }
}
private fun md5(value: String): String =
MessageDigest.getInstance("MD5")
.digest(value.toByteArray())
.joinToString("") { "%02x".format(it.toInt() and 0xFF) }
// vlc doesn't allow arbitrary headers :c // vlc doesn't allow arbitrary headers :c
private fun applyRequestHeaders(media: Media, extras: Bundle?) { private fun applyRequestHeaders(media: Media, extras: Bundle?) {
if (extras == null) return if (extras == null) return
@@ -599,10 +616,12 @@ class VlcPlayer(ctx: Context) :
} }
player.getTracks(IMedia.Track.Type.Text)?.forEach { track -> player.getTracks(IMedia.Track.Type.Text)?.forEach { track ->
// external subs don't have label/language, match them back to surface them
val slave = subtitleSlaveForTrackId(track.id)
val format = Format.Builder() val format = Format.Builder()
.setId(track.id) .setId(track.id)
.setLabel(track.name) .setLabel(slave?.label ?: track.name)
.setLanguage(track.language) .setLanguage(slave?.language ?: track.language)
.setSampleMimeType("text/x-unknown") .setSampleMimeType("text/x-unknown")
.build() .build()
val group = TrackGroup("vlc-sub-${track.id}", format) val group = TrackGroup("vlc-sub-${track.id}", format)
@@ -880,30 +899,22 @@ class VlcPlayer(ctx: Context) :
if (surfaceView == null) return clearVideoSurface() if (surfaceView == null) return clearVideoSurface()
if (vlcVout.areViewsAttached() && boundSurfaceView === surfaceView) return if (vlcVout.areViewsAttached() && boundSurfaceView === surfaceView) return
boundSurfaceView = surfaceView boundSurfaceView = surfaceView
// Hand VLC the SurfaceView (not a raw Surface) so it reads the real view videoOutputStale = (
// size and keeps the video layout correct as the surface resizes (e.g. the player.playerState == IMedia.State.Playing ||
// PIP window shrinking/growing); see updateVideoLayout for size updates. player.playerState == IMedia.State.Paused
)
vlcVout.setVideoView(surfaceView) vlcVout.setVideoView(surfaceView)
vlcVout.attachViews() vlcVout.attachViews()
} }
/**
* VLC tears down its video decoder + output whenever the output Surface is
* destroyed (e.g. the SurfaceView being reparented for PIP) and does not
* rebuild them when a new Surface arrives — the picture stays black. Toggling
* the video track forces VLC to spin up a fresh decoder/vout against the
* currently-attached Surface. Safe no-op when nothing is attached/playing.
*/
fun rebuildVideoOutput() { fun rebuildVideoOutput() {
if (!vlcVout.areViewsAttached() || !player.isPlaying) return if (!videoOutputStale || !player.isPlaying || !vlcVout.areViewsAttached()) return
videoOutputStale = false
player.setVideoTrackEnabled(false) player.setVideoTrackEnabled(false)
player.setVideoTrackEnabled(true) player.setVideoTrackEnabled(true)
player.updateVideoSurfaces() player.updateVideoSurfaces()
} }
/** Recompute the video layout for the current surface size. Must run whenever
* the surface resizes (VLC latches a stale geometry otherwise -> the picture
* renders at the wrong size, anchored in a corner). */
fun updateVideoLayout(width: Int, height: Int) { fun updateVideoLayout(width: Int, height: Int) {
if (!vlcVout.areViewsAttached()) return if (!vlcVout.areViewsAttached()) return
if (width > 0 && height > 0) vlcVout.setWindowSize(width, height) if (width > 0 && height > 0) vlcVout.setWindowSize(width, height)
+13 -6
View File
@@ -6,7 +6,12 @@
"name": "react-native-omni", "name": "react-native-omni",
"dependencies": { "dependencies": {
"@expo/config-plugins": "^57.0.3", "@expo/config-plugins": "^57.0.3",
"@videojs/react": "10.0.0-beta.25", "@videojs/core": "github:zoriya/v10#build26-core",
"@videojs/media": "github:zoriya/v10#build26-media",
"@videojs/react": "github:zoriya/v10#build26-react",
"@videojs/spf": "github:zoriya/v10#build26-spf",
"@videojs/store": "github:zoriya/v10#build26-store",
"@videojs/utils": "github:zoriya/v10#build26-utils",
"jassub": "^2.5.7", "jassub": "^2.5.7",
"libpgs": "^0.8.1", "libpgs": "^0.8.1",
}, },
@@ -723,15 +728,17 @@
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
"@videojs/core": ["@videojs/core@10.0.0-beta.25", "", { "dependencies": { "@videojs/spf": "10.0.0-beta.25", "@videojs/store": "10.0.0-beta.25", "@videojs/utils": "10.0.0-beta.25", "@vimeo/player": "^2.30.3", "dashjs": "^5.2.0", "hls.js": "^1.6.7", "mux-embed": "^5.17.10" } }, "sha512-Yxd6c+u7LrYl6aKzZRyR5PjDqUt1lUbgFwIhg6pMzI8bJszIGjLS7TDzXdALpEFJpM1Z08dbqCEzCGaMj7Eu6A=="], "@videojs/core": ["@videojs/core@github:zoriya/v10#02a4769", { "peerDependencies": { "@videojs/media": "*", "@videojs/store": "*", "@videojs/utils": "*" } }, "zoriya-v10-02a4769", "sha512-eq79BQJ+e2Lx6k9tCW68vBolcJD3OynwgE7Y8pmI9gb+RWQAYJFLEY8B576ueKHhcgOOT/taerI/Wg3eqougSg=="],
"@videojs/react": ["@videojs/react@10.0.0-beta.25", "", { "dependencies": { "@videojs/core": "10.0.0-beta.25", "@videojs/spf": "10.0.0-beta.25", "@videojs/store": "10.0.0-beta.25", "@videojs/utils": "10.0.0-beta.25" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-MUG8NFntK3u4OTArwqG4utZ11HU1Ku5kzzTfTA2BMG5shNj7KWqPz0BMR2Nnv6hwEvcxpm9vwZxk+AwkmoB5jA=="], "@videojs/media": ["@videojs/media@github:zoriya/v10#46507f9", { "dependencies": { "@vimeo/player": "^2.30.3", "dashjs": "^5.2.0", "hls.js": "^1.6.7", "mux-embed": "^5.17.10" }, "peerDependencies": { "@videojs/spf": "*", "@videojs/utils": "*" } }, "zoriya-v10-46507f9", "sha512-1f7jIP5KPGKo4NeyG+Q22eS7KhvYZam8yvG985Y9edCp7wFcrVcraEQwKJrESaa/GDNgeKIeEg05MZ+l+ZHftQ=="],
"@videojs/spf": ["@videojs/spf@10.0.0-beta.25", "", { "dependencies": { "@videojs/utils": "10.0.0-beta.25", "signal-polyfill": "^0.2.2" } }, "sha512-SETOzCPbSxuzCoX+bF2u7/Z80AA/Xwpqpy5ki//yslIyPxWkMT0GFydXdoZ/bJpNsBqGmDbDxEBDo1uMjnM4mQ=="], "@videojs/react": ["@videojs/react@github:zoriya/v10#784a383", { "peerDependencies": { "@videojs/core": "*", "@videojs/media": "*", "@videojs/spf": "*", "@videojs/store": "*", "@videojs/utils": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "zoriya-v10-784a383", "sha512-wLuXUjLfhLNZAVSxCsTmpidN8DZPy6Us788vXDSRJKh/a6WDTCWV2op26EK3eCgeC1P8Hwc5s5cOEhqx2fGMRQ=="],
"@videojs/store": ["@videojs/store@10.0.0-beta.25", "", { "dependencies": { "@videojs/utils": "10.0.0-beta.25" }, "peerDependencies": { "@videojs/element": "10.0.0-beta.25", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@videojs/element", "react"] }, "sha512-XvPC0+MQ6AZ/oKGpSzmjOM56aYESMpPsTg5zddBDXhcqeAOUxpEsXDqhU1rsm751tU4ZavGxHENx70lBOa5dUA=="], "@videojs/spf": ["@videojs/spf@github:zoriya/v10#8920135", { "dependencies": { "signal-polyfill": "^0.2.2" }, "peerDependencies": { "@videojs/utils": "*" } }, "zoriya-v10-8920135", "sha512-jGXo5tyPpIk96XWksTKbWpi7POHzdt4KtvqDZ9+SDa+MOqLV3TcgM8soo6KO2jMnkv8d8APqaz+E27qfDf16kg=="],
"@videojs/utils": ["@videojs/utils@10.0.0-beta.25", "", {}, "sha512-tq/848qmzgTncopeYE5iiYs1Pfzj4OEdSe7zE2H6xoOB0PUMlnPOThA3aybHJ8Lh9bmw+wTb6QJ0NZanIzt2Zg=="], "@videojs/store": ["@videojs/store@github:zoriya/v10#3f9bddd", { "peerDependencies": { "@videojs/element": "*", "@videojs/utils": "*", "react": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@videojs/element", "react"] }, "zoriya-v10-3f9bddd", "sha512-QjSGJ6EO6uoij41F7KgMKrNi5XjvXfkODOrn17wcFa6+Ig1+ftnckkNZsJ44bARdJ1XUkuj3wNyGidYMrmKRmQ=="],
"@videojs/utils": ["@videojs/utils@github:zoriya/v10#9fed1b6", {}, "zoriya-v10-9fed1b6", "sha512-ZgbMw3v41aEru0056VVwy0UtiZk8zq6Tm6ddRAJtm5VXz2nEf36CoejFFvmGICW5cQ2WWhvMQmtqCSr+HmYGIw=="],
"@vimeo/player": ["@vimeo/player@2.30.4", "", { "dependencies": { "native-promise-only": "0.8.1", "weakmap-polyfill": "2.0.4" } }, "sha512-M8m1UAhJSb+KCWuXDLWHViwj+3YY/0ogwFquRfMHs9e9LYjXT9iB7n+sOCKwUusbiXuU2HKmXx+FEGHtYZfUSg=="], "@vimeo/player": ["@vimeo/player@2.30.4", "", { "dependencies": { "native-promise-only": "0.8.1", "weakmap-polyfill": "2.0.4" } }, "sha512-M8m1UAhJSb+KCWuXDLWHViwj+3YY/0ogwFquRfMHs9e9LYjXT9iB7n+sOCKwUusbiXuU2HKmXx+FEGHtYZfUSg=="],
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+6 -1
View File
@@ -63,7 +63,12 @@
}, },
"dependencies": { "dependencies": {
"@expo/config-plugins": "^57.0.3", "@expo/config-plugins": "^57.0.3",
"@videojs/react": "10.0.0-beta.25", "@videojs/core": "github:zoriya/v10#build26-core",
"@videojs/media": "github:zoriya/v10#build26-media",
"@videojs/react": "github:zoriya/v10#build26-react",
"@videojs/spf": "github:zoriya/v10#build26-spf",
"@videojs/store": "github:zoriya/v10#build26-store",
"@videojs/utils": "github:zoriya/v10#build26-utils",
"jassub": "^2.5.7", "jassub": "^2.5.7",
"libpgs": "^0.8.1" "libpgs": "^0.8.1"
}, },
-1
View File
@@ -59,7 +59,6 @@ export class WebOmniPlayer implements OmniPlayer {
this._store.state.toggleRemotePlayback(); this._store.state.toggleRemotePlayback();
} }
castOptions: CastOptions | null = null;
_source: Source | undefined = undefined; _source: Source | undefined = undefined;
private _showNotification = false; private _showNotification = false;
+19 -5
View File
@@ -1,4 +1,5 @@
import { createPlayer } from "@videojs/react"; import { createPlayer } from "@videojs/react";
import { GoogleCast } from "@videojs/react/media/google-cast";
import { videoFeatures } from "@videojs/react/video"; import { videoFeatures } from "@videojs/react/video";
import { import {
createContext, createContext,
@@ -7,6 +8,7 @@ import {
useEffect, useEffect,
useRef, useRef,
} from "react"; } from "react";
import { usePlayerState } from "./events";
import { WebOmniPlayer } from "./player.web"; import { WebOmniPlayer } from "./player.web";
import type { OmniPlayer, PlayerBackend } from "./types/player"; import type { OmniPlayer, PlayerBackend } from "./types/player";
import type { CastOptions, Source } from "./types/source"; import type { CastOptions, Source } from "./types/source";
@@ -67,15 +69,27 @@ const PlayerInitializer = ({
} }
}, [source, store]); }, [source, store]);
useEffect(() => {
player.castOptions = cast ?? null;
}, [cast]);
useEffect(() => { useEffect(() => {
player.showNotification = showNotification; player.showNotification = showNotification;
}, [showNotification]); }, [showNotification]);
return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>; return (
<PlayerCtx.Provider value={player}>
<CastBridge cast={cast} />
{children}
</PlayerCtx.Provider>
);
};
const CastBridge = ({ cast }: { cast?: CastOptions | null }) => {
const source = usePlayerState("source");
return (
<GoogleCast
receiver={cast?.receiverApplicationId}
src={source?.castId}
customData={source?.castData}
/>
);
}; };
export const usePlayer = () => useContext(PlayerCtx); export const usePlayer = () => useContext(PlayerCtx);
+2 -10
View File
@@ -1,4 +1,4 @@
import type { HlsMediaConfig } from "@videojs/core/dom/media/hls-js"; import type { HlsMediaConfig } from "@videojs/media/dom/hls-js";
import { HlsJsVideo } from "@videojs/react/media/hlsjs-video"; import { HlsJsVideo } from "@videojs/react/media/hlsjs-video";
import { Video } from "@videojs/react/video"; import { Video } from "@videojs/react/video";
import { import {
@@ -106,7 +106,6 @@ export const OmniView = ({
autoplay, autoplay,
subtitleAssets, subtitleAssets,
}: OmniViewProps & { style: CSSProperties }) => { }: OmniViewProps & { style: CSSProperties }) => {
const player = usePlayer() as WebOmniPlayer;
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLVideoElement>(undefined!); const ref = useRef<HTMLVideoElement>(undefined!);
const source = usePlayerState("source"); const source = usePlayerState("source");
@@ -119,8 +118,6 @@ export const OmniView = ({
const headersRef = useRef(source?.src?.headers); const headersRef = useRef(source?.src?.headers);
headersRef.current = source?.src?.headers; headersRef.current = source?.src?.headers;
const castId = source?.castId;
const castData = source?.castData;
const config = useMemo<HlsMediaConfig>( const config = useMemo<HlsMediaConfig>(
() => ({ () => ({
@@ -133,13 +130,8 @@ export const OmniView = ({
} }
}, },
}, },
googleCast: {
receiver: player.castOptions?.receiverApplicationId,
customData: castData ?? null,
...(castId && { src: castId }),
},
}), }),
[player.castOptions, castData, castId], [],
); );
// While casting, the receiver renders subtitles (the player forwards the // While casting, the receiver renders subtitles (the player forwards the