Compare commits

..
14 Commits
21 changed files with 513 additions and 277 deletions
+1 -1
View File
@@ -110,7 +110,7 @@ function Player() {
export default function App() {
const source = {
src: [{ uri: "https://example.com/stream.m3u8", headers: {} }],
src: { uri: "https://example.com/stream.m3u8", headers: {} },
subtitles: [],
metadata: { title: "My video", hasPrev: false, hasNext: true },
};
+1 -1
View File
@@ -151,7 +151,7 @@ dependencies {
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("com.google.android.gms:play-services-cast-framework:22.3.1")
implementation("androidx.mediarouter:mediarouter:1.7.0")
}
@@ -13,6 +13,7 @@ class OmniCastOptionsProvider : OptionsProvider {
return CastOptions.Builder()
.setReceiverApplicationId(appId)
.setShowSystemOutputSwitcherOnCastIconClick(true)
.build()
}
@@ -56,6 +56,10 @@ class OmniCastTrackSelector : CastTrackSelector() {
indices.forEach { selections.remove(groups[it]) }
if (disabled) return
val overrides = request.trackSelectionParameters.overrides
val overrideMatch = indices.firstOrNull { i ->
overrides.keys.any { it.id == groups[i].id }
}
val match = indices.firstOrNull { i ->
val name: String? = tracks[i].name
val language: String? = tracks[i].language
@@ -64,6 +68,7 @@ class OmniCastTrackSelector : CastTrackSelector() {
Util.normalizeLanguageCode(language) in languages)
}
val chosen = when {
overrideMatch != null -> overrideMatch
match != null -> match
previous.isNotEmpty() -> previous.first()
selectDefault -> indices.first()
@@ -43,8 +43,14 @@ class OmniMediaItemConverter : MediaItemConverter {
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.artist?.let {
putString(CastMediaMetadata.KEY_ARTIST, it.toString())
putString(CastMediaMetadata.KEY_SUBTITLE, it.toString())
}
meta.albumTitle?.let {
putString(CastMediaMetadata.KEY_ALBUM_TITLE, it.toString())
putString(CastMediaMetadata.KEY_STUDIO, it.toString())
}
meta.artworkUri?.let { addImage(WebImage(it)) }
}
@@ -57,7 +63,8 @@ class OmniMediaItemConverter : MediaItemConverter {
}
val tracks = local.subtitleConfigurations.mapIndexed { index, subtitle ->
MediaTrack.Builder(index.toLong(), MediaTrack.TYPE_TEXT)
// caf tracks are 1 based instead of 0 based. videojs does the same
MediaTrack.Builder((index + 1).toLong(), MediaTrack.TYPE_TEXT)
.setSubtype(MediaTrack.SUBTYPE_SUBTITLES)
.setContentId(subtitle.uri.toString())
.setContentType(subtitle.mimeType ?: "text/vtt")
@@ -8,7 +8,6 @@ import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.SurfaceHolder
import androidx.core.app.NotificationCompat
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
@@ -38,6 +37,7 @@ 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.framework.CastContext
import com.google.android.gms.cast.framework.CastState
import com.google.android.gms.cast.framework.CastStateListener
@@ -106,22 +106,41 @@ class OmniPlayer(
override var showNotification: Boolean? = false
set(value) {
if (value == true) {
if (notificationPlayer != null && notificationPlayer?.isPlaying == true) {
field = value
syncNotificationService()
}
private var serviceRunning = false
private fun syncNotificationService() {
val shouldShow = showNotification == true && source != null
when {
shouldShow && !serviceRunning -> {
val otherIsPlaying = notificationPlayer?.let { other ->
other !== localPlayer && runOnMainThreadSync { other.isPlaying }
} == true
if (otherIsPlaying) {
throw Error("Two players can't display notifications at the same time.")
}
notificationPlayer = localPlayer
ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java))
} else if (field == true && notificationPlayer == localPlayer) {
serviceRunning = true
}
!shouldShow && serviceRunning -> {
ctx.stopService(Intent(ctx, OmniPlayerService::class.java))
notificationPlayer = null
if (notificationPlayer == localPlayer) notificationPlayer = null
serviceRunning = false
}
field = value
}
}
override val castStatus: CastStatus
get() = runOnMainThreadSync { computeCastStatus() }
val isCasting: Boolean
get() = castContext?.sessionManager?.currentCastSession?.isConnected == true
private fun computeCastStatus(): CastStatus {
val cc = castContext ?: return CastStatus.UNSUPPORTED
return when (cc.castState) {
@@ -142,6 +161,9 @@ class OmniPlayer(
return@runOnMainThread
}
val activity = ctx.currentActivity ?: return@runOnMainThread
if (SystemOutputSwitcherDialogController.showDialog(activity)) {
return@runOnMainThread
}
val selector = cc.mergedSelector ?: return@runOnMainThread
MediaRouteChooserDialog(activity).apply { routeSelector = selector }.show()
}
@@ -205,16 +227,29 @@ class OmniPlayer(
.build()
}
fun setSurface(holder: SurfaceHolder?) {
fun setVideoView(surfaceView: android.view.SurfaceView?) {
runOnMainThread {
if (holder == null) {
if (surfaceView == null) {
localPlayer.clearVideoSurface()
} else {
localPlayer.setVideoSurfaceHolder(holder)
localPlayer.setVideoSurfaceView(surfaceView)
}
}
}
// rebuild video pipeline after pip, noop for exoplayer that recover on it's own.
fun rebuildVideoOutput() {
runOnMainThread {
(localPlayer as? VlcPlayer)?.rebuildVideoOutput()
}
}
fun updateVideoLayout(width: Int, height: Int) {
runOnMainThread {
(localPlayer as? VlcPlayer)?.updateVideoLayout(width, height)
}
}
override val hasPrev: Boolean get() = player.hasPreviousMediaItem()
override val hasNext: Boolean get() = player.hasNextMediaItem()
override val status by mainThreadProperty {
@@ -244,7 +279,7 @@ class OmniPlayer(
override var playbackRate by mainThreadProperty(
get = { player.playbackParameters.speed.toDouble() },
set = { value -> player.setPlaybackSpeed(value.toFloat().coerceAtLeast(0f)) }
set = { value -> player.setPlaybackSpeed(value.toFloat().coerceAtLeast(0.01f)) }
)
override var muted by mainThreadProperty(
@@ -273,9 +308,9 @@ class OmniPlayer(
field = value
if (value == null) {
runOnMainThreadSync {
player.setMediaItem(MediaItem.EMPTY)
player.prepare()
player.clearMediaItems()
}
syncNotificationService()
return
}
val handleAudioFocus =
@@ -287,18 +322,11 @@ class OmniPlayer(
// 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 {
player.setMediaItem(MediaItem.EMPTY)
player.prepare()
}
return
}
httpDataSourceFactory?.setDefaultRequestProperties(firstSrc.headers)
val src = value.src
httpDataSourceFactory?.setDefaultRequestProperties(src.headers)
val currentItem = buildMediaItem(
firstSrc,
src,
value.metadata,
value.subtitles,
value.castId,
@@ -315,6 +343,7 @@ class OmniPlayer(
player.setMediaItems(mediaItems, startIndex, startPositionMs.toLong())
player.prepare()
}
syncNotificationService()
}
override fun play() {
@@ -350,55 +379,35 @@ class OmniPlayer(
}
}
override fun selectAudio(audio: Track) {
runOnMainThreadSync {
player.trackSelectionParameters = player.trackSelectionParameters
.buildUpon()
.setPreferredAudioLanguage(audio.language)
.setPreferredAudioLabels(*(audio.label?.let { arrayOf(it) } ?: emptyArray()))
.build()
}
}
override fun selectAudio(audio: Track) = selectTrack(C.TRACK_TYPE_AUDIO, audio)
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
override fun selectSubtitle(subtitle: Track?) = selectTrack(C.TRACK_TYPE_TEXT, subtitle)
private fun selectTrack(type: Int, track: Track?) {
runOnMainThreadSync {
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)
.build()
val params = player.trackSelectionParameters.buildUpon()
.clearOverridesOfType(type)
if (track == null) {
params.setTrackTypeDisabled(type, true)
} else {
player.trackSelectionParameters = player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setPreferredTextLanguage(subtitle.language)
.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.
params.setTrackTypeDisabled(type, false)
val group = player.currentTracks.groups.firstOrNull {
it.type == type && it.mediaTrackGroup.id == track.id
}?.mediaTrackGroup
if (group != null) {
params.setOverrideForType(TrackSelectionOverride(group, 0))
} else {
val labels = track.label?.let { arrayOf(it) } ?: emptyArray()
if (type == C.TRACK_TYPE_AUDIO) {
params.setPreferredAudioLanguage(track.language)
.setPreferredAudioLabels(*labels)
} else {
params.setPreferredTextLanguage(track.language)
.setPreferredTextLabels(*labels)
}
}
}
player.trackSelectionParameters = params.build()
}
}
@@ -406,18 +415,30 @@ class OmniPlayer(
val groups = player.currentTracks.groups.filter { it.type == trackType }
if (groups.isEmpty()) return emptyArray()
return groups.map {
it.getTrackFormat(0).run {
Track(
id = it.mediaTrackGroup.id,
label = this.label,
language = this.language,
selected = it.isSelected
)
}
// when casting tracks are stripped of some metadata, recover it
val castTracks = castMediaTracksById()
return groups.map { group ->
val format = group.getTrackFormat(0)
val castTrackId = group.mediaTrackGroup.id.substringAfterLast("track=", "").toLongOrNull()
val cast = castTracks[castTrackId]
Track(
id = group.mediaTrackGroup.id,
label = cast?.name ?: format.label,
language = cast?.language ?: format.language,
selected = group.isSelected
)
}.toTypedArray()
}
private fun castMediaTracksById(): Map<Long, com.google.android.gms.cast.MediaTrack> {
if (!isCasting) return emptyMap()
val tracks = castContext?.sessionManager?.currentCastSession
?.remoteMediaClient?.mediaInfo?.mediaTracks ?: return emptyMap()
return tracks.associateBy { it.id }
}
private fun getRenditions(): Array<Rendition> {
val group =
player.currentTracks.groups.firstOrNull { it.isSelected && it.type == C.TRACK_TYPE_VIDEO }
@@ -481,10 +502,6 @@ class OmniPlayer(
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"
@@ -502,7 +519,13 @@ class OmniPlayerService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
player = OmniPlayer.notificationPlayer ?: throw Error("No player available")
val available = OmniPlayer.notificationPlayer
if (available == null) {
startForeground(1, createImmediateNotification())
stopSelf()
return
}
player = available
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
}
@@ -532,6 +555,16 @@ class OmniPlayerService : MediaSessionService() {
triggerNotificationUpdate()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val current = OmniPlayer.notificationPlayer
if (current != null && ::mediaSession.isInitialized && mediaSession.player !== current) {
player = current
mediaSession.player = current
}
super.onStartCommand(intent, flags, startId)
return START_NOT_STICKY
}
private fun createImmediateNotification(): Notification {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
@@ -570,7 +603,7 @@ class OmniPlayerService : MediaSessionService() {
}
override fun onDestroy() {
mediaSession.release()
if (::mediaSession.isInitialized) mediaSession.release()
super.onDestroy()
}
}
+107 -13
View File
@@ -4,6 +4,7 @@ import android.app.PictureInPictureParams
import android.graphics.Rect
import android.os.Build
import android.util.Rational
import android.view.Gravity
import android.view.SurfaceHolder
import android.view.SurfaceView
import android.view.View
@@ -11,6 +12,10 @@ import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.view.isVisible
import androidx.media3.common.Player
import androidx.media3.common.VideoSize
import androidx.media3.common.text.CueGroup
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.SubtitleView
import com.facebook.react.bridge.LifecycleEventListener
import com.facebook.react.uimanager.ThemedReactContext
import com.margelo.nitro.omni.HybridOmniPlayerSpec
@@ -68,6 +73,16 @@ class OmniView(val context: ThemedReactContext) :
}
}
private val contentFrame = AspectRatioFrameLayout(context).apply {
setResizeMode(AspectRatioFrameLayout.RESIZE_MODE_FIT)
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT,
Gravity.CENTER
)
view.addView(this)
}
private val surfaceView = SurfaceView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
@@ -75,8 +90,19 @@ class OmniView(val context: ThemedReactContext) :
)
holder.addCallback(this@OmniView)
addOnLayoutChangeListener(this@OmniView)
contentFrame.addView(this)
}
private val subtitleView = SubtitleView(context).apply {
layoutParams = FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
)
setUserDefaultStyle()
setUserDefaultTextSize()
view.addView(this)
}
private var surfaceReady = false
private var boundPlayer: OmniPlayer? = null
private var rootContent: ViewGroup? = null
@@ -94,6 +120,20 @@ class OmniView(val context: ThemedReactContext) :
context.addLifecycleEventListener(this)
}
override fun onVideoSizeChanged(videoSize: VideoSize) {
contentFrame.setAspectRatio(
if (videoSize.width > 0 && videoSize.height > 0) {
videoSize.width * videoSize.pixelWidthHeightRatio / videoSize.height
} else {
0f
}
)
}
override fun onCues(cueGroup: CueGroup) {
subtitleView.setCues(cueGroup.cues)
}
override fun onLayoutChange(
view: View?,
left: Int,
@@ -125,6 +165,7 @@ class OmniView(val context: ThemedReactContext) :
autoPip == false && curPip == this -> {
activeView = WeakReference(null)
clearPictureInPictureParams()
}
}
@@ -139,12 +180,12 @@ class OmniView(val context: ThemedReactContext) :
}
boundPlayer?.localPlayer?.removeListener(this)
boundPlayer?.setSurface(null)
boundPlayer?.setVideoView(null)
boundPlayer = omniPlayer
omniPlayer.localPlayer.addListener(this)
if (surfaceReady) {
omniPlayer.setSurface(surfaceView.holder)
omniPlayer.setVideoView(surfaceView)
}
if (autoplay == true && !omniPlayer.isPlaying) {
@@ -156,6 +197,7 @@ class OmniView(val context: ThemedReactContext) :
restoreUiAfterPip()
if (activeView.get() == this) {
activeView = WeakReference(null)
clearPictureInPictureParams()
}
surfaceView.removeOnLayoutChangeListener(this)
context.removeLifecycleEventListener(this)
@@ -164,7 +206,7 @@ class OmniView(val context: ThemedReactContext) :
val omniPlayer = player as? OmniPlayer ?: return
boundPlayer?.localPlayer?.removeListener(this)
omniPlayer.setSurface(null)
omniPlayer.setVideoView(null)
boundPlayer = null
}
@@ -183,8 +225,10 @@ class OmniView(val context: ThemedReactContext) :
return
}
val omniPlayer = boundPlayer ?: return
if (!omniPlayer.isPlaying) {
if (boundPlayer == null ||
boundPlayer?.localPlayer?.isPlaying != true ||
boundPlayer?.isCasting == true
) {
return
}
@@ -215,16 +259,46 @@ class OmniView(val context: ThemedReactContext) :
return
}
if (boundPlayer?.isCasting == true) {
clearPictureInPictureParams()
return
}
val autoEnterEnabled =
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
autoPip == true &&
boundPlayer?.isPlaying == true
boundPlayer?.localPlayer?.isPlaying == true &&
boundPlayer?.isCasting != true
activity.setPictureInPictureParams(buildPipParams(autoEnterEnabled))
}
private fun clearPictureInPictureParams() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
return
}
val activity = context.currentActivity ?: return
if (activity.isFinishing || activity.isDestroyed) {
return
}
activity.setPictureInPictureParams(
PictureInPictureParams.Builder()
.setAutoEnterEnabled(false)
.build()
)
}
private fun clampedAspectRatio(width: Int, height: Int): Rational {
val minRatio = 0.42
val maxRatio = 2.38
val ratio = width.toDouble() / height.toDouble()
if (ratio in minRatio..maxRatio) return Rational(width, height)
val clamped = ratio.coerceIn(minRatio, maxRatio)
return Rational((clamped * 1000).toInt(), 1000)
}
private fun buildPipParams(autoEnterEnabled: Boolean): PictureInPictureParams {
val aspectRatio = if (surfaceView.width > 0 && surfaceView.height > 0) {
Rational(surfaceView.width, surfaceView.height)
clampedAspectRatio(surfaceView.width, surfaceView.height)
} else {
Rational(16, 9)
}
@@ -253,7 +327,7 @@ class OmniView(val context: ThemedReactContext) :
if (context.currentActivity !== activity || autoPip != true || boundPlayer == null) {
return
}
if (!afterEnteredPip && boundPlayer?.isPlaying != true) return
if (!afterEnteredPip && boundPlayer?.localPlayer?.isPlaying != true) return
isolateUiForPip(activity)
}
@@ -273,7 +347,7 @@ class OmniView(val context: ThemedReactContext) :
child.visibility = View.GONE
}
view.removeView(surfaceView)
contentFrame.removeView(surfaceView)
root.addView(
surfaceView,
FrameLayout.LayoutParams(
@@ -297,7 +371,7 @@ class OmniView(val context: ThemedReactContext) :
}
rootContentViews = emptyList()
view.addView(surfaceView, FrameLayout.LayoutParams(
contentFrame.addView(surfaceView, FrameLayout.LayoutParams(
FrameLayout.LayoutParams.MATCH_PARENT,
FrameLayout.LayoutParams.MATCH_PARENT
))
@@ -305,9 +379,22 @@ 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()
}
override fun surfaceCreated(holder: SurfaceHolder) {
surfaceReady = true
boundPlayer?.setSurface(holder)
boundPlayer?.setVideoView(surfaceView)
if (boundPlayer?.localPlayer?.isPlaying == true) pendingVideoRebuild = true
updatePictureInPictureParams()
}
@@ -316,10 +403,17 @@ class OmniView(val context: ThemedReactContext) :
format: Int,
width: Int,
height: Int
) { }
) {
boundPlayer?.updateVideoLayout(width, height)
if (pendingVideoRebuild) {
surfaceView.removeCallbacks(rebuildRunnable)
surfaceView.postDelayed(rebuildRunnable, 200)
}
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
surfaceReady = false
boundPlayer?.setSurface(null)
surfaceView.removeCallbacks(rebuildRunnable)
boundPlayer?.setVideoView(null)
}
}
@@ -97,10 +97,13 @@ 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
private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY
private var userInitiatedTransition: Boolean = false
@Volatile
private var cachedBufferedPosition: Long = 0L
private var boundSurfaceView: SurfaceView? = null
private val availableCommands: Player.Commands = Player.Commands.Builder()
.add(COMMAND_PLAY_PAUSE)
@@ -466,7 +469,11 @@ class VlcPlayer(ctx: Context) :
override fun getShuffleModeEnabled(): Boolean = false
override fun isLoading() = player.playerState == IMedia.State.Opening
override fun isLoading(): Boolean {
val state = playbackState
if (state == STATE_IDLE || state == STATE_ENDED) return false
return player.playerState == IMedia.State.Opening
}
override fun seekTo(
mediaItemIndex: Int,
@@ -492,11 +499,11 @@ class VlcPlayer(ctx: Context) :
override fun getSeekForwardIncrement(): Long = 15_000L
override fun setPlaybackParameters(playbackParameters: PlaybackParameters) {
player.rate = playbackParameters.speed.coerceAtLeast(0f)
player.rate = playbackParameters.speed.coerceAtLeast(0.01f)
}
override fun getPlaybackParameters(): PlaybackParameters =
PlaybackParameters(player.rate.coerceAtLeast(0f))
PlaybackParameters(player.rate.takeIf { it > 0f } ?: 1f)
override fun stop() {
player.stop()
@@ -504,6 +511,7 @@ class VlcPlayer(ctx: Context) :
override fun release() {
player.setEventListener(null)
listeners.release()
abandonAudioFocus()
player.stop()
clearVideoSurface()
@@ -517,36 +525,43 @@ class VlcPlayer(ctx: Context) :
val selectedAudio = player.getSelectedTrack(IMedia.Track.Type.Audio)
val selectedSubtitle = player.getSelectedTrack(IMedia.Track.Type.Text)
val videoTracks = player.getTracks(IMedia.Track.Type.Video)
if (!videoTracks.isNullOrEmpty()) {
val videoFormats = videoTracks.map { track ->
Format.Builder()
.setId(track.id)
.setLabel(track.name)
.setLanguage(track.language)
.setSampleMimeType("video/x-unknown")
.build()
player.getTracks(IMedia.Track.Type.Video).orEmpty()
.groupBy { listOf(it.language, it.name, it.description) }
.values.forEach { videoTracks ->
val videoFormats = videoTracks.map { track ->
Format.Builder()
.setId(track.id)
.setLabel(track.name)
.setLanguage(track.language)
.setSampleMimeType("video/x-unknown")
.build()
}
val group = TrackGroup("vlc-video-${videoTracks.minOf { it.id }}", *videoFormats.toTypedArray())
val selected = BooleanArray(videoFormats.size) { idx ->
selectedVideo != null && selectedVideo.id == videoTracks[idx].id
}
val support = IntArray(videoFormats.size) { FORMAT_HANDLED }
result.add(Group(group, videoFormats.size > 1, support, selected))
}
val group = TrackGroup("vlc-video", *videoFormats.toTypedArray())
val selected = BooleanArray(videoFormats.size) { idx ->
selectedVideo != null && selectedVideo.id == videoTracks[idx].id
}
val support = IntArray(videoFormats.size) { FORMAT_HANDLED }
result.add(Group(group, videoFormats.size > 1, support, selected))
}
player.getTracks(IMedia.Track.Type.Audio)?.forEach { track ->
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)
val selected = booleanArrayOf(selectedAudio != null && selectedAudio.id == track.id)
val support = intArrayOf(FORMAT_HANDLED)
result.add(Group(group, false, support, selected))
}
player.getTracks(IMedia.Track.Type.Audio).orEmpty()
.groupBy { listOf(it.language, it.name, it.description) }
.values.forEach { audioTracks ->
val audioFormats = audioTracks.map { track ->
Format.Builder()
.setId(track.id)
.setLabel(track.name)
.setLanguage(track.language)
.setSampleMimeType("audio/x-unknown")
.build()
}
val group = TrackGroup("vlc-audio-${audioTracks.minOf { it.id }}", *audioFormats.toTypedArray())
val selected = BooleanArray(audioFormats.size) { idx ->
selectedAudio != null && selectedAudio.id == audioTracks[idx].id
}
val support = IntArray(audioFormats.size) { FORMAT_HANDLED }
result.add(Group(group, audioFormats.size > 1, support, selected))
}
player.getTracks(IMedia.Track.Type.Text)?.forEach { track ->
val format = Format.Builder()
@@ -688,25 +703,27 @@ class VlcPlayer(ctx: Context) :
}
override fun getCurrentTimeline(): Timeline {
if (mediaItems.isEmpty()) return Timeline.EMPTY
// snapshot the current playlist for the returned timeline.
// timelines are immutable
val items = mediaItems
val currentIndex = currentMediaItemIndex
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 {
if (index != currentMediaItemIndex) return TIME_UNSET
val dur = duration
return if (dur == TIME_UNSET) TIME_UNSET else dur * 1000L
}
private fun durationUsForIndex(index: Int): Long =
if (index == currentIndex) currentDurationUs else TIME_UNSET
override fun getWindowCount(): Int = mediaItems.size
override fun getWindowCount(): Int = items.size
override fun getWindow(
windowIndex: Int,
window: Window,
defaultPositionProjectionUs: Long
): Window {
check(windowIndex in mediaItems.indices)
val index = windowIndex.coerceIn(items.indices)
return window.set(
windowIndex,
mediaItems[windowIndex],
index,
items[index],
null,
TIME_UNSET,
TIME_UNSET,
@@ -715,34 +732,33 @@ class VlcPlayer(ctx: Context) :
false,
null,
0L,
durationUsForIndex(windowIndex),
0,
mediaItems.size - 1,
durationUsForIndex(index),
index,
index,
0L
)
}
override fun getPeriodCount(): Int = mediaItems.size
override fun getPeriodCount(): Int = items.size
override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period {
check(periodIndex in mediaItems.indices)
return period.set(periodIndex, periodIndex, 0, durationUsForIndex(periodIndex), 0L)
val index = periodIndex.coerceIn(items.indices)
return period.set(index, index, index, durationUsForIndex(index), 0L)
}
override fun getIndexOfPeriod(uid: Any): Int {
return if (uid is Int && uid in mediaItems.indices) uid else INDEX_UNSET
return if (uid is Int && uid in items.indices) uid else INDEX_UNSET
}
override fun getUidOfPeriod(periodIndex: Int): Any {
check(periodIndex in mediaItems.indices)
return periodIndex
return periodIndex.coerceIn(items.indices)
}
}
}
override fun getCurrentPeriodIndex() = currentMediaItemIndex
override fun getCurrentPeriodIndex() = currentMediaItemIndex.coerceAtLeast(0)
override fun getCurrentMediaItemIndex() = currentMediaItemIndex
override fun getCurrentMediaItemIndex() = currentMediaItemIndex.coerceAtLeast(0)
override fun getDuration(): Long = player.length.takeIf { it > 0 } ?: TIME_UNSET
@@ -795,6 +811,7 @@ class VlcPlayer(ctx: Context) :
}
override fun clearVideoSurface() {
boundSurfaceView = null
vlcVout.detachViews()
}
@@ -825,10 +842,39 @@ class VlcPlayer(ctx: Context) :
}
override fun setVideoSurfaceView(surfaceView: SurfaceView?) {
vlcVout.setVideoView(surfaceView ?: return clearVideoSurface())
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.
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
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)
player.updateVideoSurfaces()
}
override fun clearVideoSurfaceView(surfaceView: SurfaceView?) {
clearVideoSurface()
}
+115 -13
View File
@@ -1,7 +1,8 @@
import type React from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View } from "react-native";
import {
type AndroidBackend,
OmniProvider,
OmniView,
useEvent,
@@ -54,10 +55,18 @@ function PlayerExample({
onPrev,
onNext,
trackLabel,
backend,
onSwitchBackend,
hasSource,
onLoad,
}: {
onPrev: () => void;
onNext: () => void;
trackLabel: string;
backend: AndroidBackend;
onSwitchBackend: (backend: AndroidBackend) => void;
hasSource: boolean;
onLoad: () => void;
}): React.JSX.Element {
const player = usePlayer();
const status = usePlayerState("status");
@@ -70,7 +79,8 @@ function PlayerExample({
const volume = usePlayerState("volume");
const isAutoQuality = usePlayerState("isAutoQuality");
const castStatus = usePlayerState("castStatus");
const [logs, setLogs] = useState<string[]>([]);
const [logs, setLogs] = useState<{ id: number; message: string }[]>([]);
const logId = useRef(0);
const [tracks, setTracks] = useState(() => ({
videos: [...player.videos],
audios: [...player.audios],
@@ -93,7 +103,7 @@ function PlayerExample({
const pushLog = useCallback((message: string) => {
setLogs((prev) => {
const next = [message, ...prev];
const next = [{ id: logId.current++, message }, ...prev];
return next.slice(0, 8);
});
}, []);
@@ -176,6 +186,15 @@ function PlayerExample({
),
);
const switchBackend = (target: AndroidBackend) => {
if (target === backend) return;
// Pause before tearing down: the native media-session guard refuses a
// second player while the current one is still *playing*, so this keeps
// the transition clean (we don't preserve state across a switch anyway).
player.pause();
onSwitchBackend(target);
};
const togglePlayback = () => {
if (isPlaying) {
player.pause();
@@ -218,7 +237,41 @@ function PlayerExample({
return (
<ScrollView style={styles.container}>
<Text style={styles.heading}>react-native-omni</Text>
<Text style={styles.subheading}>{trackLabel}</Text>
<Text style={styles.subheading}>
{hasSource ? trackLabel : "No media loaded"}
</Text>
<View style={styles.row}>
<Pressable
style={[styles.button, hasSource && styles.selectedTrackButton]}
onPress={onLoad}
>
<Text style={styles.buttonText}>
{hasSource ? "Unload media" : "Load media"}
</Text>
</Pressable>
</View>
<View style={styles.row}>
<Pressable
style={[
styles.button,
backend === "vlc" && styles.selectedTrackButton,
]}
onPress={() => switchBackend("vlc")}
>
<Text style={styles.buttonText}>VLC</Text>
</Pressable>
<Pressable
style={[
styles.button,
backend === "exoplayer" && styles.selectedTrackButton,
]}
onPress={() => switchBackend("exoplayer")}
>
<Text style={styles.buttonText}>ExoPlayer</Text>
</Pressable>
</View>
<OmniView
style={styles.video}
@@ -449,8 +502,8 @@ function PlayerExample({
<Text style={styles.logText}>Event log will appear here.</Text>
) : (
logs.map((entry) => (
<Text key={`${entry}`} style={styles.logText}>
{entry}
<Text key={entry.id} style={styles.logText}>
{entry.message}
</Text>
))
)}
@@ -461,6 +514,30 @@ function PlayerExample({
function App(): React.JSX.Element {
const [currentIndex, setCurrentIndex] = useState(0);
// Start with no media loaded so we can verify the app boots (and the media
// notification stays hidden) before any source is set.
const [hasSource, setHasSource] = useState(false);
const [backend, setBackend] = useState<AndroidBackend>("vlc");
const [pendingBackend, setPendingBackend] = useState<AndroidBackend | null>(
null,
);
// Switching backend recreates the native player, so we fully unmount the
// provider first (rendering nothing) and remount it on the next tick with
// the new backend. This lets the old OmniView/player tear down before the
// new one is created, avoiding the "only one view"/"two players" guards.
const handleSwitchBackend = useCallback((next: AndroidBackend) => {
setPendingBackend(next);
}, []);
useEffect(() => {
if (pendingBackend === null) return;
const id = setTimeout(() => {
setBackend(pendingBackend);
setPendingBackend(null);
}, 300);
return () => clearTimeout(id);
}, [pendingBackend]);
const handlePrev = useCallback(() => {
setCurrentIndex((index) => (index === 0 ? PLAYLIST.length - 1 : index - 1));
@@ -472,16 +549,16 @@ function App(): React.JSX.Element {
const source = useMemo(
() => ({
src: [
{
uri: PLAYLIST[currentIndex].uri,
headers: {},
},
],
src: {
uri: PLAYLIST[currentIndex].uri,
headers: {},
},
subtitles: [
{
id: "kusu",
link: "https://jassub.pages.dev/subtitles/Kusriya%20S2%20OP1v3.ass",
label: "ass test",
language: "jp",
},
{
id: "pgs",
@@ -505,12 +582,31 @@ function App(): React.JSX.Element {
[currentIndex],
);
// While switching, render nothing so the current player is torn down first.
if (pendingBackend !== null) {
return (
<View style={styles.switching}>
<Text style={styles.subheading}>Switching to {pendingBackend}…</Text>
</View>
);
}
return (
<OmniProvider source={source} showNotification>
<OmniProvider
key={backend}
source={hasSource ? source : undefined}
backend={{ android: backend }}
cast={{receiverApplicationId: "D8FB0FC1"}}
showNotification
>
<PlayerExample
onPrev={handlePrev}
onNext={handleNext}
trackLabel={PLAYLIST[currentIndex].title}
backend={backend}
onSwitchBackend={handleSwitchBackend}
hasSource={hasSource}
onLoad={() => setHasSource((v) => !v)}
/>
</OmniProvider>
);
@@ -525,6 +621,12 @@ const styles = StyleSheet.create({
paddingVertical: 12,
gap: 12,
},
switching: {
flex: 1,
backgroundColor: "#0b1020",
alignItems: "center",
justifyContent: "center",
},
heading: {
fontSize: 24,
fontWeight: "700",
@@ -14,6 +14,7 @@
<activity
android:name=".MainActivity"
android:label="@string/app_name"
android:screenOrientation="nosensor"
android:configChanges="keyboard|keyboardHidden|orientation|screenLayout|screenSize|smallestScreenSize|uiMode"
android:launchMode="singleTask"
android:resizeableActivity="true"
@@ -28,5 +29,8 @@
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="dev.zoriya.omni.OmniCastOptionsProvider" />
<receiver
android:name="androidx.mediarouter.media.MediaTransferReceiver"
android:exported="true" />
</application>
</manifest>
@@ -33,11 +33,11 @@ namespace margelo::nitro::omni { struct CastOptions; }
#include <optional>
#include "JSource.hpp"
#include "VideoSrc.hpp"
#include <vector>
#include "JVideoSrc.hpp"
#include <string>
#include <unordered_map>
#include "Subtitle.hpp"
#include <vector>
#include "JSubtitle.hpp"
#include "Metadata.hpp"
#include "JMetadata.hpp"
+1 -1
View File
@@ -35,11 +35,11 @@ namespace margelo::nitro::omni { enum class CastStatus; }
#include "Source.hpp"
#include "JSource.hpp"
#include "VideoSrc.hpp"
#include <vector>
#include "JVideoSrc.hpp"
#include <string>
#include <unordered_map>
#include "Subtitle.hpp"
#include <vector>
#include "JSubtitle.hpp"
#include "Metadata.hpp"
#include "JMetadata.hpp"
+5 -23
View File
@@ -42,8 +42,8 @@ namespace margelo::nitro::omni {
[[nodiscard]]
Source toCpp() const {
static const auto clazz = javaClassStatic();
static const auto fieldSrc = clazz->getField<jni::JArrayClass<JVideoSrc>>("src");
jni::local_ref<jni::JArrayClass<JVideoSrc>> src = this->getFieldValue(fieldSrc);
static const auto fieldSrc = clazz->getField<JVideoSrc>("src");
jni::local_ref<JVideoSrc> src = this->getFieldValue(fieldSrc);
static const auto fieldStartTime = clazz->getField<jni::JDouble>("startTime");
jni::local_ref<jni::JDouble> startTime = this->getFieldValue(fieldStartTime);
static const auto fieldSubtitles = clazz->getField<jni::JArrayClass<JSubtitle>>("subtitles");
@@ -59,16 +59,7 @@ namespace margelo::nitro::omni {
static const auto fieldCastData = clazz->getField<jni::JMap<jni::JString, jni::JString>>("castData");
jni::local_ref<jni::JMap<jni::JString, jni::JString>> castData = this->getFieldValue(fieldCastData);
return Source(
[&](auto&& __input) {
size_t __size = __input->size();
std::vector<VideoSrc> __vector;
__vector.reserve(__size);
for (size_t __i = 0; __i < __size; __i++) {
auto __element = __input->getElement(__i);
__vector.push_back(__element->toCpp());
}
return __vector;
}(src),
src->toCpp(),
startTime != nullptr ? std::make_optional(startTime->value()) : std::nullopt,
[&](auto&& __input) {
size_t __size = __input->size();
@@ -110,21 +101,12 @@ namespace margelo::nitro::omni {
*/
[[maybe_unused]]
static jni::local_ref<JSource::javaobject> fromCpp(const Source& value) {
using JSignature = JSource(jni::alias_ref<jni::JArrayClass<JVideoSrc>>, jni::alias_ref<jni::JDouble>, jni::alias_ref<jni::JArrayClass<JSubtitle>>, jni::alias_ref<jni::JArrayClass<jni::JString>>, jni::alias_ref<JMetadata>, jni::alias_ref<JMixAudioMode>, jni::alias_ref<jni::JString>, jni::alias_ref<jni::JMap<jni::JString, jni::JString>>);
using JSignature = JSource(jni::alias_ref<JVideoSrc>, jni::alias_ref<jni::JDouble>, jni::alias_ref<jni::JArrayClass<JSubtitle>>, jni::alias_ref<jni::JArrayClass<jni::JString>>, jni::alias_ref<JMetadata>, jni::alias_ref<JMixAudioMode>, jni::alias_ref<jni::JString>, jni::alias_ref<jni::JMap<jni::JString, jni::JString>>);
static const auto clazz = javaClassStatic();
static const auto create = clazz->getStaticMethod<JSignature>("fromCpp");
return create(
clazz,
[&](auto&& __input) {
size_t __size = __input.size();
jni::local_ref<jni::JArrayClass<JVideoSrc>> __array = jni::JArrayClass<JVideoSrc>::newArray(__size);
for (size_t __i = 0; __i < __size; __i++) {
const auto& __element = __input[__i];
auto __elementJni = JVideoSrc::fromCpp(__element);
__array->setElement(__i, *__elementJni);
}
return __array;
}(value.src),
JVideoSrc::fromCpp(value.src),
value.startTime.has_value() ? jni::JDouble::valueOf(value.startTime.value()) : nullptr,
[&](auto&& __input) {
size_t __size = __input.size();
@@ -20,7 +20,7 @@ import java.util.Objects
data class Source(
@DoNotStrip
@Keep
val src: Array<VideoSrc>,
val src: VideoSrc,
@DoNotStrip
@Keep
val startTime: Double?,
@@ -79,7 +79,7 @@ data class Source(
@Keep
@Suppress("unused")
@JvmStatic
private fun fromCpp(src: Array<VideoSrc>, startTime: Double?, subtitles: Array<Subtitle>, fonts: Array<String>?, metadata: Metadata?, mixAudio: MixAudioMode?, castId: String?, castData: Map<String, String>?): Source {
private fun fromCpp(src: VideoSrc, startTime: Double?, subtitles: Array<Subtitle>, fonts: Array<String>?, metadata: Metadata?, mixAudio: MixAudioMode?, castId: String?, castData: Map<String, String>?): Source {
return Source(src, startTime, subtitles, fonts, metadata, mixAudio, castId, castData)
}
}
+6 -6
View File
@@ -38,9 +38,9 @@ namespace margelo::nitro::omni { struct Metadata; }
namespace margelo::nitro::omni { enum class MixAudioMode; }
#include "VideoSrc.hpp"
#include <vector>
#include <optional>
#include "Subtitle.hpp"
#include <vector>
#include <string>
#include "Metadata.hpp"
#include "MixAudioMode.hpp"
@@ -53,7 +53,7 @@ namespace margelo::nitro::omni {
*/
struct Source final {
public:
std::vector<VideoSrc> src SWIFT_PRIVATE;
VideoSrc src SWIFT_PRIVATE;
std::optional<double> startTime SWIFT_PRIVATE;
std::vector<Subtitle> subtitles SWIFT_PRIVATE;
std::optional<std::vector<std::string>> fonts SWIFT_PRIVATE;
@@ -64,7 +64,7 @@ namespace margelo::nitro::omni {
public:
Source() = default;
explicit Source(std::vector<VideoSrc> src, std::optional<double> startTime, std::vector<Subtitle> subtitles, std::optional<std::vector<std::string>> fonts, std::optional<Metadata> metadata, std::optional<MixAudioMode> mixAudio, std::optional<std::string> castId, std::optional<std::unordered_map<std::string, std::string>> castData): src(src), startTime(startTime), subtitles(subtitles), fonts(fonts), metadata(metadata), mixAudio(mixAudio), castId(castId), castData(castData) {}
explicit Source(VideoSrc src, std::optional<double> startTime, std::vector<Subtitle> subtitles, std::optional<std::vector<std::string>> fonts, std::optional<Metadata> metadata, std::optional<MixAudioMode> mixAudio, std::optional<std::string> castId, std::optional<std::unordered_map<std::string, std::string>> castData): src(src), startTime(startTime), subtitles(subtitles), fonts(fonts), metadata(metadata), mixAudio(mixAudio), castId(castId), castData(castData) {}
public:
friend bool operator==(const Source& lhs, const Source& rhs) = default;
@@ -80,7 +80,7 @@ namespace margelo::nitro {
static inline margelo::nitro::omni::Source fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) {
jsi::Object obj = arg.asObject(runtime);
return margelo::nitro::omni::Source(
JSIConverter<std::vector<margelo::nitro::omni::VideoSrc>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "src"))),
JSIConverter<margelo::nitro::omni::VideoSrc>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "src"))),
JSIConverter<std::optional<double>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "startTime"))),
JSIConverter<std::vector<margelo::nitro::omni::Subtitle>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "subtitles"))),
JSIConverter<std::optional<std::vector<std::string>>>::fromJSI(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "fonts"))),
@@ -92,7 +92,7 @@ namespace margelo::nitro {
}
static inline jsi::Value toJSI(jsi::Runtime& runtime, const margelo::nitro::omni::Source& arg) {
jsi::Object obj(runtime);
obj.setProperty(runtime, PropNameIDCache::get(runtime, "src"), JSIConverter<std::vector<margelo::nitro::omni::VideoSrc>>::toJSI(runtime, arg.src));
obj.setProperty(runtime, PropNameIDCache::get(runtime, "src"), JSIConverter<margelo::nitro::omni::VideoSrc>::toJSI(runtime, arg.src));
obj.setProperty(runtime, PropNameIDCache::get(runtime, "startTime"), JSIConverter<std::optional<double>>::toJSI(runtime, arg.startTime));
obj.setProperty(runtime, PropNameIDCache::get(runtime, "subtitles"), JSIConverter<std::vector<margelo::nitro::omni::Subtitle>>::toJSI(runtime, arg.subtitles));
obj.setProperty(runtime, PropNameIDCache::get(runtime, "fonts"), JSIConverter<std::optional<std::vector<std::string>>>::toJSI(runtime, arg.fonts));
@@ -110,7 +110,7 @@ namespace margelo::nitro {
if (!nitro::isPlainObject(runtime, obj)) {
return false;
}
if (!JSIConverter<std::vector<margelo::nitro::omni::VideoSrc>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "src")))) return false;
if (!JSIConverter<margelo::nitro::omni::VideoSrc>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "src")))) return false;
if (!JSIConverter<std::optional<double>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "startTime")))) return false;
if (!JSIConverter<std::vector<margelo::nitro::omni::Subtitle>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "subtitles")))) return false;
if (!JSIConverter<std::optional<std::vector<std::string>>>::canConvert(runtime, obj.getProperty(runtime, PropNameIDCache::get(runtime, "fonts")))) return false;
+17
View File
@@ -7,6 +7,8 @@ import {
const OPTIONS_PROVIDER_NAME =
"com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME";
const OPTIONS_PROVIDER_VALUE = "dev.zoriya.omni.OmniCastOptionsProvider";
const MEDIA_TRANSFER_RECEIVER_NAME =
"androidx.mediarouter.media.MediaTransferReceiver";
function patchManifestForCast(
androidManifest: AndroidConfig.Manifest.AndroidManifest,
@@ -36,6 +38,21 @@ function patchManifestForCast(
}
mainApplication["meta-data"] = metaData;
const receivers = mainApplication.receiver ?? [];
if (
!receivers.some(
(item) => item.$["android:name"] === MEDIA_TRANSFER_RECEIVER_NAME,
)
) {
receivers.push({
$: {
"android:name": MEDIA_TRANSFER_RECEIVER_NAME,
"android:exported": "true",
},
});
}
mainApplication.receiver = receivers;
return androidManifest;
}
-3
View File
@@ -127,9 +127,6 @@ export const useEvent = <Event extends keyof OmniEvents>(
return undefined;
}, [event, player]);
// ASS/PGS (overlay) subtitle selection is not a videojs text-track change, so
// re-fire subtitleChange when the overlay subtitle changes too — consumers
// only need useEvent("subtitleChange").
useEffect(() => {
if (event !== "subtitleChange") return;
return player.subscribeOverlaySubtitle(() => {
+10 -26
View File
@@ -217,47 +217,31 @@ export class WebOmniPlayer implements OmniPlayer {
get subtitles(): Track[] {
const textTracks = selectTextTrack(this._store.state)?.textTrackList ?? [];
const native = textTracks
return textTracks
.filter((x) => x.kind === "subtitles" || x.kind === "captions")
.map((track) => ({
id: track.id!,
label: track.label,
language: track.language,
selected: track.mode === "showing",
selected:
track.mode === "showing" || this.overlaySubtitle?.id === track.id,
}));
const overlay = this.overlaySubtitles.map((sub) => ({
id: sub.id,
label: sub.label,
language: sub.language,
selected: this.overlaySubtitle?.id === sub.id,
}));
return [...native, ...overlay];
}
selectSubtitle(subtitle?: Track): void {
const tracks = selectTextTrack(this._store.state);
const overlay = subtitle
? this.overlaySubtitles.find((s) => s.id === subtitle.id)
: undefined;
const tracks = selectTextTrack(this._store.state);
if (this.castStatus === "connected") {
tracks?.selectSubtitlesTrack(subtitle ? subtitle.id : "off");
this.setOverlaySubtitle(null);
return;
}
tracks?.selectSubtitlesTrack(overlay || !subtitle ? "off" : subtitle.id);
this.setOverlaySubtitle(overlay ?? null);
if (this.castStatus === "connected") {
window.cast.framework.CastContext.getInstance()
.getCurrentSession()
?.sendMessage("urn:x-cast:dev.zoriya.omni", {
subtitle: overlay?.id ?? null,
})
.catch(() => {});
}
}
// To be used on a callback of a chromecast session, it only changes the local state
applyRemoteSubtitle(id: string | null): void {
this.setOverlaySubtitle(
id ? (this.overlaySubtitles.find((s) => s.id === id) ?? null) : null,
);
}
private setOverlaySubtitle(sub: Subtitle | null): void {
+1 -31
View File
@@ -60,7 +60,7 @@ const PlayerInitializer = ({
useEffect(() => {
player.source = source;
const uri = source?.src[0]?.uri;
const uri = source?.src?.uri;
if (uri !== seekedForSrc.current) {
seekedForSrc.current = uri;
if (source?.startTime) store.seek(source.startTime);
@@ -75,36 +75,6 @@ const PlayerInitializer = ({
player.showNotification = showNotification;
}, [showNotification]);
useEffect(() => {
let media: chrome.cast.media.Media | null = null;
const onMediaUpdate = () => {
const data = media?.customData as { subtitle?: unknown } | undefined;
player.applyRemoteSubtitle(
typeof data?.subtitle === "string" ? data.subtitle : null,
);
};
const syncMedia = () => {
const next =
window.cast.framework.CastContext.getInstance()
.getCurrentSession()
?.getMediaSession() ?? null;
if (next === media) return;
media?.removeUpdateListener(onMediaUpdate);
media = next;
media?.addUpdateListener(onMediaUpdate);
onMediaUpdate();
};
const unsubscribe = store.subscribe(syncMedia);
syncMedia();
return () => {
unsubscribe();
media?.removeUpdateListener(onMediaUpdate);
};
}, [store]);
return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>;
};
+1 -1
View File
@@ -1,5 +1,5 @@
export interface Source {
src: VideoSrc[];
src: VideoSrc;
startTime?: number;
subtitles: Subtitle[];
// fonts that can be used by ass subtitles on the web (native will use
+12 -18
View File
@@ -10,11 +10,7 @@ import {
useSyncExternalStore,
} from "react";
import { usePlayerState } from "./events";
import {
getSubtitleFormat,
isCustomSubtitle,
type WebOmniPlayer,
} from "./player.web";
import { getSubtitleFormat, type WebOmniPlayer } from "./player.web";
import { usePlayer, VideoPlayer } from "./provider.web";
import type { SubtitleAssets } from "./types/subtitles";
import type { OmniViewProps } from "./types/view";
@@ -114,7 +110,7 @@ export const OmniView = ({
const containerRef = useRef<HTMLDivElement>(null);
const ref = useRef<HTMLVideoElement>(undefined!);
const src = player.source?.src[0];
const src = player.source?.src;
const isHls =
src?.mimeType?.toLowerCase().includes("mpegurl") ||
src?.uri.split(/[?#]/)[0]?.toLowerCase().endsWith(".m3u8") ||
@@ -165,18 +161,16 @@ export const OmniView = ({
crossOrigin="anonymous"
style={{ width: "100%", height: "100%", objectFit: "contain" }}
>
{(player.source?.subtitles ?? [])
.filter((subtitle) => !isCustomSubtitle(subtitle))
.map((subtitle) => (
<track
key={subtitle.id}
id={subtitle.id}
kind="subtitles"
src={subtitle.link}
srcLang={subtitle.language}
label={subtitle.label ?? subtitle.language ?? subtitle.id}
/>
))}
{(player.source?.subtitles ?? []).map((subtitle) => (
<track
key={subtitle.id}
id={subtitle.id}
kind="subtitles"
src={subtitle.link}
srcLang={subtitle.language}
label={subtitle.label ?? subtitle.language ?? subtitle.id}
/>
))}
</Tech>
)}
{castStatus !== "connected" && castStatus !== "connecting" && (