mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-05 05:36:17 +00:00
feat(android): support chromecast
This commit is contained in:
@@ -12,6 +12,7 @@ videojs v10 on the web (ios not implemented yet, PR welcome)
|
||||
tracks at runtime.
|
||||
- **Rich subtitle support**: vtt, srt, ass (via [jassub](https://github.com/ThaUnknown/jassub)) and pgs (via [libpgs](https://github.com/Arcus92/libpgs-js))
|
||||
- **Picture-in-Picture**: enter PiP automatically or on demand on Android.
|
||||
- **Chromecast**: cast to a receiver on both web and Android .
|
||||
- **and basic player stuff**: media sessions, playlists, hook based api...
|
||||
|
||||
|
||||
@@ -41,6 +42,7 @@ The plugin will:
|
||||
- Enable Picture-in-Picture on your `MainActivity` (declares
|
||||
`supportsPictureInPicture`, adds the required `configChanges`, and hooks the
|
||||
pip lifecycle callbacks).
|
||||
- Register omni's Chromecast `OptionsProvider`
|
||||
|
||||
If you are not using Expo, replicate those manifest/activity changes manually.
|
||||
|
||||
|
||||
@@ -149,6 +149,10 @@ dependencies {
|
||||
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("com.google.android.gms:play-services-cast-framework:21.5.0")
|
||||
implementation("androidx.mediarouter:mediarouter:1.7.0")
|
||||
}
|
||||
|
||||
if (isNewArchitectureEnabled()) {
|
||||
|
||||
@@ -18,6 +18,7 @@ import androidx.media3.common.Player.STATE_READY
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.VideoSize
|
||||
import com.margelo.nitro.omni.BoolProperty
|
||||
import com.margelo.nitro.omni.CastStatus
|
||||
import com.margelo.nitro.omni.HybridOmniEventMapSpec
|
||||
import com.margelo.nitro.omni.NumberProperty
|
||||
import com.margelo.nitro.omni.PlayerStatus
|
||||
@@ -25,7 +26,7 @@ import com.margelo.nitro.omni.Rendition
|
||||
import com.margelo.nitro.omni.Track
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Listener {
|
||||
class EventMap() : HybridOmniEventMapSpec(), Player.Listener {
|
||||
private val onPrevListeners = mutableSetOf<() -> Unit>()
|
||||
private val onNextListeners = mutableSetOf<() -> Unit>()
|
||||
private val onEndListeners = mutableSetOf<() -> Unit>()
|
||||
@@ -38,13 +39,57 @@ class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Li
|
||||
private val stateListeners = mutableMapOf<NumberProperty, MutableSet<(Double) -> Unit>>()
|
||||
private val stateBoolListeners = mutableMapOf<BoolProperty, MutableSet<(Boolean) -> Unit>>()
|
||||
private val playerStatusListeners = mutableSetOf<(PlayerStatus) -> Unit>()
|
||||
private val castStatusListeners = mutableSetOf<(CastStatus) -> Unit>()
|
||||
|
||||
private var lastMediaItemIndex = 0
|
||||
private var lastRendition: Rendition? = null
|
||||
private var lastIsAutoQuality: Boolean? = null
|
||||
|
||||
init {
|
||||
player.addListener(this)
|
||||
// swapped on cast start/end
|
||||
private var _player: Player? = null
|
||||
var player: Player
|
||||
get() = _player!!
|
||||
set(value) {
|
||||
if (_player === value) return
|
||||
_player?.removeListener(this)
|
||||
_player = value
|
||||
value.addListener(this)
|
||||
lastMediaItemIndex = value.currentMediaItemIndex
|
||||
lastRendition = null
|
||||
lastIsAutoQuality = null
|
||||
|
||||
// immediatly send all the state
|
||||
val status = when (value.playbackState) {
|
||||
STATE_BUFFERING -> PlayerStatus.LOADING
|
||||
STATE_READY -> PlayerStatus.READYTOPLAY
|
||||
else -> PlayerStatus.IDLE
|
||||
}
|
||||
playerStatusListeners.forEach { it(status) }
|
||||
stateBoolListeners[BoolProperty.ISPLAYING]?.forEach { it(value.isPlaying) }
|
||||
stateBoolListeners[BoolProperty.MUTED]?.forEach { it(value.volume <= 0f) }
|
||||
stateListeners[NumberProperty.VOLUME]?.forEach { it(value.volume.toDouble()) }
|
||||
stateListeners[NumberProperty.PLAYBACKRATE]?.forEach {
|
||||
it(value.playbackParameters.speed.toDouble())
|
||||
}
|
||||
stateListeners[NumberProperty.CURRENTTIME]?.forEach {
|
||||
it((value.currentPosition.toDouble() / 1000.0).coerceAtLeast(0.0))
|
||||
}
|
||||
stateListeners[NumberProperty.BUFFERED]?.forEach {
|
||||
it((value.totalBufferedDuration.toDouble() / 1000.0).coerceAtLeast(0.0))
|
||||
}
|
||||
stateListeners[NumberProperty.DURATION]?.forEach {
|
||||
val duration = value.duration
|
||||
it(
|
||||
if (duration == C.TIME_UNSET) 0.0 else (duration.toDouble() / 1000.0).coerceAtLeast(
|
||||
0.0
|
||||
)
|
||||
)
|
||||
}
|
||||
onTracksChanged(value.currentTracks)
|
||||
}
|
||||
|
||||
fun emitCastStatus(status: CastStatus) {
|
||||
castStatusListeners.forEach { it(status) }
|
||||
}
|
||||
|
||||
private fun selectedTrack(trackType: Int): Track? {
|
||||
@@ -83,6 +128,7 @@ class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Li
|
||||
}
|
||||
} else null
|
||||
}
|
||||
|
||||
else -> (0 until group.length).firstOrNull { group.isTrackSelected(it) }
|
||||
} ?: return null
|
||||
|
||||
@@ -121,6 +167,7 @@ class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Li
|
||||
onEndListeners.forEach { it() }
|
||||
PlayerStatus.IDLE
|
||||
}
|
||||
|
||||
else -> PlayerStatus.IDLE
|
||||
}
|
||||
playerStatusListeners.forEach { it(state) }
|
||||
@@ -213,7 +260,11 @@ class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Li
|
||||
}
|
||||
stateListeners[NumberProperty.DURATION]?.forEach {
|
||||
val duration = player.duration
|
||||
it(if (duration == C.TIME_UNSET) 0.0 else (duration.toDouble() / 1000.0).coerceAtLeast(0.0))
|
||||
it(
|
||||
if (duration == C.TIME_UNSET) 0.0 else (duration.toDouble() / 1000.0).coerceAtLeast(
|
||||
0.0
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,6 +292,14 @@ class EventMap(private val player: Player) : HybridOmniEventMapSpec(), Player.Li
|
||||
playerStatusListeners.remove(cb)
|
||||
}
|
||||
|
||||
override fun addCastStatusListener(cb: (value: CastStatus) -> Unit) {
|
||||
castStatusListeners.add(cb)
|
||||
}
|
||||
|
||||
override fun removeCastStatusListener(cb: (value: CastStatus) -> Unit) {
|
||||
castStatusListeners.remove(cb)
|
||||
}
|
||||
|
||||
override fun addOnEndListener(cb: () -> Unit) {
|
||||
onEndListeners.add(cb)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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.OptionsProvider
|
||||
import com.google.android.gms.cast.framework.SessionProvider
|
||||
|
||||
class OmniCastOptionsProvider : OptionsProvider {
|
||||
override fun getCastOptions(context: Context): CastOptions {
|
||||
val appId = OmniPlayer.receiverApplicationId
|
||||
?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
|
||||
|
||||
return CastOptions.Builder()
|
||||
.setReceiverApplicationId(appId)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? = null
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package dev.zoriya.omni
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.media3.cast.CastTrackSelector
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.TrackGroup
|
||||
import androidx.media3.common.util.Util
|
||||
import com.google.android.gms.cast.MediaTrack
|
||||
import com.google.common.collect.ImmutableSet
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
class OmniCastTrackSelector : CastTrackSelector() {
|
||||
override fun evaluate(request: CastTrackSelectorRequest): CastTrackSelectorResult {
|
||||
val params = request.trackSelectionParameters
|
||||
val selections = LinkedHashSet(request.currentlySelectedTrackGroups)
|
||||
|
||||
selectType(
|
||||
selections,
|
||||
request,
|
||||
castType = MediaTrack.TYPE_AUDIO,
|
||||
languages = params.preferredAudioLanguages,
|
||||
labels = params.preferredAudioLabels,
|
||||
disabled = C.TRACK_TYPE_AUDIO in params.disabledTrackTypes,
|
||||
selectDefault = true,
|
||||
)
|
||||
selectType(
|
||||
selections,
|
||||
request,
|
||||
castType = MediaTrack.TYPE_TEXT,
|
||||
languages = params.preferredTextLanguages,
|
||||
labels = params.preferredTextLabels,
|
||||
disabled = C.TRACK_TYPE_TEXT in params.disabledTrackTypes,
|
||||
selectDefault = false,
|
||||
)
|
||||
|
||||
return request.buildResultUpon()
|
||||
.setSelections(ImmutableSet.copyOf(selections))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun selectType(
|
||||
selections: MutableSet<TrackGroup>,
|
||||
request: CastTrackSelectorRequest,
|
||||
castType: Int,
|
||||
languages: List<String>,
|
||||
labels: List<String>,
|
||||
disabled: Boolean,
|
||||
selectDefault: Boolean,
|
||||
) {
|
||||
val tracks = request.mediaTracks
|
||||
val groups = request.trackGroupList
|
||||
val indices = tracks.indices.filter { tracks[it].type == castType }
|
||||
if (indices.isEmpty()) return
|
||||
|
||||
val previous = indices.filter { groups[it] in selections }
|
||||
indices.forEach { selections.remove(groups[it]) }
|
||||
if (disabled) return
|
||||
|
||||
val match = indices.firstOrNull { i ->
|
||||
val name: String? = tracks[i].name
|
||||
val language: String? = tracks[i].language
|
||||
(labels.isNotEmpty() && name != null && name in labels) ||
|
||||
(languages.isNotEmpty() && language != null &&
|
||||
Util.normalizeLanguageCode(language) in languages)
|
||||
}
|
||||
val chosen = when {
|
||||
match != null -> match
|
||||
previous.isNotEmpty() -> previous.first()
|
||||
selectDefault -> indices.first()
|
||||
else -> null
|
||||
}
|
||||
chosen?.let { selections.add(groups[it]) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package dev.zoriya.omni
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.cast.MediaItemConverter
|
||||
import androidx.media3.common.MediaItem
|
||||
import com.google.android.gms.cast.MediaInfo
|
||||
import com.google.android.gms.cast.MediaQueueItem
|
||||
import com.google.android.gms.cast.MediaTrack
|
||||
import com.google.android.gms.common.images.WebImage
|
||||
import org.json.JSONObject
|
||||
import com.google.android.gms.cast.MediaMetadata as CastMediaMetadata
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
class OmniMediaItemConverter : MediaItemConverter {
|
||||
override fun toMediaItem(mediaQueueItem: MediaQueueItem): MediaItem {
|
||||
val info = mediaQueueItem.media
|
||||
val uri = info?.contentUrl ?: info?.contentId ?: ""
|
||||
val builder = MediaItem.Builder()
|
||||
.setUri(uri)
|
||||
.setMediaId(info?.contentId ?: uri)
|
||||
info?.contentType?.let { builder.setMimeType(it) }
|
||||
info?.metadata?.let { md ->
|
||||
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))
|
||||
.build()
|
||||
)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
override fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem {
|
||||
val local = requireNotNull(mediaItem.localConfiguration) {
|
||||
"MediaItem must have a localConfiguration to be cast"
|
||||
}
|
||||
val uri = local.uri.toString()
|
||||
val extras = mediaItem.requestMetadata.extras
|
||||
val contentId = extras?.getString(OmniPlayer.CAST_ID_EXTRA) ?: uri
|
||||
|
||||
val meta = mediaItem.mediaMetadata
|
||||
val castMetadata = CastMediaMetadata(CastMediaMetadata.MEDIA_TYPE_MOVIE).apply {
|
||||
meta.title?.let { putString(CastMediaMetadata.KEY_TITLE, it.toString()) }
|
||||
meta.artist?.let { putString(CastMediaMetadata.KEY_ARTIST, it.toString()) }
|
||||
meta.albumTitle?.let { putString(CastMediaMetadata.KEY_ALBUM_TITLE, it.toString()) }
|
||||
meta.artworkUri?.let { addImage(WebImage(it)) }
|
||||
}
|
||||
|
||||
val customData = extras?.getString(OmniPlayer.CAST_DATA_EXTRA)?.let {
|
||||
try {
|
||||
JSONObject(it)
|
||||
} catch (_: Throwable) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
val tracks = local.subtitleConfigurations.mapIndexed { index, subtitle ->
|
||||
MediaTrack.Builder(index.toLong(), MediaTrack.TYPE_TEXT)
|
||||
.setSubtype(MediaTrack.SUBTYPE_SUBTITLES)
|
||||
.setContentId(subtitle.uri.toString())
|
||||
.setContentType(subtitle.mimeType ?: "text/vtt")
|
||||
.apply {
|
||||
subtitle.label?.let { setName(it) }
|
||||
subtitle.language?.let { setLanguage(it) }
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
val path = uri.toUri().path?.lowercase() ?: uri.lowercase()
|
||||
val contentType = local.mimeType ?: when {
|
||||
path.endsWith(".mpd") -> "application/dash+xml"
|
||||
path.endsWith(".mp4") -> "video/mp4"
|
||||
path.endsWith(".webm") -> "video/webm"
|
||||
path.endsWith(".mkv") -> "video/x-matroska"
|
||||
else -> "application/x-mpegurl"
|
||||
}
|
||||
|
||||
val mediaInfo = MediaInfo.Builder(contentId)
|
||||
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||
.setContentUrl(uri)
|
||||
.setContentType(contentType)
|
||||
.setMetadata(castMetadata)
|
||||
.apply {
|
||||
if (tracks.isNotEmpty()) setMediaTracks(tracks)
|
||||
customData?.let { setCustomData(it) }
|
||||
}
|
||||
.build()
|
||||
|
||||
return MediaQueueItem.Builder(mediaInfo)
|
||||
.setAutoplay(true)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -32,21 +32,32 @@ 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 com.google.android.gms.cast.framework.CastContext
|
||||
import com.google.android.gms.cast.framework.CastState
|
||||
import com.google.android.gms.cast.framework.CastStateListener
|
||||
import com.margelo.nitro.omni.CastOptions
|
||||
import dev.zoriya.omni.utils.ThreadHelper.mainThreadProperty
|
||||
import dev.zoriya.omni.utils.ThreadHelper.runOnMainThread
|
||||
import dev.zoriya.omni.utils.ThreadHelper.runOnMainThreadSync
|
||||
import org.json.JSONObject
|
||||
|
||||
@SuppressLint("UnsafeOptInUsageError")
|
||||
class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : HybridOmniPlayerSpec() {
|
||||
class OmniPlayer(
|
||||
private val backend: AndroidBackend = AndroidBackend.VLC,
|
||||
castOptions: CastOptions? = null,
|
||||
) : HybridOmniPlayerSpec() {
|
||||
private val ctx = NitroModules.applicationContext ?: throw Error("No Context available!")
|
||||
|
||||
// exoplayer only, used to specify request headers.
|
||||
private var httpDataSourceFactory: DefaultHttpDataSource.Factory? = null
|
||||
|
||||
val player: Player = runOnMainThreadSync {
|
||||
val localPlayer: Player = runOnMainThreadSync {
|
||||
when (backend) {
|
||||
AndroidBackend.EXOPLAYER -> {
|
||||
val http = DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
|
||||
@@ -61,7 +72,37 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb
|
||||
AndroidBackend.VLC -> VlcPlayer(ctx)
|
||||
}
|
||||
}
|
||||
override val eventMap = EventMap(player)
|
||||
override val eventMap = EventMap()
|
||||
|
||||
private var castContext: CastContext? = null
|
||||
private val castStateListener =
|
||||
CastStateListener { eventMap.emitCastStatus(computeCastStatus()) }
|
||||
|
||||
val player: Player = runOnMainThreadSync {
|
||||
castOptions?.receiverApplicationId?.let { receiverApplicationId = it }
|
||||
val cc = try {
|
||||
CastContext.getSharedInstance(ctx)
|
||||
} catch (_: Throwable) {
|
||||
// No Google Play Services / Cast SDK -> casting unsupported.
|
||||
null
|
||||
}
|
||||
castContext = cc
|
||||
val active = if (cc == null) {
|
||||
localPlayer
|
||||
} else {
|
||||
cc.addCastStateListener(castStateListener)
|
||||
val remote = RemoteCastPlayer.Builder(ctx)
|
||||
.setMediaItemConverter(OmniMediaItemConverter())
|
||||
.setTrackSelector(OmniCastTrackSelector())
|
||||
.build()
|
||||
CastPlayer.Builder(ctx)
|
||||
.setLocalPlayer(localPlayer)
|
||||
.setRemotePlayer(remote)
|
||||
.build()
|
||||
}
|
||||
eventMap.player = active
|
||||
active
|
||||
}
|
||||
|
||||
override var showNotification: Boolean? = false
|
||||
set(value) {
|
||||
@@ -69,20 +110,41 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb
|
||||
if (notificationPlayer != null && notificationPlayer?.isPlaying == true) {
|
||||
throw Error("Two players can't display notifications at the same time.")
|
||||
}
|
||||
notificationPlayer = player
|
||||
notificationPlayer = localPlayer
|
||||
ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java))
|
||||
} else if (field == true && notificationPlayer == player) {
|
||||
} else if (field == true && notificationPlayer == localPlayer) {
|
||||
ctx.stopService(Intent(ctx, OmniPlayerService::class.java))
|
||||
notificationPlayer = null
|
||||
}
|
||||
field = value
|
||||
}
|
||||
|
||||
// TODO: cast is not implemented on the native (Android) side yet.
|
||||
override val castStatus: CastStatus get() = CastStatus.UNSUPPORTED
|
||||
override val castStatus: CastStatus
|
||||
get() = runOnMainThreadSync { computeCastStatus() }
|
||||
|
||||
private fun computeCastStatus(): CastStatus {
|
||||
val cc = castContext ?: return CastStatus.UNSUPPORTED
|
||||
return when (cc.castState) {
|
||||
CastState.NO_DEVICES_AVAILABLE -> CastStatus.UNAVAILABLE
|
||||
CastState.NOT_CONNECTED -> CastStatus.AVAILABLE
|
||||
CastState.CONNECTING -> CastStatus.CONNECTING
|
||||
CastState.CONNECTED -> CastStatus.CONNECTED
|
||||
else -> CastStatus.UNAVAILABLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun toggleCastStatus() {
|
||||
// TODO: implement casting on Android.
|
||||
runOnMainThread {
|
||||
val cc = castContext ?: return@runOnMainThread
|
||||
val session = cc.sessionManager.currentCastSession
|
||||
if (session != null && session.isConnected) {
|
||||
cc.sessionManager.endCurrentSession(true)
|
||||
return@runOnMainThread
|
||||
}
|
||||
val activity = ctx.currentActivity ?: return@runOnMainThread
|
||||
val selector = cc.mergedSelector ?: return@runOnMainThread
|
||||
MediaRouteChooserDialog(activity).apply { routeSelector = selector }.show()
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
@@ -90,18 +152,20 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb
|
||||
super.dispose()
|
||||
|
||||
eventMap.dispose()
|
||||
runOnMainThread { player.release() }
|
||||
runOnMainThread {
|
||||
castContext?.removeCastStateListener(castStateListener)
|
||||
// release both cast and local players.
|
||||
player.release()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildMediaItem(
|
||||
src: com.margelo.nitro.omni.VideoSrc,
|
||||
metadata: com.margelo.nitro.omni.Metadata?,
|
||||
subtitles: Array<com.margelo.nitro.omni.Subtitle>
|
||||
subtitles: Array<com.margelo.nitro.omni.Subtitle>,
|
||||
castId: String? = null,
|
||||
castData: Map<String, String>? = null,
|
||||
): MediaItem {
|
||||
// vlc only path (and it only supports a few headers :c)
|
||||
val extras = if (src.headers.isEmpty()) null else Bundle(src.headers.size).apply {
|
||||
for ((name, value) in src.headers) putString(name, value)
|
||||
}
|
||||
return MediaItem.Builder()
|
||||
.setUri(src.uri)
|
||||
.setMimeType(src.mimeType)
|
||||
@@ -126,7 +190,16 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb
|
||||
.setRequestMetadata(
|
||||
RequestMetadata.Builder()
|
||||
.setMediaUri(src.uri.toUri())
|
||||
.apply { extras?.let { setExtras(it) } }
|
||||
.setExtras(
|
||||
Bundle().apply {
|
||||
castId?.let { putString(CAST_ID_EXTRA, it) }
|
||||
castData?.let { data ->
|
||||
putString(CAST_DATA_EXTRA, JSONObject(data as Map<*, *>).toString())
|
||||
}
|
||||
// vlc only path (and it only supports a few headers :c).
|
||||
for ((name, value) in src.headers) putString(name, value)
|
||||
}
|
||||
)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
@@ -135,9 +208,9 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb
|
||||
fun setSurface(holder: SurfaceHolder?) {
|
||||
runOnMainThread {
|
||||
if (holder == null) {
|
||||
player.clearVideoSurface()
|
||||
localPlayer.clearVideoSurface()
|
||||
} else {
|
||||
player.setVideoSurfaceHolder(holder)
|
||||
localPlayer.setVideoSurfaceHolder(holder)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -211,7 +284,9 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
runOnMainThread { player.setAudioAttributes(audioAttributes, handleAudioFocus) }
|
||||
// audio focus only makes sense locally. not in a cast session
|
||||
runOnMainThread { localPlayer.setAudioAttributes(audioAttributes, handleAudioFocus) }
|
||||
|
||||
val firstSrc = value.src.firstOrNull()
|
||||
if (firstSrc == null) {
|
||||
runOnMainThreadSync {
|
||||
@@ -220,25 +295,23 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
httpDataSourceFactory?.setDefaultRequestProperties(firstSrc.headers)
|
||||
|
||||
val currentItem = buildMediaItem(firstSrc, value.metadata, value.subtitles)
|
||||
val currentItem = buildMediaItem(
|
||||
firstSrc,
|
||||
value.metadata,
|
||||
value.subtitles,
|
||||
value.castId,
|
||||
value.castData,
|
||||
)
|
||||
val mediaItems = mutableListOf<MediaItem>()
|
||||
|
||||
if (value.metadata?.hasPrev == true) {
|
||||
mediaItems.add(currentItem)
|
||||
}
|
||||
|
||||
if (value.metadata?.hasPrev == true) mediaItems.add(currentItem)
|
||||
mediaItems.add(currentItem)
|
||||
|
||||
if (value.metadata?.hasNext == true) {
|
||||
mediaItems.add(currentItem)
|
||||
}
|
||||
if (value.metadata?.hasNext == true) mediaItems.add(currentItem)
|
||||
|
||||
runOnMainThreadSync {
|
||||
val startIndex = if (value.metadata?.hasPrev == true) 1 else 0
|
||||
val startPositionMs = (value.startTime?.coerceAtLeast(0.0) ?: 0.0) * 1000.0
|
||||
val startPositionMs = ((value.startTime ?: 0.0).coerceAtLeast(0.0)) * 1000.0
|
||||
player.setMediaItems(mediaItems, startIndex, startPositionMs.toLong())
|
||||
player.prepare()
|
||||
}
|
||||
@@ -288,8 +361,23 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb
|
||||
}
|
||||
|
||||
override fun selectSubtitle(subtitle: Track?) {
|
||||
// Custom (ASS/PGS) subtitles can't be rendered by the cast receiver's
|
||||
// native pipeline, so while casting they are forwarded over omni's
|
||||
// message channel (the receiver draws them as an overlay). This mirrors
|
||||
// the web behavior.
|
||||
val custom = subtitle?.let { track ->
|
||||
source?.subtitles?.firstOrNull { it.id == track.id }?.let { sub ->
|
||||
val mime = sub.mimeType?.lowercase() ?: ""
|
||||
val ext = sub.link.substringBefore('?').substringBefore('#')
|
||||
.substringAfterLast('.', "").lowercase()
|
||||
mime.contains("ass") || mime.contains("ssa") || mime.contains("pgs") ||
|
||||
ext == "ass" || ext == "ssa" || ext == "sup"
|
||||
}
|
||||
} ?: false
|
||||
runOnMainThreadSync {
|
||||
if (subtitle == null) {
|
||||
val session = castContext?.sessionManager?.currentCastSession
|
||||
val casting = session?.isConnected == true
|
||||
if (subtitle == null || (custom && casting)) {
|
||||
player.trackSelectionParameters = player.trackSelectionParameters
|
||||
.buildUpon()
|
||||
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true)
|
||||
@@ -302,6 +390,15 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb
|
||||
.setPreferredTextLabels(*(subtitle.label?.let { arrayOf(it) } ?: emptyArray()))
|
||||
.build()
|
||||
}
|
||||
if (casting) {
|
||||
val id = if (custom) subtitle?.id else null
|
||||
val payload = JSONObject().apply { put("subtitle", id ?: JSONObject.NULL) }
|
||||
try {
|
||||
session?.sendMessage(CAST_MESSAGE_NAMESPACE, payload.toString())
|
||||
} catch (_: Throwable) {
|
||||
// best effort; receiver may not support the namespace.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +480,18 @@ class OmniPlayer(private val backend: AndroidBackend = AndroidBackend.VLC) : Hyb
|
||||
|
||||
companion object {
|
||||
var notificationPlayer: Player? = null
|
||||
|
||||
// Cast custom-message channel (shared with the web receiver) used to
|
||||
// forward overlay (ASS/PGS) subtitle selection to the receiver.
|
||||
const val CAST_MESSAGE_NAMESPACE = "urn:x-cast:dev.zoriya.omni"
|
||||
|
||||
// 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"
|
||||
|
||||
// Receiver application id passed at runtime via OmniProvider's `cast`
|
||||
// prop; read by OmniCastOptionsProvider when the Cast SDK initializes.
|
||||
var receiverApplicationId: String? = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
package dev.zoriya.omni
|
||||
|
||||
import android.util.Log
|
||||
import com.facebook.react.uimanager.ThemedReactContext
|
||||
import com.margelo.nitro.omni.AndroidBackend
|
||||
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?): HybridOmniPlayerSpec {
|
||||
return OmniPlayer(backend?.android ?: AndroidBackend.VLC).apply {
|
||||
override fun createPlayer(
|
||||
props: Source?,
|
||||
backend: PlayerBackend?,
|
||||
cast: CastOptions?,
|
||||
): HybridOmniPlayerSpec {
|
||||
return OmniPlayer(backend?.android ?: AndroidBackend.VLC, cast).apply {
|
||||
source = props
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,10 +138,10 @@ class OmniView(val context: ThemedReactContext) :
|
||||
return
|
||||
}
|
||||
|
||||
boundPlayer?.player?.removeListener(this)
|
||||
boundPlayer?.localPlayer?.removeListener(this)
|
||||
boundPlayer?.setSurface(null)
|
||||
boundPlayer = omniPlayer
|
||||
omniPlayer.player.addListener(this)
|
||||
omniPlayer.localPlayer.addListener(this)
|
||||
|
||||
if (surfaceReady) {
|
||||
omniPlayer.setSurface(surfaceView.holder)
|
||||
@@ -163,7 +163,7 @@ class OmniView(val context: ThemedReactContext) :
|
||||
if (!::player.isInitialized) return
|
||||
|
||||
val omniPlayer = player as? OmniPlayer ?: return
|
||||
boundPlayer?.player?.removeListener(this)
|
||||
boundPlayer?.localPlayer?.removeListener(this)
|
||||
omniPlayer.setSurface(null)
|
||||
boundPlayer = null
|
||||
}
|
||||
|
||||
@@ -118,6 +118,7 @@ class VlcPlayer(ctx: Context) :
|
||||
.add(COMMAND_SET_VOLUME)
|
||||
.add(COMMAND_SET_VIDEO_SURFACE)
|
||||
.add(COMMAND_SET_MEDIA_ITEM)
|
||||
.add(COMMAND_CHANGE_MEDIA_ITEMS)
|
||||
.add(COMMAND_GET_CURRENT_MEDIA_ITEM)
|
||||
.add(COMMAND_GET_METADATA)
|
||||
.add(COMMAND_GET_TIMELINE)
|
||||
@@ -438,8 +439,10 @@ class VlcPlayer(ctx: Context) :
|
||||
|
||||
override fun getPlaybackState(): Int =
|
||||
when {
|
||||
playerError != null -> STATE_IDLE
|
||||
currentMediaItemIndex == INDEX_UNSET -> STATE_IDLE
|
||||
player.media == null -> STATE_IDLE
|
||||
player.playerState == IMedia.State.Opening -> STATE_BUFFERING
|
||||
player.isPlaying -> STATE_READY
|
||||
player.isSeekable && player.time >= player.length && player.length > 0 -> STATE_ENDED
|
||||
else -> STATE_READY
|
||||
@@ -520,6 +523,7 @@ class VlcPlayer(ctx: Context) :
|
||||
Format.Builder()
|
||||
.setId(track.id)
|
||||
.setLabel(track.name)
|
||||
.setLanguage(track.language)
|
||||
.setSampleMimeType("video/x-unknown")
|
||||
.build()
|
||||
}
|
||||
@@ -535,6 +539,7 @@ class VlcPlayer(ctx: Context) :
|
||||
val format = Format.Builder()
|
||||
.setId(track.id)
|
||||
.setLabel(track.name)
|
||||
.setLanguage(track.language)
|
||||
.setSampleMimeType("audio/x-unknown")
|
||||
.build()
|
||||
val group = TrackGroup("vlc-audio-${track.id}", format)
|
||||
@@ -547,6 +552,7 @@ class VlcPlayer(ctx: Context) :
|
||||
val format = Format.Builder()
|
||||
.setId(track.id)
|
||||
.setLabel(track.name)
|
||||
.setLanguage(track.language)
|
||||
.setSampleMimeType("text/x-unknown")
|
||||
.build()
|
||||
val group = TrackGroup("vlc-sub-${track.id}", format)
|
||||
|
||||
@@ -69,6 +69,7 @@ function PlayerExample({
|
||||
const muted = usePlayerState("muted");
|
||||
const volume = usePlayerState("volume");
|
||||
const isAutoQuality = usePlayerState("isAutoQuality");
|
||||
const castStatus = usePlayerState("castStatus");
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [tracks, setTracks] = useState(() => ({
|
||||
videos: [...player.videos],
|
||||
@@ -265,6 +266,21 @@ function PlayerExample({
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
style={styles.button}
|
||||
disabled={castStatus === "unsupported"}
|
||||
onPress={() => player.toggleCastStatus()}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{castStatus === "connected" || castStatus === "connecting"
|
||||
? "Stop cast"
|
||||
: "Cast"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Text style={styles.buttonText}>Cast: {castStatus}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsCard}>
|
||||
<Text style={styles.statText}>Status: {status}</Text>
|
||||
<Text style={styles.statText}>
|
||||
|
||||
@@ -25,5 +25,8 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||
android:value="dev.zoriya.omni.OmniCastOptionsProvider" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
+2
@@ -19,6 +19,7 @@
|
||||
#include "JFunc_void_double.hpp"
|
||||
#include "JFunc_void_bool.hpp"
|
||||
#include "JFunc_void_PlayerStatus.hpp"
|
||||
#include "JFunc_void_CastStatus.hpp"
|
||||
#include "JFunc_void.hpp"
|
||||
#include "JFunc_void_std__string_std__string.hpp"
|
||||
#include "JFunc_void_std__string.hpp"
|
||||
@@ -65,6 +66,7 @@ void registerAllNatives() {
|
||||
margelo::nitro::omni::JFunc_void_double_cxx::registerNatives();
|
||||
margelo::nitro::omni::JFunc_void_bool_cxx::registerNatives();
|
||||
margelo::nitro::omni::JFunc_void_PlayerStatus_cxx::registerNatives();
|
||||
margelo::nitro::omni::JFunc_void_CastStatus_cxx::registerNatives();
|
||||
margelo::nitro::omni::JFunc_void_cxx::registerNatives();
|
||||
margelo::nitro::omni::JFunc_void_std__string_std__string_cxx::registerNatives();
|
||||
margelo::nitro::omni::JFunc_void_std__string_cxx::registerNatives();
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
///
|
||||
/// JCastOptions.hpp
|
||||
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
|
||||
/// https://github.com/mrousavy/nitro
|
||||
/// Copyright © Marc Rousavy @ Margelo
|
||||
///
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <fbjni/fbjni.h>
|
||||
#include "CastOptions.hpp"
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
namespace margelo::nitro::omni {
|
||||
|
||||
using namespace facebook;
|
||||
|
||||
/**
|
||||
* The C++ JNI bridge between the C++ struct "CastOptions" and the Kotlin data class "CastOptions".
|
||||
*/
|
||||
struct JCastOptions final: public jni::JavaClass<JCastOptions> {
|
||||
public:
|
||||
static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/omni/CastOptions;";
|
||||
|
||||
public:
|
||||
/**
|
||||
* Convert this Java/Kotlin-based struct to the C++ struct CastOptions by copying all values to C++.
|
||||
*/
|
||||
[[maybe_unused]]
|
||||
[[nodiscard]]
|
||||
CastOptions toCpp() const {
|
||||
static const auto clazz = javaClassStatic();
|
||||
static const auto fieldReceiverApplicationId = clazz->getField<jni::JString>("receiverApplicationId");
|
||||
jni::local_ref<jni::JString> receiverApplicationId = this->getFieldValue(fieldReceiverApplicationId);
|
||||
return CastOptions(
|
||||
receiverApplicationId != nullptr ? std::make_optional(receiverApplicationId->toStdString()) : std::nullopt
|
||||
);
|
||||
}
|
||||
|
||||
public:
|
||||
/**
|
||||
* Create a Java/Kotlin-based struct by copying all values from the given C++ struct to Java.
|
||||
*/
|
||||
[[maybe_unused]]
|
||||
static jni::local_ref<JCastOptions::javaobject> fromCpp(const CastOptions& value) {
|
||||
using JSignature = JCastOptions(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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace margelo::nitro::omni
|
||||
@@ -0,0 +1,77 @@
|
||||
///
|
||||
/// JFunc_void_CastStatus.hpp
|
||||
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
|
||||
/// https://github.com/mrousavy/nitro
|
||||
/// Copyright © Marc Rousavy @ Margelo
|
||||
///
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <fbjni/fbjni.h>
|
||||
#include <functional>
|
||||
|
||||
#include "CastStatus.hpp"
|
||||
#include <functional>
|
||||
#include <NitroModules/JNICallable.hpp>
|
||||
#include "JCastStatus.hpp"
|
||||
|
||||
namespace margelo::nitro::omni {
|
||||
|
||||
using namespace facebook;
|
||||
|
||||
/**
|
||||
* Represents the Java/Kotlin callback `(value: CastStatus) -> Unit`.
|
||||
* This can be passed around between C++ and Java/Kotlin.
|
||||
*/
|
||||
struct JFunc_void_CastStatus: public jni::JavaClass<JFunc_void_CastStatus> {
|
||||
public:
|
||||
static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/omni/Func_void_CastStatus;";
|
||||
|
||||
public:
|
||||
/**
|
||||
* Invokes the function this `JFunc_void_CastStatus` instance holds through JNI.
|
||||
*/
|
||||
void invoke(CastStatus value) const {
|
||||
static const auto method = javaClassStatic()->getMethod<void(jni::alias_ref<JCastStatus> /* value */)>("invoke");
|
||||
method(self(), JCastStatus::fromCpp(value));
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* An implementation of Func_void_CastStatus that is backed by a C++ implementation (using `std::function<...>`)
|
||||
*/
|
||||
class JFunc_void_CastStatus_cxx final: public jni::HybridClass<JFunc_void_CastStatus_cxx, JFunc_void_CastStatus> {
|
||||
public:
|
||||
static jni::local_ref<JFunc_void_CastStatus::javaobject> fromCpp(const std::function<void(CastStatus /* value */)>& func) {
|
||||
return JFunc_void_CastStatus_cxx::newObjectCxxArgs(func);
|
||||
}
|
||||
|
||||
public:
|
||||
/**
|
||||
* Invokes the C++ `std::function<...>` this `JFunc_void_CastStatus_cxx` instance holds.
|
||||
*/
|
||||
void invoke_cxx(jni::alias_ref<JCastStatus> value) {
|
||||
_func(value->toCpp());
|
||||
}
|
||||
|
||||
public:
|
||||
[[nodiscard]]
|
||||
inline const std::function<void(CastStatus /* value */)>& getFunction() const {
|
||||
return _func;
|
||||
}
|
||||
|
||||
public:
|
||||
static constexpr auto kJavaDescriptor = "Lcom/margelo/nitro/omni/Func_void_CastStatus_cxx;";
|
||||
static void registerNatives() {
|
||||
registerHybrid({makeNativeMethod("invoke_cxx", JFunc_void_CastStatus_cxx::invoke_cxx)});
|
||||
}
|
||||
|
||||
private:
|
||||
explicit JFunc_void_CastStatus_cxx(const std::function<void(CastStatus /* value */)>& func): _func(func) { }
|
||||
|
||||
private:
|
||||
friend HybridBase;
|
||||
std::function<void(CastStatus /* value */)> _func;
|
||||
};
|
||||
|
||||
} // namespace margelo::nitro::omni
|
||||
@@ -13,6 +13,8 @@ namespace margelo::nitro::omni { enum class NumberProperty; }
|
||||
namespace margelo::nitro::omni { enum class BoolProperty; }
|
||||
// Forward declaration of `PlayerStatus` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { enum class PlayerStatus; }
|
||||
// Forward declaration of `CastStatus` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { enum class CastStatus; }
|
||||
// Forward declaration of `Track` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Track; }
|
||||
// Forward declaration of `Rendition` to properly resolve imports.
|
||||
@@ -29,6 +31,9 @@ namespace margelo::nitro::omni { struct Rendition; }
|
||||
#include "PlayerStatus.hpp"
|
||||
#include "JFunc_void_PlayerStatus.hpp"
|
||||
#include "JPlayerStatus.hpp"
|
||||
#include "CastStatus.hpp"
|
||||
#include "JFunc_void_CastStatus.hpp"
|
||||
#include "JCastStatus.hpp"
|
||||
#include "JFunc_void.hpp"
|
||||
#include <string>
|
||||
#include "JFunc_void_std__string_std__string.hpp"
|
||||
@@ -99,6 +104,14 @@ namespace margelo::nitro::omni {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<JFunc_void_PlayerStatus::javaobject> /* cb */)>("removePlayerStatusListener_cxx");
|
||||
method(_javaPart, JFunc_void_PlayerStatus_cxx::fromCpp(cb));
|
||||
}
|
||||
void JHybridOmniEventMapSpec::addCastStatusListener(const std::function<void(CastStatus /* value */)>& cb) {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<JFunc_void_CastStatus::javaobject> /* cb */)>("addCastStatusListener_cxx");
|
||||
method(_javaPart, JFunc_void_CastStatus_cxx::fromCpp(cb));
|
||||
}
|
||||
void JHybridOmniEventMapSpec::removeCastStatusListener(const std::function<void(CastStatus /* value */)>& cb) {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<JFunc_void_CastStatus::javaobject> /* cb */)>("removeCastStatusListener_cxx");
|
||||
method(_javaPart, JFunc_void_CastStatus_cxx::fromCpp(cb));
|
||||
}
|
||||
void JHybridOmniEventMapSpec::addOnEndListener(const std::function<void()>& cb) {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<JFunc_void::javaobject> /* cb */)>("addOnEndListener_cxx");
|
||||
method(_javaPart, JFunc_void_cxx::fromCpp(cb));
|
||||
|
||||
@@ -60,6 +60,8 @@ namespace margelo::nitro::omni {
|
||||
void removeStateBoolListener(BoolProperty key, const std::function<void(bool /* value */)>& cb) override;
|
||||
void addPlayerStatusListener(const std::function<void(PlayerStatus /* value */)>& cb) override;
|
||||
void removePlayerStatusListener(const std::function<void(PlayerStatus /* value */)>& cb) override;
|
||||
void addCastStatusListener(const std::function<void(CastStatus /* value */)>& cb) override;
|
||||
void removeCastStatusListener(const std::function<void(CastStatus /* value */)>& cb) override;
|
||||
void addOnEndListener(const std::function<void()>& cb) override;
|
||||
void removeOnEndListener(const std::function<void()>& cb) override;
|
||||
void addOnPrevListener(const std::function<void()>& cb) override;
|
||||
|
||||
@@ -23,6 +23,8 @@ namespace margelo::nitro::omni { enum class MixAudioMode; }
|
||||
namespace margelo::nitro::omni { struct PlayerBackend; }
|
||||
// Forward declaration of `AndroidBackend` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { enum class AndroidBackend; }
|
||||
// Forward declaration of `CastOptions` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct CastOptions; }
|
||||
|
||||
#include <memory>
|
||||
#include "HybridOmniPlayerSpec.hpp"
|
||||
@@ -45,6 +47,8 @@ namespace margelo::nitro::omni { enum class AndroidBackend; }
|
||||
#include "JPlayerBackend.hpp"
|
||||
#include "AndroidBackend.hpp"
|
||||
#include "JAndroidBackend.hpp"
|
||||
#include "CastOptions.hpp"
|
||||
#include "JCastOptions.hpp"
|
||||
|
||||
namespace margelo::nitro::omni {
|
||||
|
||||
@@ -79,9 +83,9 @@ namespace margelo::nitro::omni {
|
||||
|
||||
|
||||
// Methods
|
||||
std::shared_ptr<HybridOmniPlayerSpec> JHybridOmniPlayerFactorySpec::createPlayer(const std::optional<Source>& props, const std::optional<PlayerBackend>& backend) {
|
||||
static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<JHybridOmniPlayerSpec::JavaPart>(jni::alias_ref<JSource> /* props */, jni::alias_ref<JPlayerBackend> /* backend */)>("createPlayer");
|
||||
auto __result = method(_javaPart, props.has_value() ? JSource::fromCpp(props.value()) : nullptr, backend.has_value() ? JPlayerBackend::fromCpp(backend.value()) : nullptr);
|
||||
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);
|
||||
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) override;
|
||||
std::shared_ptr<HybridOmniPlayerSpec> createPlayer(const std::optional<Source>& props, const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) override;
|
||||
|
||||
private:
|
||||
jni::global_ref<JHybridOmniPlayerFactorySpec::JavaPart> _javaPart;
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
///
|
||||
/// CastOptions.kt
|
||||
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
|
||||
/// https://github.com/mrousavy/nitro
|
||||
/// Copyright © Marc Rousavy @ Margelo
|
||||
///
|
||||
|
||||
package com.margelo.nitro.omni
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.facebook.proguard.annotations.DoNotStrip
|
||||
import java.util.Objects
|
||||
|
||||
|
||||
/**
|
||||
* Represents the JavaScript object/struct "CastOptions".
|
||||
*/
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
data class CastOptions(
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
val receiverApplicationId: String?
|
||||
) {
|
||||
/* primary constructor */
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is CastOptions) return false
|
||||
return Objects.deepEquals(this.receiverApplicationId, other.receiverApplicationId)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return arrayOf<Any?>(
|
||||
receiverApplicationId
|
||||
).contentDeepHashCode()
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Constructor called from C++
|
||||
*/
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
@Suppress("unused")
|
||||
@JvmStatic
|
||||
private fun fromCpp(receiverApplicationId: String?): CastOptions {
|
||||
return CastOptions(receiverApplicationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
///
|
||||
/// Func_void_CastStatus.kt
|
||||
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
|
||||
/// https://github.com/mrousavy/nitro
|
||||
/// Copyright © Marc Rousavy @ Margelo
|
||||
///
|
||||
|
||||
package com.margelo.nitro.omni
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.facebook.jni.HybridData
|
||||
import com.facebook.proguard.annotations.DoNotStrip
|
||||
import dalvik.annotation.optimization.FastNative
|
||||
|
||||
|
||||
/**
|
||||
* Represents the JavaScript callback `(value: enum) => void`.
|
||||
* This can be either implemented in C++ (in which case it might be a callback coming from JS),
|
||||
* or in Kotlin/Java (in which case it is a native callback).
|
||||
*/
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
@Suppress("ClassName", "RedundantUnitReturnType")
|
||||
fun interface Func_void_CastStatus: (CastStatus) -> Unit {
|
||||
/**
|
||||
* Call the given JS callback.
|
||||
* @throws Throwable if the JS function itself throws an error, or if the JS function/runtime has already been deleted.
|
||||
*/
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
override fun invoke(value: CastStatus): Unit
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the JavaScript callback `(value: enum) => void`.
|
||||
* This is implemented in C++, via a `std::function<...>`.
|
||||
* The callback might be coming from JS.
|
||||
*/
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
@Suppress(
|
||||
"KotlinJniMissingFunction", "unused",
|
||||
"RedundantSuppression", "RedundantUnitReturnType", "FunctionName",
|
||||
"ConvertSecondaryConstructorToPrimary", "ClassName", "LocalVariableName",
|
||||
)
|
||||
class Func_void_CastStatus_cxx: Func_void_CastStatus {
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
private val mHybridData: HybridData
|
||||
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
private constructor(hybridData: HybridData) {
|
||||
mHybridData = hybridData
|
||||
}
|
||||
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
override fun invoke(value: CastStatus): Unit
|
||||
= invoke_cxx(value)
|
||||
|
||||
@FastNative
|
||||
private external fun invoke_cxx(value: CastStatus): Unit
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the JavaScript callback `(value: enum) => void`.
|
||||
* This is implemented in Java/Kotlin, via a `(CastStatus) -> Unit`.
|
||||
* The callback is always coming from native.
|
||||
*/
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
@Suppress("ClassName", "RedundantUnitReturnType", "unused")
|
||||
class Func_void_CastStatus_java(private val function: (CastStatus) -> Unit): Func_void_CastStatus {
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
override fun invoke(value: CastStatus): Unit {
|
||||
return this.function(value)
|
||||
}
|
||||
}
|
||||
+18
@@ -82,6 +82,24 @@ abstract class HybridOmniEventMapSpec: HybridObject() {
|
||||
return __result
|
||||
}
|
||||
|
||||
abstract fun addCastStatusListener(cb: (value: CastStatus) -> Unit): Unit
|
||||
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
private fun addCastStatusListener_cxx(cb: Func_void_CastStatus): Unit {
|
||||
val __result = addCastStatusListener(cb)
|
||||
return __result
|
||||
}
|
||||
|
||||
abstract fun removeCastStatusListener(cb: (value: CastStatus) -> Unit): Unit
|
||||
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
private fun removeCastStatusListener_cxx(cb: Func_void_CastStatus): Unit {
|
||||
val __result = removeCastStatusListener(cb)
|
||||
return __result
|
||||
}
|
||||
|
||||
abstract fun addOnEndListener(cb: () -> Unit): Unit
|
||||
|
||||
@DoNotStrip
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ abstract class HybridOmniPlayerFactorySpec: HybridObject() {
|
||||
// Methods
|
||||
@DoNotStrip
|
||||
@Keep
|
||||
abstract fun createPlayer(props: Source?, backend: PlayerBackend?): HybridOmniPlayerSpec
|
||||
abstract fun createPlayer(props: Source?, backend: PlayerBackend?, cast: CastOptions?): HybridOmniPlayerSpec
|
||||
|
||||
// Default implementation of `HybridObject.toString()`
|
||||
override fun toString(): String {
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
///
|
||||
/// CastOptions.hpp
|
||||
/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE.
|
||||
/// https://github.com/mrousavy/nitro
|
||||
/// Copyright © Marc Rousavy @ Margelo
|
||||
///
|
||||
|
||||
#pragma once
|
||||
|
||||
#if __has_include(<NitroModules/JSIConverter.hpp>)
|
||||
#include <NitroModules/JSIConverter.hpp>
|
||||
#else
|
||||
#error NitroModules cannot be found! Are you sure you installed NitroModules properly?
|
||||
#endif
|
||||
#if __has_include(<NitroModules/NitroDefines.hpp>)
|
||||
#include <NitroModules/NitroDefines.hpp>
|
||||
#else
|
||||
#error NitroModules cannot be found! Are you sure you installed NitroModules properly?
|
||||
#endif
|
||||
#if __has_include(<NitroModules/JSIHelpers.hpp>)
|
||||
#include <NitroModules/JSIHelpers.hpp>
|
||||
#else
|
||||
#error NitroModules cannot be found! Are you sure you installed NitroModules properly?
|
||||
#endif
|
||||
#if __has_include(<NitroModules/PropNameIDCache.hpp>)
|
||||
#include <NitroModules/PropNameIDCache.hpp>
|
||||
#else
|
||||
#error NitroModules cannot be found! Are you sure you installed NitroModules properly?
|
||||
#endif
|
||||
|
||||
|
||||
|
||||
#include <string>
|
||||
#include <optional>
|
||||
|
||||
namespace margelo::nitro::omni {
|
||||
|
||||
/**
|
||||
* A struct which can be represented as a JavaScript object (CastOptions).
|
||||
*/
|
||||
struct CastOptions final {
|
||||
public:
|
||||
std::optional<std::string> receiverApplicationId SWIFT_PRIVATE;
|
||||
|
||||
public:
|
||||
CastOptions() = default;
|
||||
explicit CastOptions(std::optional<std::string> receiverApplicationId): receiverApplicationId(receiverApplicationId) {}
|
||||
|
||||
public:
|
||||
friend bool operator==(const CastOptions& lhs, const CastOptions& rhs) = default;
|
||||
};
|
||||
|
||||
} // namespace margelo::nitro::omni
|
||||
|
||||
namespace margelo::nitro {
|
||||
|
||||
// C++ CastOptions <> JS CastOptions (object)
|
||||
template <>
|
||||
struct JSIConverter<margelo::nitro::omni::CastOptions> final {
|
||||
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")))
|
||||
);
|
||||
}
|
||||
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));
|
||||
return obj;
|
||||
}
|
||||
static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) {
|
||||
if (!value.isObject()) {
|
||||
return false;
|
||||
}
|
||||
jsi::Object obj = value.getObject(runtime);
|
||||
if (!nitro::isPlainObject(runtime, obj)) {
|
||||
return false;
|
||||
}
|
||||
if (!JSIConverter<std::optional<std::string>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "receiverApplicationId")))) return false;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
} // namespace margelo::nitro
|
||||
@@ -20,6 +20,8 @@ namespace margelo::nitro::omni {
|
||||
prototype.registerHybridMethod("removeStateBoolListener", &HybridOmniEventMapSpec::removeStateBoolListener);
|
||||
prototype.registerHybridMethod("addPlayerStatusListener", &HybridOmniEventMapSpec::addPlayerStatusListener);
|
||||
prototype.registerHybridMethod("removePlayerStatusListener", &HybridOmniEventMapSpec::removePlayerStatusListener);
|
||||
prototype.registerHybridMethod("addCastStatusListener", &HybridOmniEventMapSpec::addCastStatusListener);
|
||||
prototype.registerHybridMethod("removeCastStatusListener", &HybridOmniEventMapSpec::removeCastStatusListener);
|
||||
prototype.registerHybridMethod("addOnEndListener", &HybridOmniEventMapSpec::addOnEndListener);
|
||||
prototype.registerHybridMethod("removeOnEndListener", &HybridOmniEventMapSpec::removeOnEndListener);
|
||||
prototype.registerHybridMethod("addOnPrevListener", &HybridOmniEventMapSpec::addOnPrevListener);
|
||||
|
||||
@@ -19,6 +19,8 @@ namespace margelo::nitro::omni { enum class NumberProperty; }
|
||||
namespace margelo::nitro::omni { enum class BoolProperty; }
|
||||
// Forward declaration of `PlayerStatus` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { enum class PlayerStatus; }
|
||||
// Forward declaration of `CastStatus` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { enum class CastStatus; }
|
||||
// Forward declaration of `Track` to properly resolve imports.
|
||||
namespace margelo::nitro::omni { struct Track; }
|
||||
// Forward declaration of `Rendition` to properly resolve imports.
|
||||
@@ -28,6 +30,7 @@ namespace margelo::nitro::omni { struct Rendition; }
|
||||
#include <functional>
|
||||
#include "BoolProperty.hpp"
|
||||
#include "PlayerStatus.hpp"
|
||||
#include "CastStatus.hpp"
|
||||
#include <string>
|
||||
#include "Track.hpp"
|
||||
#include <optional>
|
||||
@@ -70,6 +73,8 @@ namespace margelo::nitro::omni {
|
||||
virtual void removeStateBoolListener(BoolProperty key, const std::function<void(bool /* value */)>& cb) = 0;
|
||||
virtual void addPlayerStatusListener(const std::function<void(PlayerStatus /* value */)>& cb) = 0;
|
||||
virtual void removePlayerStatusListener(const std::function<void(PlayerStatus /* value */)>& cb) = 0;
|
||||
virtual void addCastStatusListener(const std::function<void(CastStatus /* value */)>& cb) = 0;
|
||||
virtual void removeCastStatusListener(const std::function<void(CastStatus /* value */)>& cb) = 0;
|
||||
virtual void addOnEndListener(const std::function<void()>& cb) = 0;
|
||||
virtual void removeOnEndListener(const std::function<void()>& cb) = 0;
|
||||
virtual void addOnPrevListener(const std::function<void()>& cb) = 0;
|
||||
|
||||
@@ -19,12 +19,15 @@ namespace margelo::nitro::omni { class HybridOmniPlayerSpec; }
|
||||
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.
|
||||
namespace margelo::nitro::omni { struct CastOptions; }
|
||||
|
||||
#include <memory>
|
||||
#include "HybridOmniPlayerSpec.hpp"
|
||||
#include "Source.hpp"
|
||||
#include <optional>
|
||||
#include "PlayerBackend.hpp"
|
||||
#include "CastOptions.hpp"
|
||||
|
||||
namespace margelo::nitro::omni {
|
||||
|
||||
@@ -57,7 +60,7 @@ namespace margelo::nitro::omni {
|
||||
|
||||
public:
|
||||
// Methods
|
||||
virtual std::shared_ptr<HybridOmniPlayerSpec> createPlayer(const std::optional<Source>& props, const std::optional<PlayerBackend>& backend) = 0;
|
||||
virtual std::shared_ptr<HybridOmniPlayerSpec> createPlayer(const std::optional<Source>& props, const std::optional<PlayerBackend>& backend, const std::optional<CastOptions>& cast) = 0;
|
||||
|
||||
protected:
|
||||
// Hybrid Setup
|
||||
|
||||
+3
-4
@@ -1,15 +1,14 @@
|
||||
import type { ConfigPlugin } from "@expo/config-plugins";
|
||||
import { createRunOncePlugin } from "@expo/config-plugins";
|
||||
import pkg from "../../package.json";
|
||||
import { withCast } from "./withCast";
|
||||
import { withMediaNotifications } from "./withMediaNotifications";
|
||||
import { withPip } from "./withPip";
|
||||
|
||||
const withOmni: ConfigPlugin = (config) => {
|
||||
let nextConfig = withPip(config);
|
||||
nextConfig = withMediaNotifications(nextConfig);
|
||||
return nextConfig;
|
||||
return withCast(withMediaNotifications(withPip(config)));
|
||||
};
|
||||
|
||||
export default createRunOncePlugin(withOmni, pkg.name, pkg.version);
|
||||
|
||||
export { withMediaNotifications, withPip };
|
||||
export { withCast, withMediaNotifications, withPip };
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
AndroidConfig,
|
||||
type ConfigPlugin,
|
||||
withAndroidManifest,
|
||||
} from "@expo/config-plugins";
|
||||
|
||||
const OPTIONS_PROVIDER_NAME =
|
||||
"com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME";
|
||||
const OPTIONS_PROVIDER_VALUE = "dev.zoriya.omni.OmniCastOptionsProvider";
|
||||
|
||||
function patchManifestForCast(
|
||||
androidManifest: AndroidConfig.Manifest.AndroidManifest,
|
||||
): AndroidConfig.Manifest.AndroidManifest {
|
||||
const mainApplication =
|
||||
AndroidConfig.Manifest.getMainApplication(androidManifest);
|
||||
if (!mainApplication) {
|
||||
console.warn(
|
||||
"AndroidManifest.xml is missing a <application> element - skipping Omni cast config.",
|
||||
);
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
const metaData = mainApplication["meta-data"] ?? [];
|
||||
const existing = metaData.find(
|
||||
(item) => item.$["android:name"] === OPTIONS_PROVIDER_NAME,
|
||||
);
|
||||
if (existing) {
|
||||
existing.$["android:value"] = OPTIONS_PROVIDER_VALUE;
|
||||
} else {
|
||||
metaData.push({
|
||||
$: {
|
||||
"android:name": OPTIONS_PROVIDER_NAME,
|
||||
"android:value": OPTIONS_PROVIDER_VALUE,
|
||||
},
|
||||
});
|
||||
}
|
||||
mainApplication["meta-data"] = metaData;
|
||||
|
||||
return androidManifest;
|
||||
}
|
||||
|
||||
export const withCast: ConfigPlugin = (config) => {
|
||||
return withAndroidManifest(config, (manifestConfig) => {
|
||||
manifestConfig.modResults = patchManifestForCast(manifestConfig.modResults);
|
||||
return manifestConfig;
|
||||
});
|
||||
};
|
||||
+2
-2
@@ -53,8 +53,8 @@ export function usePlayerState<Key extends keyof OmniPlayerState>(
|
||||
em.addPlayerStatusListener(setState);
|
||||
return () => em.removePlayerStatusListener(setState);
|
||||
case "castStatus":
|
||||
// TODO: implement this
|
||||
return;
|
||||
em.addCastStatusListener(setState);
|
||||
return () => em.removeCastStatusListener(setState);
|
||||
}
|
||||
}, [player, key]);
|
||||
|
||||
|
||||
+2
-2
@@ -15,7 +15,7 @@ export const OmniProvider = ({
|
||||
source,
|
||||
backend = { android: "vlc" },
|
||||
showNotification = false,
|
||||
cast: _,
|
||||
cast,
|
||||
}: {
|
||||
source?: Source;
|
||||
cast?: CastOptions;
|
||||
@@ -24,7 +24,7 @@ export const OmniProvider = ({
|
||||
showNotification?: boolean;
|
||||
}) => {
|
||||
const player = useLazyRef(() =>
|
||||
ProviderFactory.createPlayer(source, backend),
|
||||
ProviderFactory.createPlayer(source, backend, cast),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import type { HybridObject } from "react-native-nitro-modules";
|
||||
import type { OmniEvents } from "../types/events";
|
||||
import type {
|
||||
CastStatus,
|
||||
OmniPlayerState,
|
||||
OmniPlayer as OmniPlayerT,
|
||||
PlayerBackend,
|
||||
PlayerStatus,
|
||||
} from "../types/player";
|
||||
import type { Source } from "../types/source";
|
||||
import type { CastOptions, Source } from "../types/source";
|
||||
|
||||
export type NumberProperty = Exclude<
|
||||
keyof OmniPlayerState,
|
||||
@@ -25,6 +26,9 @@ export interface OmniEventMap extends HybridObject<{ android: "kotlin" }> {
|
||||
addPlayerStatusListener(cb: (value: PlayerStatus) => void): void;
|
||||
removePlayerStatusListener(cb: (value: PlayerStatus) => void): void;
|
||||
|
||||
addCastStatusListener(cb: (value: CastStatus) => void): void;
|
||||
removeCastStatusListener(cb: (value: CastStatus) => void): void;
|
||||
|
||||
addOnEndListener(cb: OmniEvents["end"]): void;
|
||||
removeOnEndListener(cb: OmniEvents["end"]): void;
|
||||
|
||||
@@ -60,5 +64,9 @@ export interface OmniPlayer
|
||||
}
|
||||
|
||||
export interface OmniPlayerFactory extends HybridObject<{ android: "kotlin" }> {
|
||||
createPlayer(props?: Source, backend?: PlayerBackend): OmniPlayer;
|
||||
createPlayer(
|
||||
props?: Source,
|
||||
backend?: PlayerBackend,
|
||||
cast?: CastOptions,
|
||||
): OmniPlayer;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user