mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-16 02:44:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9b7663f9a | ||
|
|
709264ec1c | ||
|
|
336db26fc1 | ||
|
|
0545e06a04 | ||
|
|
da73a72217 | ||
|
|
88410ca19d | ||
|
|
be10a769dd | ||
|
|
e95fb80cba | ||
|
|
82675e565a | ||
|
|
236a828c0b | ||
|
|
ba7a2b6a6d | ||
|
|
1dbffadb62 | ||
|
|
4f6d544591 | ||
|
|
456f903f97 | ||
|
|
af3e96de78 | ||
|
|
fe51950bcf |
+9
-10
@@ -5,7 +5,7 @@ buildscript {
|
||||
}
|
||||
|
||||
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
|
||||
implementation project(":react-native-nitro-modules")
|
||||
|
||||
implementation("org.videolan.android:libvlc-all:4.0.0-eap24")
|
||||
implementation("androidx.media3:media3-common:1.4.1")
|
||||
implementation("androidx.media3:media3-session:1.4.1")
|
||||
implementation("androidx.media3:media3-ui:1.4.1")
|
||||
implementation 'androidx.media3:media3-exoplayer:1.10.0'
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.10.0")
|
||||
|
||||
implementation("androidx.media3:media3-cast:1.10.0")
|
||||
implementation("org.videolan.android:libvlc-all:4.0.0-eap29")
|
||||
implementation("androidx.media3:media3-common:1.11.0")
|
||||
implementation("androidx.media3:media3-session:1.11.0")
|
||||
implementation("androidx.media3:media3-ui:1.11.0")
|
||||
implementation 'androidx.media3:media3-exoplayer:1.11.0'
|
||||
implementation("androidx.media3:media3-exoplayer-hls:1.11.0")
|
||||
implementation("androidx.media3:media3-cast:1.11.0")
|
||||
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()) {
|
||||
|
||||
@@ -5,12 +5,9 @@ import androidx.media3.common.C
|
||||
import androidx.media3.common.C.TRACK_TYPE_AUDIO
|
||||
import androidx.media3.common.C.TRACK_TYPE_TEXT
|
||||
import androidx.media3.common.C.TRACK_TYPE_VIDEO
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.PlaybackParameters
|
||||
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_ENDED
|
||||
import androidx.media3.common.Player.STATE_IDLE
|
||||
@@ -18,6 +15,8 @@ import androidx.media3.common.Player.STATE_READY
|
||||
import androidx.media3.common.Timeline
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.VideoSize
|
||||
import com.google.android.gms.cast.MediaStatus
|
||||
import com.google.android.gms.cast.framework.media.RemoteMediaClient
|
||||
import com.margelo.nitro.omni.BoolProperty
|
||||
import com.margelo.nitro.omni.CastStatus
|
||||
import com.margelo.nitro.omni.HybridOmniEventMapSpec
|
||||
@@ -59,7 +58,6 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
|
||||
private val castStatusListeners = mutableSetOf<(CastStatus) -> Unit>()
|
||||
private val sourceListeners = mutableSetOf<(Source?) -> Unit>()
|
||||
|
||||
private var lastMediaItemIndex = 0
|
||||
private var lastRendition: Rendition? = null
|
||||
private var lastRenditions: Array<Rendition>? = null
|
||||
private var lastIsAutoQuality: Boolean? = null
|
||||
@@ -73,7 +71,6 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
|
||||
_player?.removeListener(this)
|
||||
_player = value
|
||||
value.addListener(this)
|
||||
lastMediaItemIndex = value.currentMediaItemIndex
|
||||
lastRendition = null
|
||||
lastRenditions = null
|
||||
lastIsAutoQuality = null
|
||||
@@ -108,6 +105,8 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
|
||||
onTracksChanged(value.currentTracks)
|
||||
}
|
||||
|
||||
var remote: RemoteMediaClient? = null
|
||||
|
||||
fun emitCastStatus(status: CastStatus) {
|
||||
castStatusListeners.forEach { it(status) }
|
||||
}
|
||||
@@ -116,6 +115,14 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
|
||||
sourceListeners.forEach { it(source) }
|
||||
}
|
||||
|
||||
fun emitPrev() {
|
||||
onPrevListeners.forEach { it() }
|
||||
}
|
||||
|
||||
fun emitNext() {
|
||||
onNextListeners.forEach { it() }
|
||||
}
|
||||
|
||||
private fun selectedTrack(trackType: Int): Track? {
|
||||
val list = when (trackType) {
|
||||
TRACK_TYPE_VIDEO -> tracks.videos
|
||||
@@ -197,7 +204,13 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
val state = when (player.playbackState) {
|
||||
STATE_IDLE -> PlayerStatus.IDLE
|
||||
STATE_IDLE -> {
|
||||
// cast doesn't have STATE_ENDED, so handle this manually
|
||||
if (remote?.mediaStatus?.idleReason == MediaStatus.IDLE_REASON_FINISHED)
|
||||
onEndListeners.forEach { it() }
|
||||
PlayerStatus.IDLE
|
||||
}
|
||||
|
||||
STATE_BUFFERING -> PlayerStatus.LOADING
|
||||
STATE_READY -> PlayerStatus.READYTOPLAY
|
||||
STATE_ENDED -> {
|
||||
@@ -278,20 +291,6 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
|
||||
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(
|
||||
oldPosition: 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 com.google.android.gms.cast.CastMediaControlIntent
|
||||
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.SessionProvider
|
||||
|
||||
@@ -11,9 +12,15 @@ class OmniCastOptionsProvider : OptionsProvider {
|
||||
val appId = OmniPlayer.receiverApplicationId
|
||||
?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
|
||||
|
||||
val mediaOptions = CastMediaOptions.Builder()
|
||||
.setMediaSessionEnabled(false)
|
||||
.setNotificationOptions(null)
|
||||
.build()
|
||||
|
||||
return CastOptions.Builder()
|
||||
.setReceiverApplicationId(appId)
|
||||
.setShowSystemOutputSwitcherOnCastIconClick(true)
|
||||
.setCastMediaOptions(mediaOptions)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
@@ -24,8 +24,15 @@ class OmniMediaItemConverter : MediaItemConverter {
|
||||
builder.setMediaMetadata(
|
||||
androidx.media3.common.MediaMetadata.Builder()
|
||||
.setTitle(md.getString(CastMediaMetadata.KEY_TITLE))
|
||||
.setArtist(md.getString(CastMediaMetadata.KEY_ARTIST))
|
||||
.setAlbumTitle(md.getString(CastMediaMetadata.KEY_ALBUM_TITLE))
|
||||
.setArtist(
|
||||
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()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,22 +29,24 @@ import com.margelo.nitro.NitroModules
|
||||
import com.margelo.nitro.omni.AndroidBackend
|
||||
import com.margelo.nitro.omni.CastStatus
|
||||
import com.margelo.nitro.omni.HybridOmniPlayerSpec
|
||||
import com.margelo.nitro.omni.Metadata
|
||||
import com.margelo.nitro.omni.MixAudioMode
|
||||
import com.margelo.nitro.omni.PlayerStatus
|
||||
import com.margelo.nitro.omni.Rendition
|
||||
import com.margelo.nitro.omni.Source
|
||||
import com.margelo.nitro.omni.Track
|
||||
import com.margelo.nitro.omni.VideoSrc
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.MediaItem.RequestMetadata
|
||||
import androidx.media3.common.MediaItem.SubtitleConfiguration
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.cast.CastPlayer
|
||||
import androidx.media3.cast.RemoteCastPlayer
|
||||
import androidx.media3.session.DefaultMediaNotificationProvider
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import androidx.mediarouter.app.MediaRouteChooserDialog
|
||||
import androidx.mediarouter.app.SystemOutputSwitcherDialogController
|
||||
import com.google.android.gms.cast.MediaStatus
|
||||
import com.google.android.gms.cast.framework.CastContext
|
||||
import com.google.android.gms.cast.framework.CastState
|
||||
import com.google.android.gms.cast.framework.CastStateListener
|
||||
@@ -99,11 +101,35 @@ class OmniPlayer(
|
||||
override val eventMap = EventMap(this)
|
||||
|
||||
private var castContext: CastContext? = null
|
||||
private val castStateListener =
|
||||
CastStateListener { eventMap.emitCastStatus(computeCastStatus()) }
|
||||
private val castStateListener = CastStateListener {
|
||||
eventMap.emitCastStatus(computeCastStatus())
|
||||
listenToReceiver()
|
||||
// we were only kept alive to hold this cast, it is over now
|
||||
if (abandoned && !isCasting) release()
|
||||
}
|
||||
|
||||
// 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
|
||||
eventMap.remote = session?.remoteMediaClient
|
||||
if (session == null) 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 {
|
||||
castOptions?.receiverApplicationId?.let { receiverApplicationId = it }
|
||||
notificationUrl = castOptions?.notificationUrl
|
||||
val cc = try {
|
||||
CastContext.getSharedInstance(ctx)
|
||||
} catch (_: Throwable) {
|
||||
@@ -124,7 +150,13 @@ class OmniPlayer(
|
||||
.setRemotePlayer(remote)
|
||||
.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
|
||||
@@ -136,23 +168,26 @@ class OmniPlayer(
|
||||
private var serviceRunning = false
|
||||
|
||||
private fun syncNotificationService() {
|
||||
if (abandoned) return
|
||||
val shouldShow = showNotification == true && source != null
|
||||
when {
|
||||
shouldShow && !serviceRunning -> {
|
||||
val otherIsPlaying = notificationPlayer?.let { other ->
|
||||
other !== localPlayer && runOnMainThreadSync { other.isPlaying }
|
||||
// an abandoned one only holds it until the cast it kept alive ends
|
||||
other !== this && !other.abandoned &&
|
||||
runOnMainThreadSync { other.player.isPlaying }
|
||||
} == true
|
||||
if (otherIsPlaying) {
|
||||
throw Error("Two players can't display notifications at the same time.")
|
||||
}
|
||||
notificationPlayer = localPlayer
|
||||
notificationPlayer = this
|
||||
ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java))
|
||||
serviceRunning = true
|
||||
}
|
||||
|
||||
!shouldShow && serviceRunning -> {
|
||||
ctx.stopService(Intent(ctx, OmniPlayerService::class.java))
|
||||
if (notificationPlayer == localPlayer) notificationPlayer = null
|
||||
if (notificationPlayer === this) notificationPlayer = null
|
||||
serviceRunning = false
|
||||
}
|
||||
}
|
||||
@@ -192,22 +227,36 @@ class OmniPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
// the app let go of us. while casting we stay alive anyway: OmniPlayerService holds the
|
||||
// process (and thus the session) up, and its notification is the only remote left - we
|
||||
// keep owning both until castStateListener sees the cast end.
|
||||
@Volatile
|
||||
private var released = false
|
||||
private var abandoned = false
|
||||
|
||||
override fun release() {
|
||||
if (released) return
|
||||
released = true
|
||||
showNotification = false
|
||||
override fun abandon() {
|
||||
if (abandoned) return
|
||||
abandoned = true
|
||||
if (!runOnMainThreadSync { isCasting }) release()
|
||||
}
|
||||
|
||||
// drop everything. this ends the cast session, so it only runs once there is no cast
|
||||
// left to hold (either there was none, or it just ended).
|
||||
private fun release() {
|
||||
runOnMainThread {
|
||||
eventMap.dispose()
|
||||
castContext?.removeCastStateListener(castStateListener)
|
||||
if (notificationPlayer === this) {
|
||||
// syncNotificationService ignores us now, hand the service back ourselves
|
||||
ctx.stopService(Intent(ctx, OmniPlayerService::class.java))
|
||||
notificationPlayer = null
|
||||
serviceRunning = false
|
||||
}
|
||||
eventMap.dispose()
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
release()
|
||||
abandon()
|
||||
super.dispose()
|
||||
}
|
||||
|
||||
@@ -273,6 +322,33 @@ class OmniPlayer(
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun sourceOf(item: MediaItem): Source {
|
||||
val meta = item.mediaMetadata
|
||||
val status = castContext?.sessionManager?.currentCastSession
|
||||
?.remoteMediaClient?.mediaStatus
|
||||
return Source(
|
||||
src = VideoSrc(
|
||||
uri = item.localConfiguration?.uri?.toString() ?: item.mediaId,
|
||||
mimeType = item.localConfiguration?.mimeType,
|
||||
headers = emptyMap(),
|
||||
),
|
||||
startTime = null,
|
||||
subtitles = emptyArray(),
|
||||
fonts = null,
|
||||
metadata = Metadata(
|
||||
title = meta.title?.toString() ?: "",
|
||||
album = meta.albumTitle?.toString(),
|
||||
artist = meta.artist?.toString(),
|
||||
imageLink = meta.artworkUri?.toString(),
|
||||
hasPrev = status?.isMediaCommandSupported(MediaStatus.COMMAND_QUEUE_PREVIOUS),
|
||||
hasNext = status?.isMediaCommandSupported(MediaStatus.COMMAND_QUEUE_NEXT),
|
||||
),
|
||||
mixAudio = null,
|
||||
castId = item.mediaId,
|
||||
castData = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun setVideoView(surfaceView: android.view.SurfaceView) {
|
||||
runOnMainThread {
|
||||
localPlayer.setVideoSurfaceView(surfaceView)
|
||||
@@ -298,8 +374,8 @@ class OmniPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
override val hasPrev: Boolean get() = player.hasPreviousMediaItem()
|
||||
override val hasNext: Boolean get() = player.hasNextMediaItem()
|
||||
override val hasPrev: Boolean get() = source?.metadata?.hasPrev == true
|
||||
override val hasNext: Boolean get() = source?.metadata?.hasNext == true
|
||||
override val status by mainThreadProperty {
|
||||
when (player.playbackState) {
|
||||
Player.STATE_IDLE,
|
||||
@@ -383,15 +459,41 @@ class OmniPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
init {
|
||||
runOnMainThreadSync { eventMap.player = player }
|
||||
runOnMainThreadSync {
|
||||
eventMap.player = player
|
||||
player.addListener(object : Player.Listener {
|
||||
override fun onMediaItemTransition(item: MediaItem?, reason: Int) = followMedia(item)
|
||||
})
|
||||
// check for cast rejoin
|
||||
followMedia(player.currentMediaItem)
|
||||
}
|
||||
}
|
||||
|
||||
override var source: Source? = null
|
||||
private fun followMedia(item: MediaItem?) {
|
||||
// our media only leaves the player while it is being handed over to the
|
||||
// receiver (starting a cast), it is not gone.
|
||||
if (item == null && ownItem != null) return
|
||||
if (item != null && item == ownItem) return
|
||||
val source = item?.let { sourceOf(it) }
|
||||
if (source == _source) return
|
||||
_source = source
|
||||
eventMap.emitSource(source)
|
||||
syncNotificationService()
|
||||
}
|
||||
|
||||
private var _source: Source? = null
|
||||
private var ownItem: MediaItem? = null
|
||||
|
||||
|
||||
override var source: Source?
|
||||
get() = _source
|
||||
set(value) {
|
||||
field = value
|
||||
_source = value
|
||||
eventMap.emitSource(value)
|
||||
if (value == null) {
|
||||
ownItem = null
|
||||
runOnMainThreadSync {
|
||||
player.clearMediaItems()
|
||||
}
|
||||
@@ -417,15 +519,11 @@ class OmniPlayer(
|
||||
value.castId,
|
||||
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)
|
||||
ownItem = currentItem
|
||||
|
||||
runOnMainThreadSync {
|
||||
val startIndex = if (value.metadata?.hasPrev == true) 1 else 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()
|
||||
}
|
||||
syncNotificationService()
|
||||
@@ -447,11 +545,11 @@ class OmniPlayer(
|
||||
}
|
||||
|
||||
override fun playPrev() {
|
||||
runOnMainThreadSync { player.seekToPreviousMediaItem() }
|
||||
runOnMainThreadSync { eventMap.emitPrev() }
|
||||
}
|
||||
|
||||
override fun playNext() {
|
||||
runOnMainThreadSync { player.seekToNextMediaItem() }
|
||||
runOnMainThreadSync { eventMap.emitNext() }
|
||||
}
|
||||
|
||||
override fun selectVideo(video: Track) {
|
||||
@@ -557,15 +655,22 @@ class OmniPlayer(
|
||||
}
|
||||
|
||||
companion object {
|
||||
var notificationPlayer: Player? = null
|
||||
var notificationPlayer: OmniPlayer? = null
|
||||
|
||||
// MediaItem RequestMetadata extras keys carrying cast-only data.
|
||||
const val CAST_ID_EXTRA = "dev.zoriya.omni.castId"
|
||||
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`
|
||||
// prop; read by OmniCastOptionsProvider when the Cast SDK initializes.
|
||||
var receiverApplicationId: String? = null
|
||||
|
||||
// `cast.notificationUrl`, opened when our notification is tapped.
|
||||
var notificationUrl: String? = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -576,34 +681,20 @@ class OmniPlayerService : MediaSessionService() {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
val available = OmniPlayer.notificationPlayer
|
||||
val available = OmniPlayer.notificationPlayer?.player
|
||||
if (available == null) {
|
||||
startForeground(1, createImmediateNotification())
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
player = available
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
val sessionActivity = launchIntent?.let {
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
it,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
val sessionActivity = openIntent()
|
||||
mediaSession = MediaSession.Builder(this, player)
|
||||
.apply {
|
||||
sessionActivity?.let { setSessionActivity(it) }
|
||||
}
|
||||
.build()
|
||||
|
||||
setMediaNotificationProvider(DefaultMediaNotificationProvider.Builder(this).build().apply {
|
||||
setSmallIcon(applicationInfo.icon.takeIf { it != 0 }
|
||||
?: android.R.drawable.ic_media_play)
|
||||
})
|
||||
addSession(mediaSession)
|
||||
setShowNotificationForIdlePlayer(SHOW_NOTIFICATION_FOR_IDLE_PLAYER_ALWAYS)
|
||||
|
||||
@@ -613,7 +704,7 @@ class OmniPlayerService : MediaSessionService() {
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val current = OmniPlayer.notificationPlayer
|
||||
val current = OmniPlayer.notificationPlayer?.player
|
||||
if (current != null && ::mediaSession.isInitialized && mediaSession.player !== current) {
|
||||
player = current
|
||||
mediaSession.player = current
|
||||
@@ -633,30 +724,40 @@ class OmniPlayerService : MediaSessionService() {
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
return NotificationCompat.Builder(this, "omni_playback")
|
||||
.setSmallIcon(applicationInfo.icon.takeIf { it != 0 }
|
||||
?: android.R.drawable.ic_media_play)
|
||||
.setSmallIcon(androidx.media3.session.R.drawable.media3_notification_small_icon)
|
||||
.setContentTitle("Omni Player")
|
||||
.setContentText("Preparing playback...")
|
||||
.setContentIntent(pendingIntent)
|
||||
.setContentIntent(openIntent())
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun openIntent(): PendingIntent? {
|
||||
val intent = OmniPlayer.notificationUrl
|
||||
?.let { Intent(Intent.ACTION_VIEW, it.toUri()).setPackage(packageName) }
|
||||
?: packageManager.getLaunchIntentForPackage(packageName)
|
||||
?: return null
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
return PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
pauseAllPlayersAndStopSelf()
|
||||
val casting = try {
|
||||
CastContext.getSharedInstance(this)
|
||||
.sessionManager.currentCastSession?.isConnected == true
|
||||
} catch (_: Throwable) {
|
||||
false
|
||||
}
|
||||
// keep service open if casting
|
||||
if (!casting) pauseAllPlayersAndStopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
@@ -6,16 +6,12 @@ import com.margelo.nitro.omni.CastOptions
|
||||
import com.margelo.nitro.omni.HybridOmniPlayerFactorySpec
|
||||
import com.margelo.nitro.omni.HybridOmniPlayerSpec
|
||||
import com.margelo.nitro.omni.PlayerBackend
|
||||
import com.margelo.nitro.omni.Source
|
||||
|
||||
class OmniPlayerFactory(val context: ThemedReactContext) : HybridOmniPlayerFactorySpec() {
|
||||
override fun createPlayer(
|
||||
props: Source?,
|
||||
backend: PlayerBackend?,
|
||||
cast: CastOptions?,
|
||||
): HybridOmniPlayerSpec {
|
||||
return OmniPlayer(backend?.android ?: AndroidBackend.VLC, cast).apply {
|
||||
source = props
|
||||
}
|
||||
return OmniPlayer(backend?.android ?: AndroidBackend.VLC, cast)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,6 +244,7 @@ class OmniView(val context: ThemedReactContext) :
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
if (isPlaying) scheduleVideoRebuild()
|
||||
updatePictureInPictureParams()
|
||||
}
|
||||
|
||||
@@ -377,22 +378,16 @@ class OmniView(val context: ThemedReactContext) :
|
||||
movedSurfaceToRootForPip = false
|
||||
}
|
||||
|
||||
// A surface that is destroyed and recreated while playback runs (the PIP
|
||||
// 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
|
||||
// once the (PIP) window has settled at its final size — firing it mid-resize
|
||||
// leaves the vout stopped — so it is debounced off surfaceChanged.
|
||||
private var pendingVideoRebuild = false
|
||||
private val rebuildRunnable = Runnable {
|
||||
if (!pendingVideoRebuild) return@Runnable
|
||||
pendingVideoRebuild = false
|
||||
boundPlayer?.rebuildVideoOutput()
|
||||
private val rebuildRunnable = Runnable { boundPlayer?.rebuildVideoOutput() }
|
||||
|
||||
private fun scheduleVideoRebuild() {
|
||||
surfaceView.removeCallbacks(rebuildRunnable)
|
||||
surfaceView.postDelayed(rebuildRunnable, 200)
|
||||
}
|
||||
|
||||
override fun surfaceCreated(holder: SurfaceHolder) {
|
||||
surfaceReady = true
|
||||
boundPlayer?.setVideoView(surfaceView)
|
||||
if (boundPlayer?.localPlayer?.isPlaying == true) pendingVideoRebuild = true
|
||||
updatePictureInPictureParams()
|
||||
}
|
||||
|
||||
@@ -403,10 +398,7 @@ class OmniView(val context: ThemedReactContext) :
|
||||
height: Int
|
||||
) {
|
||||
boundPlayer?.updateVideoLayout(width, height)
|
||||
if (pendingVideoRebuild) {
|
||||
surfaceView.removeCallbacks(rebuildRunnable)
|
||||
surfaceView.postDelayed(rebuildRunnable, 200)
|
||||
}
|
||||
scheduleVideoRebuild()
|
||||
}
|
||||
|
||||
override fun surfaceDestroyed(holder: SurfaceHolder) {
|
||||
|
||||
@@ -83,13 +83,15 @@ class VlcPlayer(ctx: Context) :
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) flush() else applicationHandler.post(flush)
|
||||
}
|
||||
|
||||
private val libVLC = LibVLC(ctx, arrayListOf(
|
||||
"--network-caching=1500",
|
||||
"--file-caching=1500",
|
||||
"--no-stats",
|
||||
"--no-osd",
|
||||
"--quiet"
|
||||
))
|
||||
private val libVLC = LibVLC(
|
||||
ctx, arrayListOf(
|
||||
"--network-caching=1500",
|
||||
"--file-caching=1500",
|
||||
"--no-stats",
|
||||
"--no-osd",
|
||||
"--quiet"
|
||||
)
|
||||
)
|
||||
|
||||
private val player = MediaPlayer(libVLC)
|
||||
|
||||
@@ -98,15 +100,28 @@ class VlcPlayer(ctx: Context) :
|
||||
private var mediaItems: List<MediaItem> = emptyList()
|
||||
private var currentMediaItemIndex: Int = INDEX_UNSET
|
||||
private var currentTrackSelectionParameters = TrackSelectionParameters.Builder().build()
|
||||
|
||||
@Volatile
|
||||
private var playerError: PlaybackException? = null
|
||||
|
||||
@Volatile
|
||||
private var released = false
|
||||
private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY
|
||||
private var userInitiatedTransition: Boolean = false
|
||||
|
||||
@Volatile
|
||||
private var cachedBufferedPosition: Long = 0L
|
||||
|
||||
// vlc handles seeks on its input thread and keeps reporting the pre-seek time
|
||||
// until it decoded the new position, so remember where we asked to go and
|
||||
// report that instead
|
||||
@Volatile
|
||||
private var pendingSeekPosition: Long = TIME_UNSET
|
||||
|
||||
@Volatile
|
||||
private var seekOriginPosition: Long = 0L
|
||||
private var boundSurfaceView: SurfaceView? = null
|
||||
private var videoOutputStale = false
|
||||
private var lastVideoSize: VideoSize = VideoSize.UNKNOWN
|
||||
|
||||
private val availableCommands: Player.Commands = Player.Commands.Builder()
|
||||
@@ -177,6 +192,7 @@ class VlcPlayer(ctx: Context) :
|
||||
|
||||
MediaPlayer.Event.Stopped -> {
|
||||
abandonAudioFocus()
|
||||
pendingSeekPosition = TIME_UNSET
|
||||
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
|
||||
it.onPlaybackStateChanged(STATE_IDLE)
|
||||
}
|
||||
@@ -184,6 +200,7 @@ class VlcPlayer(ctx: Context) :
|
||||
|
||||
MediaPlayer.Event.EndReached -> {
|
||||
abandonAudioFocus()
|
||||
pendingSeekPosition = TIME_UNSET
|
||||
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
|
||||
it.onPlaybackStateChanged(STATE_ENDED)
|
||||
}
|
||||
@@ -215,7 +232,14 @@ class VlcPlayer(ctx: Context) :
|
||||
|
||||
// progress is polled instead of pushed dozens of times per seconds.
|
||||
// this improves perf & battery life (native -> js bridge is expensive)
|
||||
MediaPlayer.Event.TimeChanged -> Unit
|
||||
MediaPlayer.Event.TimeChanged -> {
|
||||
val pending = pendingSeekPosition
|
||||
if (pending != TIME_UNSET &&
|
||||
(Math.abs(event.timeChanged - seekOriginPosition) > 1_000 || Math.abs(event.timeChanged - pending) < 1_000)
|
||||
) {
|
||||
pendingSeekPosition = TIME_UNSET
|
||||
}
|
||||
}
|
||||
|
||||
// vlc has no video-size event, Vout event still has unknown size.
|
||||
// This is the first callback with known size.
|
||||
@@ -293,7 +317,11 @@ class VlcPlayer(ctx: Context) :
|
||||
audioManager.requestAudioFocus(request)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN)
|
||||
audioManager.requestAudioFocus(
|
||||
this,
|
||||
AudioManager.STREAM_MUSIC,
|
||||
AudioManager.AUDIOFOCUS_GAIN
|
||||
)
|
||||
}
|
||||
hasAudioFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
|
||||
return hasAudioFocus
|
||||
@@ -346,8 +374,10 @@ class VlcPlayer(ctx: Context) :
|
||||
currentMediaItemIndex = targetIndex
|
||||
playlistMetadata = mediaItems.getOrNull(targetIndex)?.mediaMetadata ?: MediaMetadata.EMPTY
|
||||
playerError = null
|
||||
pendingSeekPosition = TIME_UNSET
|
||||
|
||||
player.stop()
|
||||
videoOutputStale = false
|
||||
|
||||
mediaItems.getOrNull(targetIndex)?.let { item ->
|
||||
val uri = item.localConfiguration?.uri?.toString()
|
||||
@@ -376,7 +406,8 @@ class VlcPlayer(ctx: Context) :
|
||||
EVENT_PLAYLIST_METADATA_CHANGED
|
||||
)
|
||||
if (prev != currentMediaItemIndex) events += EVENT_MEDIA_ITEM_TRANSITION
|
||||
val reason = if (userInitiatedTransition) MEDIA_ITEM_TRANSITION_REASON_SEEK else MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED
|
||||
val reason =
|
||||
if (userInitiatedTransition) MEDIA_ITEM_TRANSITION_REASON_SEEK else MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED
|
||||
userInitiatedTransition = false
|
||||
notifyListeners(events.toTypedArray()) {
|
||||
it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)
|
||||
@@ -430,6 +461,7 @@ class VlcPlayer(ctx: Context) :
|
||||
mediaItems = emptyList()
|
||||
currentMediaItemIndex = INDEX_UNSET
|
||||
playlistMetadata = MediaMetadata.EMPTY
|
||||
pendingSeekPosition = TIME_UNSET
|
||||
player.stop()
|
||||
|
||||
val events = mutableListOf(
|
||||
@@ -495,7 +527,8 @@ class VlcPlayer(ctx: Context) :
|
||||
seekCommand: Int,
|
||||
isRepeatingCurrentItem: Boolean
|
||||
) {
|
||||
val targetIndex = if (mediaItemIndex == INDEX_UNSET) currentMediaItemIndex else mediaItemIndex
|
||||
val targetIndex =
|
||||
if (mediaItemIndex == INDEX_UNSET) currentMediaItemIndex else mediaItemIndex
|
||||
if (targetIndex == INDEX_UNSET || targetIndex !in mediaItems.indices) return
|
||||
|
||||
if (targetIndex != currentMediaItemIndex) {
|
||||
@@ -507,6 +540,10 @@ class VlcPlayer(ctx: Context) :
|
||||
|
||||
val from = getCurrentPosition()
|
||||
val target = positionMs.coerceAtLeast(0L).takeIf { it != TIME_UNSET } ?: 0L
|
||||
if (player.isSeekable) {
|
||||
seekOriginPosition = player.time.coerceAtLeast(0L)
|
||||
pendingSeekPosition = target
|
||||
}
|
||||
player.time = target
|
||||
// since vlc reports progress events every few ms they don't have a seek finished event.
|
||||
notifyListeners(EVENT_POSITION_DISCONTINUITY) {
|
||||
@@ -579,14 +616,17 @@ class VlcPlayer(ctx: Context) :
|
||||
.setWidth(videoTrack?.width?.takeIf { it > 0 } ?: Format.NO_VALUE)
|
||||
.setHeight(videoTrack?.height?.takeIf { it > 0 } ?: Format.NO_VALUE)
|
||||
.setFrameRate(
|
||||
videoTrack?.takeIf { it.frameRateDen > 0 }
|
||||
videoTrack?.takeIf { it.frameRateDen > 0 && it.frameRateNum > 0 }
|
||||
?.let { it.frameRateNum.toFloat() / it.frameRateDen }
|
||||
?: Format.NO_VALUE.toFloat()
|
||||
)
|
||||
.setAverageBitrate(track.bitrate.takeIf { it > 0 } ?: Format.NO_VALUE)
|
||||
.build()
|
||||
}
|
||||
val group = TrackGroup("vlc-video-${videoTracks.minOf { it.id }}", *videoFormats.toTypedArray())
|
||||
val group = TrackGroup(
|
||||
"vlc-video-${videoTracks.minOf { it.id }}",
|
||||
*videoFormats.toTypedArray()
|
||||
)
|
||||
val selected = BooleanArray(videoFormats.size) { idx ->
|
||||
selectedVideo != null && selectedVideo.id == videoTracks[idx].id
|
||||
}
|
||||
@@ -605,7 +645,10 @@ class VlcPlayer(ctx: Context) :
|
||||
.setSampleMimeType("audio/x-unknown")
|
||||
.build()
|
||||
}
|
||||
val group = TrackGroup("vlc-audio-${audioTracks.minOf { it.id }}", *audioFormats.toTypedArray())
|
||||
val group = TrackGroup(
|
||||
"vlc-audio-${audioTracks.minOf { it.id }}",
|
||||
*audioFormats.toTypedArray()
|
||||
)
|
||||
val selected = BooleanArray(audioFormats.size) { idx ->
|
||||
selectedAudio != null && selectedAudio.id == audioTracks[idx].id
|
||||
}
|
||||
@@ -623,7 +666,8 @@ class VlcPlayer(ctx: Context) :
|
||||
.setSampleMimeType("text/x-unknown")
|
||||
.build()
|
||||
val group = TrackGroup("vlc-sub-${track.id}", format)
|
||||
val selected = booleanArrayOf(selectedSubtitle != null && selectedSubtitle.id == track.id)
|
||||
val selected =
|
||||
booleanArrayOf(selectedSubtitle != null && selectedSubtitle.id == track.id)
|
||||
val support = intArrayOf(FORMAT_HANDLED)
|
||||
result.add(Group(group, false, support, selected))
|
||||
}
|
||||
@@ -661,10 +705,12 @@ class VlcPlayer(ctx: Context) :
|
||||
player.setVideoTrackEnabled(true)
|
||||
player.selectTrack(trackId)
|
||||
}
|
||||
|
||||
TRACK_TYPE_AUDIO -> {
|
||||
hasAudioOverride = true
|
||||
player.selectTrack(trackId)
|
||||
}
|
||||
|
||||
TRACK_TYPE_TEXT -> {
|
||||
hasTextOverride = true
|
||||
player.selectTrack(trackId)
|
||||
@@ -709,7 +755,12 @@ class VlcPlayer(ctx: Context) :
|
||||
val preferredLabels = labels.filter { it.isNotEmpty() }
|
||||
if (preferredLanguages.isEmpty() && preferredLabels.isEmpty()) return null
|
||||
|
||||
data class Candidate(val id: String, val langIndex: Int, val labelIndex: Int, val hasDefaultFlag: Boolean)
|
||||
data class Candidate(
|
||||
val id: String,
|
||||
val langIndex: Int,
|
||||
val labelIndex: Int,
|
||||
val hasDefaultFlag: Boolean
|
||||
)
|
||||
|
||||
val candidates = tracks.groups
|
||||
.filter { it.type == trackType }
|
||||
@@ -759,7 +810,8 @@ class VlcPlayer(ctx: Context) :
|
||||
// timelines are immutable
|
||||
val items = mediaItems
|
||||
val currentIndex = currentMediaItemIndex
|
||||
val currentDurationUs = duration.takeIf { it != TIME_UNSET }?.let { it * 1000L } ?: TIME_UNSET
|
||||
val currentDurationUs =
|
||||
duration.takeIf { it != TIME_UNSET }?.let { it * 1000L } ?: TIME_UNSET
|
||||
if (items.isEmpty()) return Timeline.EMPTY
|
||||
return object : Timeline() {
|
||||
private fun durationUsForIndex(index: Int): Long =
|
||||
@@ -814,7 +866,11 @@ class VlcPlayer(ctx: Context) :
|
||||
|
||||
override fun getDuration(): Long = player.length.takeIf { it > 0 } ?: TIME_UNSET
|
||||
|
||||
override fun getCurrentPosition() = player.time.coerceAtLeast(0L)
|
||||
override fun getCurrentPosition(): Long {
|
||||
val pending = pendingSeekPosition
|
||||
if (pending != TIME_UNSET) return pending
|
||||
return player.time.coerceAtLeast(0L)
|
||||
}
|
||||
|
||||
override fun getBufferedPosition(): Long {
|
||||
val duration = getDuration()
|
||||
@@ -897,30 +953,22 @@ class VlcPlayer(ctx: Context) :
|
||||
if (surfaceView == null) return clearVideoSurface()
|
||||
if (vlcVout.areViewsAttached() && boundSurfaceView === surfaceView) return
|
||||
boundSurfaceView = surfaceView
|
||||
// Hand VLC the SurfaceView (not a raw Surface) so it reads the real view
|
||||
// size and keeps the video layout correct as the surface resizes (e.g. the
|
||||
// PIP window shrinking/growing); see updateVideoLayout for size updates.
|
||||
videoOutputStale = (
|
||||
player.playerState == IMedia.State.Playing ||
|
||||
player.playerState == IMedia.State.Paused
|
||||
)
|
||||
vlcVout.setVideoView(surfaceView)
|
||||
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() {
|
||||
if (!vlcVout.areViewsAttached() || !player.isPlaying) return
|
||||
if (!videoOutputStale || !player.isPlaying || !vlcVout.areViewsAttached()) return
|
||||
videoOutputStale = false
|
||||
player.setVideoTrackEnabled(false)
|
||||
player.setVideoTrackEnabled(true)
|
||||
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) {
|
||||
if (!vlcVout.areViewsAttached()) return
|
||||
if (width > 0 && height > 0) vlcVout.setWindowSize(width, height)
|
||||
@@ -952,7 +1000,8 @@ class VlcPlayer(ctx: Context) :
|
||||
}
|
||||
|
||||
override fun getVideoSize(): VideoSize {
|
||||
val videoTrack = player.getSelectedTrack(IMedia.Track.Type.Video) as? VideoTrack ?: return VideoSize.UNKNOWN
|
||||
val videoTrack = player.getSelectedTrack(IMedia.Track.Type.Video) as? VideoTrack
|
||||
?: return VideoSize.UNKNOWN
|
||||
if (videoTrack.width <= 0 || videoTrack.height <= 0) return VideoSize.UNKNOWN
|
||||
val par = if (videoTrack.sarNum > 0 && videoTrack.sarDen > 0) {
|
||||
videoTrack.sarNum.toFloat() / videoTrack.sarDen.toFloat()
|
||||
|
||||
+8
-1
@@ -79,6 +79,7 @@ function PlayerExample({
|
||||
const volume = usePlayerState("volume");
|
||||
const isAutoQuality = usePlayerState("isAutoQuality");
|
||||
const castStatus = usePlayerState("castStatus");
|
||||
const source = usePlayerState("source");
|
||||
const [logs, setLogs] = useState<{ id: number; message: string }[]>([]);
|
||||
const logId = useRef(0);
|
||||
const tracks = {
|
||||
@@ -281,6 +282,9 @@ function PlayerExample({
|
||||
<Text style={styles.statText}>
|
||||
Volume: {(volume * 100).toFixed(0)}%
|
||||
</Text>
|
||||
<Text style={styles.statText}>
|
||||
Source: {source?.metadata?.title ?? "(none)"}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.selectorCard}>
|
||||
@@ -511,7 +515,10 @@ function App(): React.JSX.Element {
|
||||
<OmniProvider
|
||||
source={hasSource ? source : undefined}
|
||||
backend={{ android: backend }}
|
||||
cast={{ receiverApplicationId: "D8FB0FC1" }}
|
||||
cast={{
|
||||
receiverApplicationId: "D8FB0FC1",
|
||||
notificationUrl: "omniexample://remote",
|
||||
}}
|
||||
showNotification
|
||||
>
|
||||
<PlayerExample
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="omniexample" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
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
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -66,6 +66,7 @@ module.exports = {
|
||||
port: 3000,
|
||||
hot: true,
|
||||
historyApiFallback: true,
|
||||
allowedHosts: "all",
|
||||
// jassub's wasm is multithreaded (pthreads), which needs SharedArrayBuffer
|
||||
// and therefore a cross-origin-isolated page. `credentialless` keeps
|
||||
// cross-origin video/subtitle/font requests working without CORP headers.
|
||||
|
||||
+7
-3
@@ -34,8 +34,11 @@ namespace margelo::nitro::omni {
|
||||
static const auto clazz = javaClassStatic();
|
||||
static const auto fieldReceiverApplicationId = clazz->getField<jni::JString>("receiverApplicationId");
|
||||
jni::local_ref<jni::JString> receiverApplicationId = this->getFieldValue(fieldReceiverApplicationId);
|
||||
static const auto fieldNotificationUrl = clazz->getField<jni::JString>("notificationUrl");
|
||||
jni::local_ref<jni::JString> notificationUrl = this->getFieldValue(fieldNotificationUrl);
|
||||
return CastOptions(
|
||||
receiverApplicationId != nullptr ? std::make_optional(receiverApplicationId->toStdString()) : std::nullopt
|
||||
receiverApplicationId != nullptr ? std::make_optional(receiverApplicationId->toStdString()) : std::nullopt,
|
||||
notificationUrl != nullptr ? std::make_optional(notificationUrl->toStdString()) : std::nullopt
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,12 +48,13 @@ namespace margelo::nitro::omni {
|
||||
*/
|
||||
[[maybe_unused]]
|
||||
static jni::local_ref<JCastOptions::javaobject> fromCpp(const CastOptions& value) {
|
||||
using JSignature = JCastOptions(jni::alias_ref<jni::JString>);
|
||||
using JSignature = JCastOptions(jni::alias_ref<jni::JString>, jni::alias_ref<jni::JString>);
|
||||
static const auto clazz = javaClassStatic();
|
||||
static const auto create = clazz->getStaticMethod<JSignature>("fromCpp");
|
||||
return create(
|
||||
clazz,
|
||||
value.receiverApplicationId.has_value() ? jni::make_jstring(value.receiverApplicationId.value()) : nullptr
|
||||
value.receiverApplicationId.has_value() ? jni::make_jstring(value.receiverApplicationId.value()) : nullptr,
|
||||
value.notificationUrl.has_value() ? jni::make_jstring(value.notificationUrl.value()) : nullptr
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
+5
-27
@@ -9,16 +9,6 @@
|
||||
|
||||
// Forward declaration of `HybridOmniPlayerSpec` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { class HybridOmniPlayerSpec; }
|
||||
// Forward declaration of `Source` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Source; }
|
||||
// Forward declaration of `VideoSrc` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct VideoSrc; }
|
||||
// Forward declaration of `Subtitle` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Subtitle; }
|
||||
// Forward declaration of `Metadata` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Metadata; }
|
||||
// Forward declaration of `MixAudioMode` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { enum class MixAudioMode; }
|
||||
// Forward declaration of `PlayerBackend` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct PlayerBackend; }
|
||||
// Forward declaration of `AndroidBackend` to properly resolve imports.
|
||||
@@ -29,26 +19,14 @@ namespace margelo::nitro::omni { struct CastOptions; }
|
||||
#include <memory>
|
||||
#include "HybridOmniPlayerSpec.hpp"
|
||||
#include "JHybridOmniPlayerSpec.hpp"
|
||||
#include "Source.hpp"
|
||||
#include <optional>
|
||||
#include "JSource.hpp"
|
||||
#include "VideoSrc.hpp"
|
||||
#include "JVideoSrc.hpp"
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include "Subtitle.hpp"
|
||||
#include <vector>
|
||||
#include "JSubtitle.hpp"
|
||||
#include "Metadata.hpp"
|
||||
#include "JMetadata.hpp"
|
||||
#include "MixAudioMode.hpp"
|
||||
#include "JMixAudioMode.hpp"
|
||||
#include "PlayerBackend.hpp"
|
||||
#include <optional>
|
||||
#include "JPlayerBackend.hpp"
|
||||
#include "AndroidBackend.hpp"
|
||||
#include "JAndroidBackend.hpp"
|
||||
#include "CastOptions.hpp"
|
||||
#include "JCastOptions.hpp"
|
||||
#include <string>
|
||||
|
||||
namespace margelo::nitro::omni {
|
||||
|
||||
@@ -83,9 +61,9 @@ namespace margelo::nitro::omni {
|
||||
|
||||
|
||||
// Methods
|
||||
std::shared_ptr<HybridOmniPlayerSpec> JHybridOmniPlayerFactorySpec::createPlayer(const std::optional<Source>& props, const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<JHybridOmniPlayerSpec::JavaPart>(jni::alias_ref<JSource> /* props */, jni::alias_ref<JPlayerBackend> /* backend */, jni::alias_ref<JCastOptions> /* cast */)>("createPlayer");
|
||||
auto __result = method(_javaPart, props.has_value() ? JSource::fromCpp(props.value()) : nullptr, backend.has_value() ? JPlayerBackend::fromCpp(backend.value()) : nullptr, cast.has_value() ? JCastOptions::fromCpp(cast.value()) : nullptr);
|
||||
std::shared_ptr<HybridOmniPlayerSpec> JHybridOmniPlayerFactorySpec::createPlayer(const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<JHybridOmniPlayerSpec::JavaPart>(jni::alias_ref<JPlayerBackend> /* backend */, jni::alias_ref<JCastOptions> /* cast */)>("createPlayer");
|
||||
auto __result = method(_javaPart, backend.has_value() ? JPlayerBackend::fromCpp(backend.value()) : nullptr, cast.has_value() ? JCastOptions::fromCpp(cast.value()) : nullptr);
|
||||
return __result->getJHybridOmniPlayerSpec();
|
||||
}
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ namespace margelo::nitro::omni {
|
||||
|
||||
public:
|
||||
// Methods
|
||||
std::shared_ptr<HybridOmniPlayerSpec> createPlayer(const std::optional<Source>& props, const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) override;
|
||||
std::shared_ptr<HybridOmniPlayerSpec> createPlayer(const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) override;
|
||||
|
||||
private:
|
||||
jni::global_ref<JHybridOmniPlayerFactorySpec::JavaPart> _javaPart;
|
||||
|
||||
+2
-2
@@ -241,8 +241,8 @@ namespace margelo::nitro::omni {
|
||||
}
|
||||
|
||||
// Methods
|
||||
void JHybridOmniPlayerSpec::release() {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("release");
|
||||
void JHybridOmniPlayerSpec::abandon() {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("abandon");
|
||||
method(_javaPart);
|
||||
}
|
||||
void JHybridOmniPlayerSpec::play() {
|
||||
|
||||
+1
-1
@@ -78,7 +78,7 @@ namespace margelo::nitro::omni {
|
||||
|
||||
public:
|
||||
// Methods
|
||||
void release() override;
|
||||
void abandon() override;
|
||||
void play() override;
|
||||
void pause() override;
|
||||
void seekBy(double offset) override;
|
||||
|
||||
@@ -20,7 +20,10 @@ import java.util.Objects
|
||||
data class CastOptions(
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
val receiverApplicationId: String?
|
||||
val receiverApplicationId: String?,
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
val notificationUrl: String?
|
||||
) {
|
||||
/* primary constructor */
|
||||
|
||||
@@ -28,11 +31,13 @@ data class CastOptions(
|
||||
if (this === other) return true
|
||||
if (other !is CastOptions) return false
|
||||
return Objects.deepEquals(this.receiverApplicationId, other.receiverApplicationId)
|
||||
&& Objects.deepEquals(this.notificationUrl, other.notificationUrl)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return arrayOf<Any?>(
|
||||
receiverApplicationId
|
||||
receiverApplicationId,
|
||||
notificationUrl
|
||||
).contentDeepHashCode()
|
||||
}
|
||||
|
||||
@@ -44,8 +49,8 @@ data class CastOptions(
|
||||
@Keep
|
||||
@Suppress("unused")
|
||||
@JvmStatic
|
||||
private fun fromCpp(receiverApplicationId: String?): CastOptions {
|
||||
return CastOptions(receiverApplicationId)
|
||||
private fun fromCpp(receiverApplicationId: String?, notificationUrl: String?): CastOptions {
|
||||
return CastOptions(receiverApplicationId, notificationUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ abstract class HybridOmniPlayerFactorySpec: HybridObject() {
|
||||
// Methods
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
abstract fun createPlayer(props: Source?, backend: PlayerBackend?, cast: CastOptions?): HybridOmniPlayerSpec
|
||||
abstract fun createPlayer(backend: PlayerBackend?, cast: CastOptions?): HybridOmniPlayerSpec
|
||||
|
||||
// Default implementation of `HybridObject.toString()`
|
||||
override fun toString(): String {
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ abstract class HybridOmniPlayerSpec: HybridObject() {
|
||||
// Methods
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
abstract fun release(): Unit
|
||||
abstract fun abandon(): Unit
|
||||
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
|
||||
+6
-2
@@ -41,10 +41,11 @@ namespace margelo::nitro::omni {
|
||||
struct CastOptions final {
|
||||
public:
|
||||
std::optional<std::string> receiverApplicationId SWIFT_PRIVATE;
|
||||
std::optional<std::string> notificationUrl SWIFT_PRIVATE;
|
||||
|
||||
public:
|
||||
CastOptions() = default;
|
||||
explicit CastOptions(std::optional<std::string> receiverApplicationId): receiverApplicationId(receiverApplicationId) {}
|
||||
explicit CastOptions(std::optional<std::string> receiverApplicationId, std::optional<std::string> notificationUrl): receiverApplicationId(receiverApplicationId), notificationUrl(notificationUrl) {}
|
||||
|
||||
public:
|
||||
friend bool operator==(const CastOptions& lhs, const CastOptions& rhs) = default;
|
||||
@@ -60,12 +61,14 @@ namespace margelo::nitro {
|
||||
static inline margelo::nitro::omni::CastOptions fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) {
|
||||
jsi::Object obj = arg.asObject(runtime);
|
||||
return margelo::nitro::omni::CastOptions(
|
||||
JSIConverter<std::optional<std::string>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "receiverApplicationId")))
|
||||
JSIConverter<std::optional<std::string>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "receiverApplicationId"))),
|
||||
JSIConverter<std::optional<std::string>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "notificationUrl")))
|
||||
);
|
||||
}
|
||||
static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::omni::CastOptions& arg) {
|
||||
jsi::Object obj(runtime);
|
||||
obj.setProperty(runtime, PropNameIDCache::get(runtime, "receiverApplicationId"), JSIConverter<std::optional<std::string>>::toJSI(runtime, arg.receiverApplicationId));
|
||||
obj.setProperty(runtime, PropNameIDCache::get(runtime, "notificationUrl"), JSIConverter<std::optional<std::string>>::toJSI(runtime, arg.notificationUrl));
|
||||
return obj;
|
||||
}
|
||||
static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) {
|
||||
@@ -77,6 +80,7 @@ namespace margelo::nitro {
|
||||
return false;
|
||||
}
|
||||
if (!JSIConverter<std::optional<std::string>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "receiverApplicationId")))) return false;
|
||||
if (!JSIConverter<std::optional<std::string>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "notificationUrl")))) return false;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -15,8 +15,6 @@
|
||||
|
||||
// Forward declaration of `HybridOmniPlayerSpec` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { class HybridOmniPlayerSpec; }
|
||||
// Forward declaration of `Source` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Source; }
|
||||
// Forward declaration of `PlayerBackend` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct PlayerBackend; }
|
||||
// Forward declaration of `CastOptions` to properly resolve imports.
|
||||
@@ -24,9 +22,8 @@ namespace margelo::nitro::omni { struct CastOptions; }
|
||||
|
||||
#include <memory>
|
||||
#include "HybridOmniPlayerSpec.hpp"
|
||||
#include "Source.hpp"
|
||||
#include <optional>
|
||||
#include "PlayerBackend.hpp"
|
||||
#include <optional>
|
||||
#include "CastOptions.hpp"
|
||||
|
||||
namespace margelo::nitro::omni {
|
||||
@@ -60,7 +57,7 @@ namespace margelo::nitro::omni {
|
||||
|
||||
public:
|
||||
// Methods
|
||||
virtual std::shared_ptr<HybridOmniPlayerSpec> createPlayer(const std::optional<Source>& props, const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) = 0;
|
||||
virtual std::shared_ptr<HybridOmniPlayerSpec> createPlayer(const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) = 0;
|
||||
|
||||
protected:
|
||||
// Hybrid Setup
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ namespace margelo::nitro::omni {
|
||||
prototype.registerHybridGetter("audios", &HybridOmniPlayerSpec::getAudios);
|
||||
prototype.registerHybridGetter("subtitles", &HybridOmniPlayerSpec::getSubtitles);
|
||||
prototype.registerHybridGetter("renditions", &HybridOmniPlayerSpec::getRenditions);
|
||||
prototype.registerHybridMethod("release", &HybridOmniPlayerSpec::release);
|
||||
prototype.registerHybridMethod("abandon", &HybridOmniPlayerSpec::abandon);
|
||||
prototype.registerHybridMethod("play", &HybridOmniPlayerSpec::play);
|
||||
prototype.registerHybridMethod("pause", &HybridOmniPlayerSpec::pause);
|
||||
prototype.registerHybridMethod("seekBy", &HybridOmniPlayerSpec::seekBy);
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ namespace margelo::nitro::omni {
|
||||
|
||||
public:
|
||||
// Methods
|
||||
virtual void release() = 0;
|
||||
virtual void abandon() = 0;
|
||||
virtual void play() = 0;
|
||||
virtual void pause() = 0;
|
||||
virtual void seekBy(double offset) = 0;
|
||||
|
||||
+10
-3
@@ -4,6 +4,7 @@ import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useEffectEvent,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { NitroModules } from "react-native-nitro-modules";
|
||||
@@ -35,7 +36,7 @@ export const OmniProvider = ({
|
||||
const [player, setPlayer] = useState<NativeOmniPlayer | null>(null);
|
||||
|
||||
const createPlayer = useEffectEvent((aBackend: AndroidBackend) =>
|
||||
ProviderFactory.createPlayer(source, { android: aBackend }, cast),
|
||||
ProviderFactory.createPlayer({ android: aBackend }, cast),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,11 +45,17 @@ export const OmniProvider = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (!player) return;
|
||||
return () => player.release();
|
||||
return () => player.abandon();
|
||||
}, [player]);
|
||||
|
||||
const inited = useRef<NativeOmniPlayer | null>(null);
|
||||
useEffect(() => {
|
||||
if (player) player.source = source;
|
||||
if (!player) return;
|
||||
if (inited.current !== player) {
|
||||
inited.current = player;
|
||||
if (source == null) return;
|
||||
}
|
||||
player.source = source;
|
||||
}, [player, source]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
+2
-14
@@ -1,13 +1,7 @@
|
||||
import { createPlayer } from "@videojs/react";
|
||||
import { GoogleCast } from "@videojs/react/media/google-cast";
|
||||
import { videoFeatures } from "@videojs/react/video";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { createContext, type ReactNode, useContext, useEffect } from "react";
|
||||
import { usePlayerState } from "./events";
|
||||
import { WebOmniPlayer } from "./player.web";
|
||||
import type { OmniPlayer, PlayerBackend } from "./types/player";
|
||||
@@ -58,16 +52,10 @@ const PlayerInitializer = ({
|
||||
}) => {
|
||||
const store = VideoPlayer.usePlayer();
|
||||
const player = useLazyRef(() => new WebOmniPlayer(store));
|
||||
const seekedForSrc = useRef<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
player.source = source;
|
||||
const uri = source?.src?.uri;
|
||||
if (uri !== seekedForSrc.current) {
|
||||
seekedForSrc.current = uri;
|
||||
if (source?.startTime) store.seek(source.startTime);
|
||||
}
|
||||
}, [source, store]);
|
||||
}, [source]);
|
||||
|
||||
useEffect(() => {
|
||||
player.showNotification = showNotification;
|
||||
|
||||
@@ -81,13 +81,10 @@ export interface OmniPlayer
|
||||
OmniPlayerT {
|
||||
readonly eventMap: OmniEventMap;
|
||||
|
||||
release(): void;
|
||||
// let go of the player. it stays alive as long as it holds a cast session (android).
|
||||
abandon(): void;
|
||||
}
|
||||
|
||||
export interface OmniPlayerFactory extends HybridObject<{ android: "kotlin" }> {
|
||||
createPlayer(
|
||||
props?: Source,
|
||||
backend?: PlayerBackend,
|
||||
cast?: CastOptions,
|
||||
): OmniPlayer;
|
||||
createPlayer(backend?: PlayerBackend, cast?: CastOptions): OmniPlayer;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface Source {
|
||||
|
||||
export interface CastOptions {
|
||||
receiverApplicationId?: string;
|
||||
// deep link opened when the cast notification is tapped (android only)
|
||||
notificationUrl?: string;
|
||||
}
|
||||
|
||||
export interface VideoSrc {
|
||||
|
||||
+14
-11
@@ -3,10 +3,10 @@ import { HlsJsVideo } from "@videojs/react/media/hlsjs-video";
|
||||
import { Video } from "@videojs/react/video";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import { usePlayerState } from "./events";
|
||||
@@ -20,7 +20,9 @@ const SubtitleOverlay = ({
|
||||
assets,
|
||||
fonts,
|
||||
}: {
|
||||
video: RefObject<HTMLVideoElement>;
|
||||
// not a ref: the overlay is re-created when the element is swapped (which
|
||||
// happens when the tech changes, ie. going from/to hls)
|
||||
video: HTMLVideoElement | null;
|
||||
assets?: SubtitleAssets;
|
||||
fonts?: string[];
|
||||
}) => {
|
||||
@@ -36,8 +38,7 @@ const SubtitleOverlay = ({
|
||||
fontsRef.current = fonts;
|
||||
|
||||
useEffect(() => {
|
||||
const el = video.current;
|
||||
if (!el || !subtitle) return;
|
||||
if (!video || !subtitle) return;
|
||||
let renderer: { destroy(): void } | null = null;
|
||||
let cancelled = false;
|
||||
const attach = (created: { destroy(): void }) => {
|
||||
@@ -58,7 +59,7 @@ const SubtitleOverlay = ({
|
||||
import("jassub")
|
||||
.then(({ default: JASSUB }) => {
|
||||
const instance = new JASSUB({
|
||||
video: el,
|
||||
video,
|
||||
subUrl: subtitle.link,
|
||||
...(videoWidth && { videoWidth }),
|
||||
...(videoHeight && { videoHeight }),
|
||||
@@ -81,7 +82,7 @@ const SubtitleOverlay = ({
|
||||
import("libpgs")
|
||||
.then(({ PgsRenderer }) => {
|
||||
const instance = new PgsRenderer({
|
||||
video: el,
|
||||
video,
|
||||
subUrl: subtitle.link,
|
||||
workerUrl:
|
||||
pgs?.workerUrl ??
|
||||
@@ -107,7 +108,7 @@ export const OmniView = ({
|
||||
subtitleAssets,
|
||||
}: OmniViewProps & { style: CSSProperties }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const ref = useRef<HTMLVideoElement>(undefined!);
|
||||
const [video, setVideo] = useState<HTMLVideoElement | null>(null);
|
||||
const source = usePlayerState("source");
|
||||
|
||||
const isHls =
|
||||
@@ -119,9 +120,11 @@ export const OmniView = ({
|
||||
const headersRef = useRef(source?.src?.headers);
|
||||
headersRef.current = source?.src?.headers;
|
||||
|
||||
const startTime = source?.startTime;
|
||||
const config = useMemo<HlsMediaConfig>(
|
||||
() => ({
|
||||
hlsJs: {
|
||||
...(startTime && { startPosition: startTime }),
|
||||
xhrSetup: (xhr: XMLHttpRequest) => {
|
||||
const headers = headersRef.current;
|
||||
if (!headers) return;
|
||||
@@ -131,7 +134,7 @@ export const OmniView = ({
|
||||
},
|
||||
},
|
||||
}),
|
||||
[],
|
||||
[startTime],
|
||||
);
|
||||
|
||||
// While casting, the receiver renders subtitles (the player forwards the
|
||||
@@ -144,8 +147,8 @@ export const OmniView = ({
|
||||
style={{ position: "relative", ...style }}
|
||||
>
|
||||
<Tech
|
||||
ref={ref}
|
||||
src={source?.src.uri}
|
||||
ref={setVideo}
|
||||
src={source && startTime ? `${source.src.uri}#t=${startTime}` : source?.src.uri}
|
||||
config={config}
|
||||
autoPlay={autoplay}
|
||||
playsInline
|
||||
@@ -165,7 +168,7 @@ export const OmniView = ({
|
||||
</Tech>
|
||||
{castStatus !== "connected" && castStatus !== "connecting" && (
|
||||
<SubtitleOverlay
|
||||
video={ref}
|
||||
video={video}
|
||||
assets={subtitleAssets}
|
||||
fonts={source?.fonts}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user