mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-16 02:44:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77a8b2337d | ||
|
|
cebe97f168 | ||
|
|
e5615cbc0c | ||
|
|
5f2d74f321 | ||
|
|
3739e760cf | ||
|
|
099eeabe18 | ||
|
|
d8f96962e3 | ||
|
|
12be950ea0 | ||
|
|
6ab5743f94 | ||
|
|
9dae350c73 | ||
|
|
074b821e06 |
@@ -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,25 +106,41 @@ class OmniPlayer(
|
||||
|
||||
override var showNotification: Boolean? = false
|
||||
set(value) {
|
||||
if (value == 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 ->
|
||||
runOnMainThreadSync { other.isPlaying }
|
||||
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) {
|
||||
@@ -145,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()
|
||||
}
|
||||
@@ -208,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 {
|
||||
@@ -247,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(
|
||||
@@ -276,9 +308,9 @@ class OmniPlayer(
|
||||
field = value
|
||||
if (value == null) {
|
||||
runOnMainThreadSync {
|
||||
player.setMediaItem(MediaItem.EMPTY)
|
||||
player.prepare()
|
||||
player.clearMediaItems()
|
||||
}
|
||||
syncNotificationService()
|
||||
return
|
||||
}
|
||||
val handleAudioFocus =
|
||||
@@ -311,6 +343,7 @@ class OmniPlayer(
|
||||
player.setMediaItems(mediaItems, startIndex, startPositionMs.toLong())
|
||||
player.prepare()
|
||||
}
|
||||
syncNotificationService()
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
@@ -346,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,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 }
|
||||
@@ -477,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"
|
||||
@@ -498,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)
|
||||
}
|
||||
@@ -528,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(
|
||||
@@ -566,7 +603,7 @@ class OmniPlayerService : MediaSessionService() {
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
mediaSession.release()
|
||||
if (::mediaSession.isInitialized) mediaSession.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ class OmniView(val context: ThemedReactContext) :
|
||||
|
||||
autoPip == false && curPip == this -> {
|
||||
activeView = WeakReference(null)
|
||||
clearPictureInPictureParams()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,12 +140,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 +157,7 @@ class OmniView(val context: ThemedReactContext) :
|
||||
restoreUiAfterPip()
|
||||
if (activeView.get() == this) {
|
||||
activeView = WeakReference(null)
|
||||
clearPictureInPictureParams()
|
||||
}
|
||||
surfaceView.removeOnLayoutChangeListener(this)
|
||||
context.removeLifecycleEventListener(this)
|
||||
@@ -164,7 +166,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 +185,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 +219,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 +287,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)
|
||||
}
|
||||
|
||||
@@ -305,9 +339,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 +363,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()
|
||||
@@ -688,25 +696,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 +725,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 +804,7 @@ class VlcPlayer(ctx: Context) :
|
||||
}
|
||||
|
||||
override fun clearVideoSurface() {
|
||||
boundSurfaceView = null
|
||||
vlcVout.detachViews()
|
||||
}
|
||||
|
||||
@@ -825,10 +835,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()
|
||||
}
|
||||
|
||||
+33
-7
@@ -1,5 +1,5 @@
|
||||
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,
|
||||
@@ -57,12 +57,16 @@ function PlayerExample({
|
||||
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");
|
||||
@@ -75,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],
|
||||
@@ -98,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);
|
||||
});
|
||||
}, []);
|
||||
@@ -232,7 +237,20 @@ 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
|
||||
@@ -484,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>
|
||||
))
|
||||
)}
|
||||
@@ -496,6 +514,9 @@ 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,
|
||||
@@ -536,6 +557,8 @@ function App(): React.JSX.Element {
|
||||
{
|
||||
id: "kusu",
|
||||
link: "https://jassub.pages.dev/subtitles/Kusriya%20S2%20OP1v3.ass",
|
||||
label: "ass test",
|
||||
language: "jp",
|
||||
},
|
||||
{
|
||||
id: "pgs",
|
||||
@@ -571,8 +594,9 @@ function App(): React.JSX.Element {
|
||||
return (
|
||||
<OmniProvider
|
||||
key={backend}
|
||||
source={source}
|
||||
source={hasSource ? source : undefined}
|
||||
backend={{ android: backend }}
|
||||
cast={{receiverApplicationId: "D8FB0FC1"}}
|
||||
showNotification
|
||||
>
|
||||
<PlayerExample
|
||||
@@ -581,6 +605,8 @@ function App(): React.JSX.Element {
|
||||
trackLabel={PLAYLIST[currentIndex].title}
|
||||
backend={backend}
|
||||
onSwitchBackend={handleSwitchBackend}
|
||||
hasSource={hasSource}
|
||||
onLoad={() => setHasSource((v) => !v)}
|
||||
/>
|
||||
</OmniProvider>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
+11
-17
@@ -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";
|
||||
@@ -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" && (
|
||||
|
||||
Reference in New Issue
Block a user