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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user