Compare commits

...
12 Commits
12 changed files with 376 additions and 130 deletions
+9 -10
View File
@@ -5,7 +5,7 @@ buildscript {
} }
dependencies { dependencies {
classpath "com.android.tools.build:gradle:8.8.0" classpath "com.android.tools.build:gradle:9.2.1"
} }
} }
@@ -143,16 +143,15 @@ dependencies {
// Add a dependency on NitroModules // Add a dependency on NitroModules
implementation project(":react-native-nitro-modules") implementation project(":react-native-nitro-modules")
implementation("org.videolan.android:libvlc-all:4.0.0-eap24") implementation("org.videolan.android:libvlc-all:4.0.0-eap29")
implementation("androidx.media3:media3-common:1.4.1") implementation("androidx.media3:media3-common:1.11.0")
implementation("androidx.media3:media3-session:1.4.1") implementation("androidx.media3:media3-session:1.11.0")
implementation("androidx.media3:media3-ui:1.4.1") implementation("androidx.media3:media3-ui:1.11.0")
implementation 'androidx.media3:media3-exoplayer:1.10.0' implementation 'androidx.media3:media3-exoplayer:1.11.0'
implementation("androidx.media3:media3-exoplayer-hls:1.10.0") implementation("androidx.media3:media3-exoplayer-hls:1.11.0")
implementation("androidx.media3:media3-cast:1.11.0")
implementation("androidx.media3:media3-cast:1.10.0")
implementation("com.google.android.gms:play-services-cast-framework:22.3.1") implementation("com.google.android.gms:play-services-cast-framework:22.3.1")
implementation("androidx.mediarouter:mediarouter:1.7.0") implementation("androidx.mediarouter:mediarouter:1.8.1")
} }
if (isNewArchitectureEnabled()) { if (isNewArchitectureEnabled()) {
@@ -5,12 +5,9 @@ import androidx.media3.common.C
import androidx.media3.common.C.TRACK_TYPE_AUDIO import androidx.media3.common.C.TRACK_TYPE_AUDIO
import androidx.media3.common.C.TRACK_TYPE_TEXT import androidx.media3.common.C.TRACK_TYPE_TEXT
import androidx.media3.common.C.TRACK_TYPE_VIDEO import androidx.media3.common.C.TRACK_TYPE_VIDEO
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.media3.common.PlaybackParameters import androidx.media3.common.PlaybackParameters
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_AUTO
import androidx.media3.common.Player.MEDIA_ITEM_TRANSITION_REASON_SEEK
import androidx.media3.common.Player.STATE_BUFFERING import androidx.media3.common.Player.STATE_BUFFERING
import androidx.media3.common.Player.STATE_ENDED import androidx.media3.common.Player.STATE_ENDED
import androidx.media3.common.Player.STATE_IDLE import androidx.media3.common.Player.STATE_IDLE
@@ -18,6 +15,8 @@ import androidx.media3.common.Player.STATE_READY
import androidx.media3.common.Timeline import androidx.media3.common.Timeline
import androidx.media3.common.Tracks import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize import androidx.media3.common.VideoSize
import com.google.android.gms.cast.MediaStatus
import com.google.android.gms.cast.framework.media.RemoteMediaClient
import com.margelo.nitro.omni.BoolProperty import com.margelo.nitro.omni.BoolProperty
import com.margelo.nitro.omni.CastStatus import com.margelo.nitro.omni.CastStatus
import com.margelo.nitro.omni.HybridOmniEventMapSpec import com.margelo.nitro.omni.HybridOmniEventMapSpec
@@ -59,7 +58,6 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
private val castStatusListeners = mutableSetOf<(CastStatus) -> Unit>() private val castStatusListeners = mutableSetOf<(CastStatus) -> Unit>()
private val sourceListeners = mutableSetOf<(Source?) -> Unit>() private val sourceListeners = mutableSetOf<(Source?) -> Unit>()
private var lastMediaItemIndex = 0
private var lastRendition: Rendition? = null private var lastRendition: Rendition? = null
private var lastRenditions: Array<Rendition>? = null private var lastRenditions: Array<Rendition>? = null
private var lastIsAutoQuality: Boolean? = null private var lastIsAutoQuality: Boolean? = null
@@ -73,7 +71,6 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
_player?.removeListener(this) _player?.removeListener(this)
_player = value _player = value
value.addListener(this) value.addListener(this)
lastMediaItemIndex = value.currentMediaItemIndex
lastRendition = null lastRendition = null
lastRenditions = null lastRenditions = null
lastIsAutoQuality = null lastIsAutoQuality = null
@@ -108,6 +105,8 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
onTracksChanged(value.currentTracks) onTracksChanged(value.currentTracks)
} }
var remote: RemoteMediaClient? = null
fun emitCastStatus(status: CastStatus) { fun emitCastStatus(status: CastStatus) {
castStatusListeners.forEach { it(status) } castStatusListeners.forEach { it(status) }
} }
@@ -116,6 +115,14 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
sourceListeners.forEach { it(source) } sourceListeners.forEach { it(source) }
} }
fun emitPrev() {
onPrevListeners.forEach { it() }
}
fun emitNext() {
onNextListeners.forEach { it() }
}
private fun selectedTrack(trackType: Int): Track? { private fun selectedTrack(trackType: Int): Track? {
val list = when (trackType) { val list = when (trackType) {
TRACK_TYPE_VIDEO -> tracks.videos TRACK_TYPE_VIDEO -> tracks.videos
@@ -197,7 +204,13 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
override fun onPlaybackStateChanged(playbackState: Int) { override fun onPlaybackStateChanged(playbackState: Int) {
val state = when (player.playbackState) { val state = when (player.playbackState) {
STATE_IDLE -> PlayerStatus.IDLE STATE_IDLE -> {
// cast doesn't have STATE_ENDED, so handle this manually
if (remote?.mediaStatus?.idleReason == MediaStatus.IDLE_REASON_FINISHED)
onEndListeners.forEach { it() }
PlayerStatus.IDLE
}
STATE_BUFFERING -> PlayerStatus.LOADING STATE_BUFFERING -> PlayerStatus.LOADING
STATE_READY -> PlayerStatus.READYTOPLAY STATE_READY -> PlayerStatus.READYTOPLAY
STATE_ENDED -> { STATE_ENDED -> {
@@ -278,20 +291,6 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
playerStatusListeners.forEach { it(PlayerStatus.ERROR) } playerStatusListeners.forEach { it(PlayerStatus.ERROR) }
} }
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (reason != MEDIA_ITEM_TRANSITION_REASON_AUTO && reason != MEDIA_ITEM_TRANSITION_REASON_SEEK) {
lastMediaItemIndex = player.currentMediaItemIndex
return
}
val newIndex = player.currentMediaItemIndex
if (newIndex < lastMediaItemIndex) {
onPrevListeners.forEach { it() }
} else if (newIndex > lastMediaItemIndex) {
onNextListeners.forEach { it() }
}
lastMediaItemIndex = newIndex
}
override fun onPositionDiscontinuity( override fun onPositionDiscontinuity(
oldPosition: Player.PositionInfo, oldPosition: Player.PositionInfo,
newPosition: Player.PositionInfo, newPosition: Player.PositionInfo,
@@ -0,0 +1,51 @@
package dev.zoriya.omni
import androidx.annotation.OptIn
import androidx.media3.common.ForwardingSimpleBasePlayer
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
@OptIn(UnstableApi::class)
class NavigationPlayer(
player: Player,
private val hasPrev: () -> Boolean,
private val hasNext: () -> Boolean,
private val onPrev: () -> Unit,
private val onNext: () -> Unit,
) : ForwardingSimpleBasePlayer(player) {
override fun getState(): State {
val state = super.getState()
return state.buildUpon()
.setAvailableCommands(
state.availableCommands.buildUpon()
.addIf(Player.COMMAND_SEEK_TO_PREVIOUS, hasPrev())
.addIf(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM, hasPrev())
.addIf(Player.COMMAND_SEEK_TO_NEXT, hasNext())
.addIf(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM, hasNext())
.build()
)
.build()
}
override fun handleSeek(
mediaItemIndex: Int,
positionMs: Long,
seekCommand: Int,
): ListenableFuture<*> = when (seekCommand) {
Player.COMMAND_SEEK_TO_PREVIOUS,
Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM -> {
onPrev()
Futures.immediateVoidFuture()
}
Player.COMMAND_SEEK_TO_NEXT,
Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM -> {
onNext()
Futures.immediateVoidFuture()
}
else -> super.handleSeek(mediaItemIndex, positionMs, seekCommand)
}
}
@@ -3,6 +3,7 @@ package dev.zoriya.omni
import android.content.Context import android.content.Context
import com.google.android.gms.cast.CastMediaControlIntent import com.google.android.gms.cast.CastMediaControlIntent
import com.google.android.gms.cast.framework.CastOptions import com.google.android.gms.cast.framework.CastOptions
import com.google.android.gms.cast.framework.media.CastMediaOptions
import com.google.android.gms.cast.framework.OptionsProvider import com.google.android.gms.cast.framework.OptionsProvider
import com.google.android.gms.cast.framework.SessionProvider import com.google.android.gms.cast.framework.SessionProvider
@@ -11,9 +12,15 @@ class OmniCastOptionsProvider : OptionsProvider {
val appId = OmniPlayer.receiverApplicationId val appId = OmniPlayer.receiverApplicationId
?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID ?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
val mediaOptions = CastMediaOptions.Builder()
.setMediaSessionEnabled(false)
.setNotificationOptions(null)
.build()
return CastOptions.Builder() return CastOptions.Builder()
.setReceiverApplicationId(appId) .setReceiverApplicationId(appId)
.setShowSystemOutputSwitcherOnCastIconClick(true) .setShowSystemOutputSwitcherOnCastIconClick(true)
.setCastMediaOptions(mediaOptions)
.build() .build()
} }
@@ -60,13 +60,18 @@ class OmniCastTrackSelector : CastTrackSelector() {
val overrideMatch = indices.firstOrNull { i -> val overrideMatch = indices.firstOrNull { i ->
overrides.keys.any { it.id == groups[i].id } overrides.keys.any { it.id == groups[i].id }
} }
val match = indices.firstOrNull { i -> val matchesLabel = { i: Int ->
val name: String? = tracks[i].name val name: String? = tracks[i].name
val language: String? = tracks[i].language labels.isNotEmpty() && name != null && name in labels
(labels.isNotEmpty() && name != null && name in labels) ||
(languages.isNotEmpty() && language != null &&
Util.normalizeLanguageCode(language) in languages)
} }
val matchesLanguage = { i: Int ->
val language: String? = tracks[i].language
languages.isNotEmpty() && language != null &&
Util.normalizeLanguageCode(language) in languages
}
val match = indices.firstOrNull { matchesLabel(it) && matchesLanguage(it) }
?: indices.firstOrNull { matchesLabel(it) }
?: indices.firstOrNull { matchesLanguage(it) }
val chosen = when { val chosen = when {
overrideMatch != null -> overrideMatch overrideMatch != null -> overrideMatch
match != null -> match match != null -> match
@@ -24,8 +24,15 @@ class OmniMediaItemConverter : MediaItemConverter {
builder.setMediaMetadata( builder.setMediaMetadata(
androidx.media3.common.MediaMetadata.Builder() androidx.media3.common.MediaMetadata.Builder()
.setTitle(md.getString(CastMediaMetadata.KEY_TITLE)) .setTitle(md.getString(CastMediaMetadata.KEY_TITLE))
.setArtist(md.getString(CastMediaMetadata.KEY_ARTIST)) .setArtist(
.setAlbumTitle(md.getString(CastMediaMetadata.KEY_ALBUM_TITLE)) md.getString(CastMediaMetadata.KEY_ARTIST)
?: md.getString(CastMediaMetadata.KEY_SUBTITLE)
)
.setAlbumTitle(
md.getString(CastMediaMetadata.KEY_ALBUM_TITLE)
?: md.getString(CastMediaMetadata.KEY_STUDIO)
)
.setArtworkUri(md.images.firstOrNull()?.url)
.build() .build()
) )
} }
@@ -1,6 +1,8 @@
package dev.zoriya.omni package dev.zoriya.omni
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context
import android.os.Looper
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
@@ -13,20 +15,27 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.text.TextRenderer
import com.margelo.nitro.NitroModules import com.margelo.nitro.NitroModules
import com.margelo.nitro.omni.AndroidBackend import com.margelo.nitro.omni.AndroidBackend
import com.margelo.nitro.omni.CastStatus import com.margelo.nitro.omni.CastStatus
import com.margelo.nitro.omni.HybridOmniPlayerSpec import com.margelo.nitro.omni.HybridOmniPlayerSpec
import com.margelo.nitro.omni.Metadata
import com.margelo.nitro.omni.MixAudioMode import com.margelo.nitro.omni.MixAudioMode
import com.margelo.nitro.omni.PlayerStatus import com.margelo.nitro.omni.PlayerStatus
import com.margelo.nitro.omni.Rendition import com.margelo.nitro.omni.Rendition
import com.margelo.nitro.omni.Source import com.margelo.nitro.omni.Source
import com.margelo.nitro.omni.Track import com.margelo.nitro.omni.Track
import com.margelo.nitro.omni.VideoSrc
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.media3.common.MediaItem.RequestMetadata import androidx.media3.common.MediaItem.RequestMetadata
import androidx.media3.common.MediaItem.SubtitleConfiguration import androidx.media3.common.MediaItem.SubtitleConfiguration
@@ -38,6 +47,7 @@ import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import androidx.mediarouter.app.MediaRouteChooserDialog import androidx.mediarouter.app.MediaRouteChooserDialog
import androidx.mediarouter.app.SystemOutputSwitcherDialogController import androidx.mediarouter.app.SystemOutputSwitcherDialogController
import com.google.android.gms.cast.MediaStatus
import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastState import com.google.android.gms.cast.framework.CastState
import com.google.android.gms.cast.framework.CastStateListener import com.google.android.gms.cast.framework.CastStateListener
@@ -62,9 +72,26 @@ class OmniPlayer(
AndroidBackend.EXOPLAYER -> { AndroidBackend.EXOPLAYER -> {
val http = DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true) val http = DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
httpDataSourceFactory = http httpDataSourceFactory = http
ExoPlayer.Builder(ctx) // karaoke subs make exoplayer crash without this...
val renderersFactory = object : DefaultRenderersFactory(ctx) {
override fun buildTextRenderers(
context: Context,
output: TextOutput,
outputLooper: Looper,
extensionRendererMode: Int,
out: ArrayList<Renderer>,
) {
out.add(
TextRenderer(output, outputLooper).apply {
experimentalSetLegacyDecodingEnabled(true)
}
)
}
}
ExoPlayer.Builder(ctx, renderersFactory)
.setMediaSourceFactory( .setMediaSourceFactory(
DefaultMediaSourceFactory(DefaultDataSource.Factory(ctx, http)) DefaultMediaSourceFactory(DefaultDataSource.Factory(ctx, http))
.experimentalParseSubtitlesDuringExtraction(false)
) )
.build() .build()
} }
@@ -75,8 +102,29 @@ class OmniPlayer(
override val eventMap = EventMap(this) override val eventMap = EventMap(this)
private var castContext: CastContext? = null private var castContext: CastContext? = null
private val castStateListener = private val castStateListener = CastStateListener {
CastStateListener { eventMap.emitCastStatus(computeCastStatus()) } eventMap.emitCastStatus(computeCastStatus())
listenToReceiver()
}
// receivers have no queue to skip in (a queue would make them play things on
// their own), so they ask us to do it when something else - google home, the
// assistant, ... - requests a prev/next. same path as our own buttons.
private fun listenToReceiver() {
val session = castContext?.sessionManager?.currentCastSession
eventMap.remote = session?.remoteMediaClient
if (session == null) return
try {
session.setMessageReceivedCallbacks(OMNI_NAMESPACE) { _, _, message ->
when (JSONObject(message).optString("action")) {
"prev" -> runOnMainThread { eventMap.emitPrev() }
"next" -> runOnMainThread { eventMap.emitNext() }
}
}
} catch (e: Throwable) {
android.util.Log.w("OmniPlayer", "could not listen to the receiver", e)
}
}
val player: Player = runOnMainThreadSync { val player: Player = runOnMainThreadSync {
castOptions?.receiverApplicationId?.let { receiverApplicationId = it } castOptions?.receiverApplicationId?.let { receiverApplicationId = it }
@@ -100,7 +148,13 @@ class OmniPlayer(
.setRemotePlayer(remote) .setRemotePlayer(remote)
.build() .build()
} }
active NavigationPlayer(
active,
hasPrev = { source?.metadata?.hasPrev == true },
hasNext = { source?.metadata?.hasNext == true },
onPrev = { eventMap.emitPrev() },
onNext = { eventMap.emitNext() },
)
} }
override var showNotification: Boolean? = false override var showNotification: Boolean? = false
@@ -116,19 +170,19 @@ class OmniPlayer(
when { when {
shouldShow && !serviceRunning -> { shouldShow && !serviceRunning -> {
val otherIsPlaying = notificationPlayer?.let { other -> val otherIsPlaying = notificationPlayer?.let { other ->
other !== localPlayer && runOnMainThreadSync { other.isPlaying } other !== player && runOnMainThreadSync { other.isPlaying }
} == true } == true
if (otherIsPlaying) { if (otherIsPlaying) {
throw Error("Two players can't display notifications at the same time.") throw Error("Two players can't display notifications at the same time.")
} }
notificationPlayer = localPlayer notificationPlayer = player
ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java)) ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java))
serviceRunning = true serviceRunning = true
} }
!shouldShow && serviceRunning -> { !shouldShow && serviceRunning -> {
ctx.stopService(Intent(ctx, OmniPlayerService::class.java)) ctx.stopService(Intent(ctx, OmniPlayerService::class.java))
if (notificationPlayer == localPlayer) notificationPlayer = null if (notificationPlayer == player) notificationPlayer = null
serviceRunning = false serviceRunning = false
} }
} }
@@ -187,6 +241,19 @@ class OmniPlayer(
super.dispose() super.dispose()
} }
private fun inferSubtitleMimeType(link: String): String? {
val ext = link.toUri().lastPathSegment?.substringAfterLast('.', "")?.lowercase()
return when (ext) {
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
"srt" -> MimeTypes.APPLICATION_SUBRIP
"ass", "ssa" -> MimeTypes.TEXT_SSA
"ttml", "dfxp", "xml" -> MimeTypes.APPLICATION_TTML
"sup" -> MimeTypes.APPLICATION_PGS
"sub" -> MimeTypes.APPLICATION_VOBSUB
else -> null
}
}
private fun buildMediaItem( private fun buildMediaItem(
src: com.margelo.nitro.omni.VideoSrc, src: com.margelo.nitro.omni.VideoSrc,
metadata: com.margelo.nitro.omni.Metadata?, metadata: com.margelo.nitro.omni.Metadata?,
@@ -212,7 +279,10 @@ class OmniPlayer(
.setId(subtitle.id) .setId(subtitle.id)
.setLanguage(subtitle.language) .setLanguage(subtitle.language)
.setLabel(subtitle.label) .setLabel(subtitle.label)
.setMimeType(subtitle.mimeType) // exoplayer requires a mime type to pick a subtitle parser.
// fall back to inferring it from the link's extension so
// consumers don't have to provide one.
.setMimeType(subtitle.mimeType ?: inferSubtitleMimeType(subtitle.link))
.build() .build()
}) })
.setRequestMetadata( .setRequestMetadata(
@@ -233,6 +303,33 @@ class OmniPlayer(
.build() .build()
} }
private fun sourceOf(item: MediaItem): Source {
val meta = item.mediaMetadata
val status = castContext?.sessionManager?.currentCastSession
?.remoteMediaClient?.mediaStatus
return Source(
src = VideoSrc(
uri = item.localConfiguration?.uri?.toString() ?: item.mediaId,
mimeType = item.localConfiguration?.mimeType,
headers = emptyMap(),
),
startTime = null,
subtitles = emptyArray(),
fonts = null,
metadata = Metadata(
title = meta.title?.toString() ?: "",
album = meta.albumTitle?.toString(),
artist = meta.artist?.toString(),
imageLink = meta.artworkUri?.toString(),
hasPrev = status?.isMediaCommandSupported(MediaStatus.COMMAND_QUEUE_PREVIOUS),
hasNext = status?.isMediaCommandSupported(MediaStatus.COMMAND_QUEUE_NEXT),
),
mixAudio = null,
castId = item.mediaId,
castData = null,
)
}
fun setVideoView(surfaceView: android.view.SurfaceView) { fun setVideoView(surfaceView: android.view.SurfaceView) {
runOnMainThread { runOnMainThread {
localPlayer.setVideoSurfaceView(surfaceView) localPlayer.setVideoSurfaceView(surfaceView)
@@ -258,8 +355,8 @@ class OmniPlayer(
} }
} }
override val hasPrev: Boolean get() = player.hasPreviousMediaItem() override val hasPrev: Boolean get() = source?.metadata?.hasPrev == true
override val hasNext: Boolean get() = player.hasNextMediaItem() override val hasNext: Boolean get() = source?.metadata?.hasNext == true
override val status by mainThreadProperty { override val status by mainThreadProperty {
when (player.playbackState) { when (player.playbackState) {
Player.STATE_IDLE, Player.STATE_IDLE,
@@ -343,15 +440,39 @@ class OmniPlayer(
} }
} }
init { init {
runOnMainThreadSync { eventMap.player = player } runOnMainThreadSync {
eventMap.player = player
player.addListener(object : Player.Listener {
override fun onMediaItemTransition(item: MediaItem?, reason: Int) = followMedia(item)
})
}
} }
override var source: Source? = null private fun followMedia(item: MediaItem?) {
// our media only leaves the player while it is being handed over to the
// receiver (starting a cast), it is not gone.
if (item == null && ownItem != null) return
if (item != null && item == ownItem) return
val source = item?.let { sourceOf(it) }
if (source == _source) return
_source = source
eventMap.emitSource(source)
syncNotificationService()
}
private var _source: Source? = null
private var ownItem: MediaItem? = null
override var source: Source?
get() = _source
set(value) { set(value) {
field = value _source = value
eventMap.emitSource(value) eventMap.emitSource(value)
if (value == null) { if (value == null) {
ownItem = null
runOnMainThreadSync { runOnMainThreadSync {
player.clearMediaItems() player.clearMediaItems()
} }
@@ -377,15 +498,11 @@ class OmniPlayer(
value.castId, value.castId,
value.castData, value.castData,
) )
val mediaItems = mutableListOf<MediaItem>() ownItem = currentItem
if (value.metadata?.hasPrev == true) mediaItems.add(currentItem)
mediaItems.add(currentItem)
if (value.metadata?.hasNext == true) mediaItems.add(currentItem)
runOnMainThreadSync { runOnMainThreadSync {
val startIndex = if (value.metadata?.hasPrev == true) 1 else 0
val startPositionMs = ((value.startTime ?: 0.0).coerceAtLeast(0.0)) * 1000.0 val startPositionMs = ((value.startTime ?: 0.0).coerceAtLeast(0.0)) * 1000.0
player.setMediaItems(mediaItems, startIndex, startPositionMs.toLong()) player.setMediaItems(listOf(currentItem), 0, startPositionMs.toLong())
player.prepare() player.prepare()
} }
syncNotificationService() syncNotificationService()
@@ -407,11 +524,11 @@ class OmniPlayer(
} }
override fun playPrev() { override fun playPrev() {
runOnMainThreadSync { player.seekToPreviousMediaItem() } runOnMainThreadSync { eventMap.emitPrev() }
} }
override fun playNext() { override fun playNext() {
runOnMainThreadSync { player.seekToNextMediaItem() } runOnMainThreadSync { eventMap.emitNext() }
} }
override fun selectVideo(video: Track) { override fun selectVideo(video: Track) {
@@ -441,15 +558,15 @@ class OmniPlayer(
}?.mediaTrackGroup }?.mediaTrackGroup
if (group != null) { if (group != null) {
params.setOverrideForType(TrackSelectionOverride(group, 0)) params.setOverrideForType(TrackSelectionOverride(group, 0))
}
// also set preferred flags for cast selection
val labels = track.label?.let { arrayOf(it) } ?: emptyArray()
if (type == C.TRACK_TYPE_AUDIO) {
params.setPreferredAudioLanguage(track.language)
.setPreferredAudioLabels(*labels)
} else { } else {
val labels = track.label?.let { arrayOf(it) } ?: emptyArray() params.setPreferredTextLanguage(track.language)
if (type == C.TRACK_TYPE_AUDIO) { .setPreferredTextLabels(*labels)
params.setPreferredAudioLanguage(track.language)
.setPreferredAudioLabels(*labels)
} else {
params.setPreferredTextLanguage(track.language)
.setPreferredTextLabels(*labels)
}
} }
} }
player.trackSelectionParameters = params.build() player.trackSelectionParameters = params.build()
@@ -523,6 +640,10 @@ class OmniPlayer(
const val CAST_ID_EXTRA = "dev.zoriya.omni.castId" const val CAST_ID_EXTRA = "dev.zoriya.omni.castId"
const val CAST_DATA_EXTRA = "dev.zoriya.omni.castData" const val CAST_DATA_EXTRA = "dev.zoriya.omni.castData"
// channel a receiver uses to ask us to play the prev/next media.
// it must declare it too (`customNamespaces` in the caf options).
const val OMNI_NAMESPACE = "urn:x-cast:dev.zoriya.omni"
// Receiver application id passed at runtime via OmniProvider's `cast` // Receiver application id passed at runtime via OmniProvider's `cast`
// prop; read by OmniCastOptionsProvider when the Cast SDK initializes. // prop; read by OmniCastOptionsProvider when the Cast SDK initializes.
var receiverApplicationId: String? = null var receiverApplicationId: String? = null
@@ -244,6 +244,7 @@ class OmniView(val context: ThemedReactContext) :
} }
override fun onIsPlayingChanged(isPlaying: Boolean) { override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) scheduleVideoRebuild()
updatePictureInPictureParams() updatePictureInPictureParams()
} }
@@ -377,22 +378,16 @@ class OmniView(val context: ThemedReactContext) :
movedSurfaceToRootForPip = false movedSurfaceToRootForPip = false
} }
// A surface that is destroyed and recreated while playback runs (the PIP private val rebuildRunnable = Runnable { boundPlayer?.rebuildVideoOutput() }
// 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 private fun scheduleVideoRebuild() {
// once the (PIP) window has settled at its final size — firing it mid-resize surfaceView.removeCallbacks(rebuildRunnable)
// leaves the vout stopped — so it is debounced off surfaceChanged. surfaceView.postDelayed(rebuildRunnable, 200)
private var pendingVideoRebuild = false
private val rebuildRunnable = Runnable {
if (!pendingVideoRebuild) return@Runnable
pendingVideoRebuild = false
boundPlayer?.rebuildVideoOutput()
} }
override fun surfaceCreated(holder: SurfaceHolder) { override fun surfaceCreated(holder: SurfaceHolder) {
surfaceReady = true surfaceReady = true
boundPlayer?.setVideoView(surfaceView) boundPlayer?.setVideoView(surfaceView)
if (boundPlayer?.localPlayer?.isPlaying == true) pendingVideoRebuild = true
updatePictureInPictureParams() updatePictureInPictureParams()
} }
@@ -403,10 +398,7 @@ class OmniView(val context: ThemedReactContext) :
height: Int height: Int
) { ) {
boundPlayer?.updateVideoLayout(width, height) boundPlayer?.updateVideoLayout(width, height)
if (pendingVideoRebuild) { scheduleVideoRebuild()
surfaceView.removeCallbacks(rebuildRunnable)
surfaceView.postDelayed(rebuildRunnable, 200)
}
} }
override fun surfaceDestroyed(holder: SurfaceHolder) { override fun surfaceDestroyed(holder: SurfaceHolder) {
@@ -46,6 +46,7 @@ import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.interfaces.IMedia.VideoTrack import org.videolan.libvlc.interfaces.IMedia.VideoTrack
import org.videolan.libvlc.interfaces.IVLCVout import org.videolan.libvlc.interfaces.IVLCVout
import java.security.MessageDigest
@SuppressLint("UnsafeOptInUsageError") @SuppressLint("UnsafeOptInUsageError")
class VlcPlayer(ctx: Context) : class VlcPlayer(ctx: Context) :
@@ -82,13 +83,15 @@ class VlcPlayer(ctx: Context) :
if (Looper.myLooper() == Looper.getMainLooper()) flush() else applicationHandler.post(flush) if (Looper.myLooper() == Looper.getMainLooper()) flush() else applicationHandler.post(flush)
} }
private val libVLC = LibVLC(ctx, arrayListOf( private val libVLC = LibVLC(
"--network-caching=1500", ctx, arrayListOf(
"--file-caching=1500", "--network-caching=1500",
"--no-stats", "--file-caching=1500",
"--no-osd", "--no-stats",
"--quiet" "--no-osd",
)) "--quiet"
)
)
private val player = MediaPlayer(libVLC) private val player = MediaPlayer(libVLC)
@@ -97,15 +100,28 @@ class VlcPlayer(ctx: Context) :
private var mediaItems: List<MediaItem> = emptyList() private var mediaItems: List<MediaItem> = emptyList()
private var currentMediaItemIndex: Int = INDEX_UNSET private var currentMediaItemIndex: Int = INDEX_UNSET
private var currentTrackSelectionParameters = TrackSelectionParameters.Builder().build() private var currentTrackSelectionParameters = TrackSelectionParameters.Builder().build()
@Volatile @Volatile
private var playerError: PlaybackException? = null private var playerError: PlaybackException? = null
@Volatile @Volatile
private var released = false private var released = false
private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY
private var userInitiatedTransition: Boolean = false private var userInitiatedTransition: Boolean = false
@Volatile @Volatile
private var cachedBufferedPosition: Long = 0L private var cachedBufferedPosition: Long = 0L
// vlc handles seeks on its input thread and keeps reporting the pre-seek time
// until it decoded the new position, so remember where we asked to go and
// report that instead
@Volatile
private var pendingSeekPosition: Long = TIME_UNSET
@Volatile
private var seekOriginPosition: Long = 0L
private var boundSurfaceView: SurfaceView? = null private var boundSurfaceView: SurfaceView? = null
private var videoOutputStale = false
private var lastVideoSize: VideoSize = VideoSize.UNKNOWN private var lastVideoSize: VideoSize = VideoSize.UNKNOWN
private val availableCommands: Player.Commands = Player.Commands.Builder() private val availableCommands: Player.Commands = Player.Commands.Builder()
@@ -176,6 +192,7 @@ class VlcPlayer(ctx: Context) :
MediaPlayer.Event.Stopped -> { MediaPlayer.Event.Stopped -> {
abandonAudioFocus() abandonAudioFocus()
pendingSeekPosition = TIME_UNSET
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(STATE_IDLE) it.onPlaybackStateChanged(STATE_IDLE)
} }
@@ -183,6 +200,7 @@ class VlcPlayer(ctx: Context) :
MediaPlayer.Event.EndReached -> { MediaPlayer.Event.EndReached -> {
abandonAudioFocus() abandonAudioFocus()
pendingSeekPosition = TIME_UNSET
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) { notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(STATE_ENDED) it.onPlaybackStateChanged(STATE_ENDED)
} }
@@ -214,7 +232,14 @@ class VlcPlayer(ctx: Context) :
// progress is polled instead of pushed dozens of times per seconds. // progress is polled instead of pushed dozens of times per seconds.
// this improves perf & battery life (native -> js bridge is expensive) // this improves perf & battery life (native -> js bridge is expensive)
MediaPlayer.Event.TimeChanged -> Unit MediaPlayer.Event.TimeChanged -> {
val pending = pendingSeekPosition
if (pending != TIME_UNSET &&
(Math.abs(event.timeChanged - seekOriginPosition) > 1_000 || Math.abs(event.timeChanged - pending) < 1_000)
) {
pendingSeekPosition = TIME_UNSET
}
}
// vlc has no video-size event, Vout event still has unknown size. // vlc has no video-size event, Vout event still has unknown size.
// This is the first callback with known size. // This is the first callback with known size.
@@ -292,7 +317,11 @@ class VlcPlayer(ctx: Context) :
audioManager.requestAudioFocus(request) audioManager.requestAudioFocus(request)
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
audioManager.requestAudioFocus(this, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) audioManager.requestAudioFocus(
this,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
} }
hasAudioFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED hasAudioFocus = result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED
return hasAudioFocus return hasAudioFocus
@@ -345,8 +374,10 @@ class VlcPlayer(ctx: Context) :
currentMediaItemIndex = targetIndex currentMediaItemIndex = targetIndex
playlistMetadata = mediaItems.getOrNull(targetIndex)?.mediaMetadata ?: MediaMetadata.EMPTY playlistMetadata = mediaItems.getOrNull(targetIndex)?.mediaMetadata ?: MediaMetadata.EMPTY
playerError = null playerError = null
pendingSeekPosition = TIME_UNSET
player.stop() player.stop()
videoOutputStale = false
mediaItems.getOrNull(targetIndex)?.let { item -> mediaItems.getOrNull(targetIndex)?.let { item ->
val uri = item.localConfiguration?.uri?.toString() val uri = item.localConfiguration?.uri?.toString()
@@ -375,7 +406,8 @@ class VlcPlayer(ctx: Context) :
EVENT_PLAYLIST_METADATA_CHANGED EVENT_PLAYLIST_METADATA_CHANGED
) )
if (prev != currentMediaItemIndex) events += EVENT_MEDIA_ITEM_TRANSITION if (prev != currentMediaItemIndex) events += EVENT_MEDIA_ITEM_TRANSITION
val reason = if (userInitiatedTransition) MEDIA_ITEM_TRANSITION_REASON_SEEK else MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED val reason =
if (userInitiatedTransition) MEDIA_ITEM_TRANSITION_REASON_SEEK else MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED
userInitiatedTransition = false userInitiatedTransition = false
notifyListeners(events.toTypedArray()) { notifyListeners(events.toTypedArray()) {
it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED) it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)
@@ -387,6 +419,20 @@ class VlcPlayer(ctx: Context) :
} }
} }
// vlc keys each slave's tracks by the md5 of its uri ("<md5(uri)>/spu/<n>")
private fun subtitleSlaveForTrackId(trackId: String): MediaItem.SubtitleConfiguration? {
val hash = trackId.substringBefore("/spu/", "")
if (hash.isEmpty()) return null
return mediaItems.getOrNull(currentMediaItemIndex)
?.localConfiguration?.subtitleConfigurations
?.firstOrNull { md5(it.uri.toString()) == hash }
}
private fun md5(value: String): String =
MessageDigest.getInstance("MD5")
.digest(value.toByteArray())
.joinToString("") { "%02x".format(it.toInt() and 0xFF) }
// vlc doesn't allow arbitrary headers :c // vlc doesn't allow arbitrary headers :c
private fun applyRequestHeaders(media: Media, extras: Bundle?) { private fun applyRequestHeaders(media: Media, extras: Bundle?) {
if (extras == null) return if (extras == null) return
@@ -415,6 +461,7 @@ class VlcPlayer(ctx: Context) :
mediaItems = emptyList() mediaItems = emptyList()
currentMediaItemIndex = INDEX_UNSET currentMediaItemIndex = INDEX_UNSET
playlistMetadata = MediaMetadata.EMPTY playlistMetadata = MediaMetadata.EMPTY
pendingSeekPosition = TIME_UNSET
player.stop() player.stop()
val events = mutableListOf( val events = mutableListOf(
@@ -480,7 +527,8 @@ class VlcPlayer(ctx: Context) :
seekCommand: Int, seekCommand: Int,
isRepeatingCurrentItem: Boolean isRepeatingCurrentItem: Boolean
) { ) {
val targetIndex = if (mediaItemIndex == INDEX_UNSET) currentMediaItemIndex else mediaItemIndex val targetIndex =
if (mediaItemIndex == INDEX_UNSET) currentMediaItemIndex else mediaItemIndex
if (targetIndex == INDEX_UNSET || targetIndex !in mediaItems.indices) return if (targetIndex == INDEX_UNSET || targetIndex !in mediaItems.indices) return
if (targetIndex != currentMediaItemIndex) { if (targetIndex != currentMediaItemIndex) {
@@ -492,6 +540,10 @@ class VlcPlayer(ctx: Context) :
val from = getCurrentPosition() val from = getCurrentPosition()
val target = positionMs.coerceAtLeast(0L).takeIf { it != TIME_UNSET } ?: 0L val target = positionMs.coerceAtLeast(0L).takeIf { it != TIME_UNSET } ?: 0L
if (player.isSeekable) {
seekOriginPosition = player.time.coerceAtLeast(0L)
pendingSeekPosition = target
}
player.time = target player.time = target
// since vlc reports progress events every few ms they don't have a seek finished event. // since vlc reports progress events every few ms they don't have a seek finished event.
notifyListeners(EVENT_POSITION_DISCONTINUITY) { notifyListeners(EVENT_POSITION_DISCONTINUITY) {
@@ -571,7 +623,10 @@ class VlcPlayer(ctx: Context) :
.setAverageBitrate(track.bitrate.takeIf { it > 0 } ?: Format.NO_VALUE) .setAverageBitrate(track.bitrate.takeIf { it > 0 } ?: Format.NO_VALUE)
.build() .build()
} }
val group = TrackGroup("vlc-video-${videoTracks.minOf { it.id }}", *videoFormats.toTypedArray()) val group = TrackGroup(
"vlc-video-${videoTracks.minOf { it.id }}",
*videoFormats.toTypedArray()
)
val selected = BooleanArray(videoFormats.size) { idx -> val selected = BooleanArray(videoFormats.size) { idx ->
selectedVideo != null && selectedVideo.id == videoTracks[idx].id selectedVideo != null && selectedVideo.id == videoTracks[idx].id
} }
@@ -590,7 +645,10 @@ class VlcPlayer(ctx: Context) :
.setSampleMimeType("audio/x-unknown") .setSampleMimeType("audio/x-unknown")
.build() .build()
} }
val group = TrackGroup("vlc-audio-${audioTracks.minOf { it.id }}", *audioFormats.toTypedArray()) val group = TrackGroup(
"vlc-audio-${audioTracks.minOf { it.id }}",
*audioFormats.toTypedArray()
)
val selected = BooleanArray(audioFormats.size) { idx -> val selected = BooleanArray(audioFormats.size) { idx ->
selectedAudio != null && selectedAudio.id == audioTracks[idx].id selectedAudio != null && selectedAudio.id == audioTracks[idx].id
} }
@@ -599,14 +657,17 @@ class VlcPlayer(ctx: Context) :
} }
player.getTracks(IMedia.Track.Type.Text)?.forEach { track -> player.getTracks(IMedia.Track.Type.Text)?.forEach { track ->
// external subs don't have label/language, match them back to surface them
val slave = subtitleSlaveForTrackId(track.id)
val format = Format.Builder() val format = Format.Builder()
.setId(track.id) .setId(track.id)
.setLabel(track.name) .setLabel(slave?.label ?: track.name)
.setLanguage(track.language) .setLanguage(slave?.language ?: track.language)
.setSampleMimeType("text/x-unknown") .setSampleMimeType("text/x-unknown")
.build() .build()
val group = TrackGroup("vlc-sub-${track.id}", format) val group = TrackGroup("vlc-sub-${track.id}", format)
val selected = booleanArrayOf(selectedSubtitle != null && selectedSubtitle.id == track.id) val selected =
booleanArrayOf(selectedSubtitle != null && selectedSubtitle.id == track.id)
val support = intArrayOf(FORMAT_HANDLED) val support = intArrayOf(FORMAT_HANDLED)
result.add(Group(group, false, support, selected)) result.add(Group(group, false, support, selected))
} }
@@ -644,10 +705,12 @@ class VlcPlayer(ctx: Context) :
player.setVideoTrackEnabled(true) player.setVideoTrackEnabled(true)
player.selectTrack(trackId) player.selectTrack(trackId)
} }
TRACK_TYPE_AUDIO -> { TRACK_TYPE_AUDIO -> {
hasAudioOverride = true hasAudioOverride = true
player.selectTrack(trackId) player.selectTrack(trackId)
} }
TRACK_TYPE_TEXT -> { TRACK_TYPE_TEXT -> {
hasTextOverride = true hasTextOverride = true
player.selectTrack(trackId) player.selectTrack(trackId)
@@ -692,7 +755,12 @@ class VlcPlayer(ctx: Context) :
val preferredLabels = labels.filter { it.isNotEmpty() } val preferredLabels = labels.filter { it.isNotEmpty() }
if (preferredLanguages.isEmpty() && preferredLabels.isEmpty()) return null if (preferredLanguages.isEmpty() && preferredLabels.isEmpty()) return null
data class Candidate(val id: String, val langIndex: Int, val labelIndex: Int, val hasDefaultFlag: Boolean) data class Candidate(
val id: String,
val langIndex: Int,
val labelIndex: Int,
val hasDefaultFlag: Boolean
)
val candidates = tracks.groups val candidates = tracks.groups
.filter { it.type == trackType } .filter { it.type == trackType }
@@ -742,7 +810,8 @@ class VlcPlayer(ctx: Context) :
// timelines are immutable // timelines are immutable
val items = mediaItems val items = mediaItems
val currentIndex = currentMediaItemIndex val currentIndex = currentMediaItemIndex
val currentDurationUs = duration.takeIf { it != TIME_UNSET }?.let { it * 1000L } ?: TIME_UNSET val currentDurationUs =
duration.takeIf { it != TIME_UNSET }?.let { it * 1000L } ?: TIME_UNSET
if (items.isEmpty()) return Timeline.EMPTY if (items.isEmpty()) return Timeline.EMPTY
return object : Timeline() { return object : Timeline() {
private fun durationUsForIndex(index: Int): Long = private fun durationUsForIndex(index: Int): Long =
@@ -797,7 +866,11 @@ class VlcPlayer(ctx: Context) :
override fun getDuration(): Long = player.length.takeIf { it > 0 } ?: TIME_UNSET override fun getDuration(): Long = player.length.takeIf { it > 0 } ?: TIME_UNSET
override fun getCurrentPosition() = player.time.coerceAtLeast(0L) override fun getCurrentPosition(): Long {
val pending = pendingSeekPosition
if (pending != TIME_UNSET) return pending
return player.time.coerceAtLeast(0L)
}
override fun getBufferedPosition(): Long { override fun getBufferedPosition(): Long {
val duration = getDuration() val duration = getDuration()
@@ -880,30 +953,22 @@ class VlcPlayer(ctx: Context) :
if (surfaceView == null) return clearVideoSurface() if (surfaceView == null) return clearVideoSurface()
if (vlcVout.areViewsAttached() && boundSurfaceView === surfaceView) return if (vlcVout.areViewsAttached() && boundSurfaceView === surfaceView) return
boundSurfaceView = surfaceView boundSurfaceView = surfaceView
// Hand VLC the SurfaceView (not a raw Surface) so it reads the real view videoOutputStale = (
// size and keeps the video layout correct as the surface resizes (e.g. the player.playerState == IMedia.State.Playing ||
// PIP window shrinking/growing); see updateVideoLayout for size updates. player.playerState == IMedia.State.Paused
)
vlcVout.setVideoView(surfaceView) vlcVout.setVideoView(surfaceView)
vlcVout.attachViews() 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() { fun rebuildVideoOutput() {
if (!vlcVout.areViewsAttached() || !player.isPlaying) return if (!videoOutputStale || !player.isPlaying || !vlcVout.areViewsAttached()) return
videoOutputStale = false
player.setVideoTrackEnabled(false) player.setVideoTrackEnabled(false)
player.setVideoTrackEnabled(true) player.setVideoTrackEnabled(true)
player.updateVideoSurfaces() 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) { fun updateVideoLayout(width: Int, height: Int) {
if (!vlcVout.areViewsAttached()) return if (!vlcVout.areViewsAttached()) return
if (width > 0 && height > 0) vlcVout.setWindowSize(width, height) if (width > 0 && height > 0) vlcVout.setWindowSize(width, height)
@@ -935,7 +1000,8 @@ class VlcPlayer(ctx: Context) :
} }
override fun getVideoSize(): VideoSize { override fun getVideoSize(): VideoSize {
val videoTrack = player.getSelectedTrack(IMedia.Track.Type.Video) as? VideoTrack ?: return VideoSize.UNKNOWN val videoTrack = player.getSelectedTrack(IMedia.Track.Type.Video) as? VideoTrack
?: return VideoSize.UNKNOWN
if (videoTrack.width <= 0 || videoTrack.height <= 0) return VideoSize.UNKNOWN if (videoTrack.width <= 0 || videoTrack.height <= 0) return VideoSize.UNKNOWN
val par = if (videoTrack.sarNum > 0 && videoTrack.sarDen > 0) { val par = if (videoTrack.sarNum > 0 && videoTrack.sarDen > 0) {
videoTrack.sarNum.toFloat() / videoTrack.sarDen.toFloat() videoTrack.sarNum.toFloat() / videoTrack.sarDen.toFloat()
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
+2 -14
View File
@@ -1,13 +1,7 @@
import { createPlayer } from "@videojs/react"; import { createPlayer } from "@videojs/react";
import { GoogleCast } from "@videojs/react/media/google-cast"; import { GoogleCast } from "@videojs/react/media/google-cast";
import { videoFeatures } from "@videojs/react/video"; import { videoFeatures } from "@videojs/react/video";
import { import { createContext, type ReactNode, useContext, useEffect } from "react";
createContext,
type ReactNode,
useContext,
useEffect,
useRef,
} from "react";
import { usePlayerState } from "./events"; import { usePlayerState } from "./events";
import { WebOmniPlayer } from "./player.web"; import { WebOmniPlayer } from "./player.web";
import type { OmniPlayer, PlayerBackend } from "./types/player"; import type { OmniPlayer, PlayerBackend } from "./types/player";
@@ -58,16 +52,10 @@ const PlayerInitializer = ({
}) => { }) => {
const store = VideoPlayer.usePlayer(); const store = VideoPlayer.usePlayer();
const player = useLazyRef(() => new WebOmniPlayer(store)); const player = useLazyRef(() => new WebOmniPlayer(store));
const seekedForSrc = useRef<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
player.source = source; player.source = source;
const uri = source?.src?.uri; }, [source]);
if (uri !== seekedForSrc.current) {
seekedForSrc.current = uri;
if (source?.startTime) store.seek(source.startTime);
}
}, [source, store]);
useEffect(() => { useEffect(() => {
player.showNotification = showNotification; player.showNotification = showNotification;
+11
View File
@@ -134,6 +134,17 @@ export const OmniView = ({
[], [],
); );
const player = usePlayer();
const uri = source?.src.uri;
const startTime = source?.startTime;
const prevUri = useRef<string | undefined>(undefined);
useEffect(() => {
if (!uri) return;
if (startTime) player.currentTime = startTime;
if (autoplay && prevUri.current && prevUri.current !== uri) player.play();
prevUri.current = uri;
}, [player, uri, startTime, autoplay]);
// While casting, the receiver renders subtitles (the player forwards the // While casting, the receiver renders subtitles (the player forwards the
// selection); skip rendering them locally on the idle <video>. // selection); skip rendering them locally on the idle <video>.
const castStatus = usePlayerState("castStatus"); const castStatus = usePlayerState("castStatus");