Compare commits

...
11 Commits
12 changed files with 316 additions and 127 deletions
+9 -10
View File
@@ -5,7 +5,7 @@ buildscript {
}
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
implementation project(":react-native-nitro-modules")
implementation("org.videolan.android:libvlc-all:4.0.0-eap24")
implementation("androidx.media3:media3-common:1.4.1")
implementation("androidx.media3:media3-session:1.4.1")
implementation("androidx.media3:media3-ui:1.4.1")
implementation 'androidx.media3:media3-exoplayer:1.10.0'
implementation("androidx.media3:media3-exoplayer-hls:1.10.0")
implementation("androidx.media3:media3-cast:1.10.0")
implementation("org.videolan.android:libvlc-all:4.0.0-eap29")
implementation("androidx.media3:media3-common:1.11.0")
implementation("androidx.media3:media3-session:1.11.0")
implementation("androidx.media3:media3-ui:1.11.0")
implementation 'androidx.media3:media3-exoplayer:1.11.0'
implementation("androidx.media3:media3-exoplayer-hls:1.11.0")
implementation("androidx.media3:media3-cast:1.11.0")
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()) {
@@ -5,12 +5,9 @@ import androidx.media3.common.C
import androidx.media3.common.C.TRACK_TYPE_AUDIO
import androidx.media3.common.C.TRACK_TYPE_TEXT
import androidx.media3.common.C.TRACK_TYPE_VIDEO
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.PlaybackParameters
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_ENDED
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.Tracks
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.CastStatus
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 sourceListeners = mutableSetOf<(Source?) -> Unit>()
private var lastMediaItemIndex = 0
private var lastRendition: Rendition? = null
private var lastRenditions: Array<Rendition>? = null
private var lastIsAutoQuality: Boolean? = null
@@ -73,7 +71,6 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
_player?.removeListener(this)
_player = value
value.addListener(this)
lastMediaItemIndex = value.currentMediaItemIndex
lastRendition = null
lastRenditions = null
lastIsAutoQuality = null
@@ -108,6 +105,8 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
onTracksChanged(value.currentTracks)
}
var remote: RemoteMediaClient? = null
fun emitCastStatus(status: CastStatus) {
castStatusListeners.forEach { it(status) }
}
@@ -116,6 +115,14 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
sourceListeners.forEach { it(source) }
}
fun emitPrev() {
onPrevListeners.forEach { it() }
}
fun emitNext() {
onNextListeners.forEach { it() }
}
private fun selectedTrack(trackType: Int): Track? {
val list = when (trackType) {
TRACK_TYPE_VIDEO -> tracks.videos
@@ -197,7 +204,13 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
override fun onPlaybackStateChanged(playbackState: Int) {
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_READY -> PlayerStatus.READYTOPLAY
STATE_ENDED -> {
@@ -278,20 +291,6 @@ class EventMap(private val tracks: TrackProvider) : HybridOmniEventMapSpec(), Pl
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(
oldPosition: 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 com.google.android.gms.cast.CastMediaControlIntent
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.SessionProvider
@@ -11,9 +12,15 @@ class OmniCastOptionsProvider : OptionsProvider {
val appId = OmniPlayer.receiverApplicationId
?: CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
val mediaOptions = CastMediaOptions.Builder()
.setMediaSessionEnabled(false)
.setNotificationOptions(null)
.build()
return CastOptions.Builder()
.setReceiverApplicationId(appId)
.setShowSystemOutputSwitcherOnCastIconClick(true)
.setCastMediaOptions(mediaOptions)
.build()
}
@@ -60,13 +60,18 @@ class OmniCastTrackSelector : CastTrackSelector() {
val overrideMatch = indices.firstOrNull { i ->
overrides.keys.any { it.id == groups[i].id }
}
val match = indices.firstOrNull { i ->
val matchesLabel = { i: Int ->
val name: String? = tracks[i].name
val language: String? = tracks[i].language
(labels.isNotEmpty() && name != null && name in labels) ||
(languages.isNotEmpty() && language != null &&
Util.normalizeLanguageCode(language) in languages)
labels.isNotEmpty() && name != null && name in labels
}
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 {
overrideMatch != null -> overrideMatch
match != null -> match
@@ -24,8 +24,15 @@ class OmniMediaItemConverter : MediaItemConverter {
builder.setMediaMetadata(
androidx.media3.common.MediaMetadata.Builder()
.setTitle(md.getString(CastMediaMetadata.KEY_TITLE))
.setArtist(md.getString(CastMediaMetadata.KEY_ARTIST))
.setAlbumTitle(md.getString(CastMediaMetadata.KEY_ALBUM_TITLE))
.setArtist(
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()
)
}
@@ -29,11 +29,13 @@ import com.margelo.nitro.NitroModules
import com.margelo.nitro.omni.AndroidBackend
import com.margelo.nitro.omni.CastStatus
import com.margelo.nitro.omni.HybridOmniPlayerSpec
import com.margelo.nitro.omni.Metadata
import com.margelo.nitro.omni.MixAudioMode
import com.margelo.nitro.omni.PlayerStatus
import com.margelo.nitro.omni.Rendition
import com.margelo.nitro.omni.Source
import com.margelo.nitro.omni.Track
import com.margelo.nitro.omni.VideoSrc
import androidx.core.net.toUri
import androidx.media3.common.MediaItem.RequestMetadata
import androidx.media3.common.MediaItem.SubtitleConfiguration
@@ -45,6 +47,7 @@ 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.MediaStatus
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.cast.framework.CastState
import com.google.android.gms.cast.framework.CastStateListener
@@ -99,8 +102,29 @@ class OmniPlayer(
override val eventMap = EventMap(this)
private var castContext: CastContext? = null
private val castStateListener =
CastStateListener { eventMap.emitCastStatus(computeCastStatus()) }
private val castStateListener = CastStateListener {
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 {
castOptions?.receiverApplicationId?.let { receiverApplicationId = it }
@@ -124,7 +148,13 @@ class OmniPlayer(
.setRemotePlayer(remote)
.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
@@ -140,19 +170,19 @@ class OmniPlayer(
when {
shouldShow && !serviceRunning -> {
val otherIsPlaying = notificationPlayer?.let { other ->
other !== localPlayer && runOnMainThreadSync { other.isPlaying }
other !== player && runOnMainThreadSync { other.isPlaying }
} == true
if (otherIsPlaying) {
throw Error("Two players can't display notifications at the same time.")
}
notificationPlayer = localPlayer
notificationPlayer = player
ctx.startForegroundService(Intent(ctx, OmniPlayerService::class.java))
serviceRunning = true
}
!shouldShow && serviceRunning -> {
ctx.stopService(Intent(ctx, OmniPlayerService::class.java))
if (notificationPlayer == localPlayer) notificationPlayer = null
if (notificationPlayer == player) notificationPlayer = null
serviceRunning = false
}
}
@@ -273,6 +303,33 @@ class OmniPlayer(
.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) {
runOnMainThread {
localPlayer.setVideoSurfaceView(surfaceView)
@@ -298,8 +355,8 @@ class OmniPlayer(
}
}
override val hasPrev: Boolean get() = player.hasPreviousMediaItem()
override val hasNext: Boolean get() = player.hasNextMediaItem()
override val hasPrev: Boolean get() = source?.metadata?.hasPrev == true
override val hasNext: Boolean get() = source?.metadata?.hasNext == true
override val status by mainThreadProperty {
when (player.playbackState) {
Player.STATE_IDLE,
@@ -383,15 +440,39 @@ class OmniPlayer(
}
}
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) {
field = value
_source = value
eventMap.emitSource(value)
if (value == null) {
ownItem = null
runOnMainThreadSync {
player.clearMediaItems()
}
@@ -417,15 +498,11 @@ class OmniPlayer(
value.castId,
value.castData,
)
val mediaItems = mutableListOf<MediaItem>()
if (value.metadata?.hasPrev == true) mediaItems.add(currentItem)
mediaItems.add(currentItem)
if (value.metadata?.hasNext == true) mediaItems.add(currentItem)
ownItem = currentItem
runOnMainThreadSync {
val startIndex = if (value.metadata?.hasPrev == true) 1 else 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()
}
syncNotificationService()
@@ -447,11 +524,11 @@ class OmniPlayer(
}
override fun playPrev() {
runOnMainThreadSync { player.seekToPreviousMediaItem() }
runOnMainThreadSync { eventMap.emitPrev() }
}
override fun playNext() {
runOnMainThreadSync { player.seekToNextMediaItem() }
runOnMainThreadSync { eventMap.emitNext() }
}
override fun selectVideo(video: Track) {
@@ -481,15 +558,15 @@ class OmniPlayer(
}?.mediaTrackGroup
if (group != null) {
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 {
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)
}
params.setPreferredTextLanguage(track.language)
.setPreferredTextLabels(*labels)
}
}
player.trackSelectionParameters = params.build()
@@ -563,6 +640,10 @@ class OmniPlayer(
const val CAST_ID_EXTRA = "dev.zoriya.omni.castId"
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`
// prop; read by OmniCastOptionsProvider when the Cast SDK initializes.
var receiverApplicationId: String? = null
@@ -244,6 +244,7 @@ class OmniView(val context: ThemedReactContext) :
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
if (isPlaying) scheduleVideoRebuild()
updatePictureInPictureParams()
}
@@ -377,22 +378,16 @@ 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()
private val rebuildRunnable = Runnable { boundPlayer?.rebuildVideoOutput() }
private fun scheduleVideoRebuild() {
surfaceView.removeCallbacks(rebuildRunnable)
surfaceView.postDelayed(rebuildRunnable, 200)
}
override fun surfaceCreated(holder: SurfaceHolder) {
surfaceReady = true
boundPlayer?.setVideoView(surfaceView)
if (boundPlayer?.localPlayer?.isPlaying == true) pendingVideoRebuild = true
updatePictureInPictureParams()
}
@@ -403,10 +398,7 @@ class OmniView(val context: ThemedReactContext) :
height: Int
) {
boundPlayer?.updateVideoLayout(width, height)
if (pendingVideoRebuild) {
surfaceView.removeCallbacks(rebuildRunnable)
surfaceView.postDelayed(rebuildRunnable, 200)
}
scheduleVideoRebuild()
}
override fun surfaceDestroyed(holder: SurfaceHolder) {
@@ -83,13 +83,15 @@ class VlcPlayer(ctx: Context) :
if (Looper.myLooper() == Looper.getMainLooper()) flush() else applicationHandler.post(flush)
}
private val libVLC = LibVLC(ctx, arrayListOf(
"--network-caching=1500",
"--file-caching=1500",
"--no-stats",
"--no-osd",
"--quiet"
))
private val libVLC = LibVLC(
ctx, arrayListOf(
"--network-caching=1500",
"--file-caching=1500",
"--no-stats",
"--no-osd",
"--quiet"
)
)
private val player = MediaPlayer(libVLC)
@@ -98,15 +100,28 @@ 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
@Volatile
private var released = false
private var playlistMetadata: MediaMetadata = MediaMetadata.EMPTY
private var userInitiatedTransition: Boolean = false
@Volatile
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 videoOutputStale = false
private var lastVideoSize: VideoSize = VideoSize.UNKNOWN
private val availableCommands: Player.Commands = Player.Commands.Builder()
@@ -177,6 +192,7 @@ class VlcPlayer(ctx: Context) :
MediaPlayer.Event.Stopped -> {
abandonAudioFocus()
pendingSeekPosition = TIME_UNSET
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(STATE_IDLE)
}
@@ -184,6 +200,7 @@ class VlcPlayer(ctx: Context) :
MediaPlayer.Event.EndReached -> {
abandonAudioFocus()
pendingSeekPosition = TIME_UNSET
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(STATE_ENDED)
}
@@ -215,7 +232,14 @@ class VlcPlayer(ctx: Context) :
// progress is polled instead of pushed dozens of times per seconds.
// 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.
// This is the first callback with known size.
@@ -293,7 +317,11 @@ class VlcPlayer(ctx: Context) :
audioManager.requestAudioFocus(request)
} else {
@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
return hasAudioFocus
@@ -346,8 +374,10 @@ class VlcPlayer(ctx: Context) :
currentMediaItemIndex = targetIndex
playlistMetadata = mediaItems.getOrNull(targetIndex)?.mediaMetadata ?: MediaMetadata.EMPTY
playerError = null
pendingSeekPosition = TIME_UNSET
player.stop()
videoOutputStale = false
mediaItems.getOrNull(targetIndex)?.let { item ->
val uri = item.localConfiguration?.uri?.toString()
@@ -376,7 +406,8 @@ class VlcPlayer(ctx: Context) :
EVENT_PLAYLIST_METADATA_CHANGED
)
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
notifyListeners(events.toTypedArray()) {
it.onTimelineChanged(currentTimeline, TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED)
@@ -430,6 +461,7 @@ class VlcPlayer(ctx: Context) :
mediaItems = emptyList()
currentMediaItemIndex = INDEX_UNSET
playlistMetadata = MediaMetadata.EMPTY
pendingSeekPosition = TIME_UNSET
player.stop()
val events = mutableListOf(
@@ -495,7 +527,8 @@ class VlcPlayer(ctx: Context) :
seekCommand: Int,
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 != currentMediaItemIndex) {
@@ -507,6 +540,10 @@ class VlcPlayer(ctx: Context) :
val from = getCurrentPosition()
val target = positionMs.coerceAtLeast(0L).takeIf { it != TIME_UNSET } ?: 0L
if (player.isSeekable) {
seekOriginPosition = player.time.coerceAtLeast(0L)
pendingSeekPosition = target
}
player.time = target
// since vlc reports progress events every few ms they don't have a seek finished event.
notifyListeners(EVENT_POSITION_DISCONTINUITY) {
@@ -579,14 +616,17 @@ class VlcPlayer(ctx: Context) :
.setWidth(videoTrack?.width?.takeIf { it > 0 } ?: Format.NO_VALUE)
.setHeight(videoTrack?.height?.takeIf { it > 0 } ?: Format.NO_VALUE)
.setFrameRate(
videoTrack?.takeIf { it.frameRateDen > 0 }
videoTrack?.takeIf { it.frameRateDen > 0 && it.frameRateNum > 0 }
?.let { it.frameRateNum.toFloat() / it.frameRateDen }
?: Format.NO_VALUE.toFloat()
)
.setAverageBitrate(track.bitrate.takeIf { it > 0 } ?: Format.NO_VALUE)
.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 ->
selectedVideo != null && selectedVideo.id == videoTracks[idx].id
}
@@ -605,7 +645,10 @@ class VlcPlayer(ctx: Context) :
.setSampleMimeType("audio/x-unknown")
.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 ->
selectedAudio != null && selectedAudio.id == audioTracks[idx].id
}
@@ -623,7 +666,8 @@ class VlcPlayer(ctx: Context) :
.setSampleMimeType("text/x-unknown")
.build()
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)
result.add(Group(group, false, support, selected))
}
@@ -661,10 +705,12 @@ class VlcPlayer(ctx: Context) :
player.setVideoTrackEnabled(true)
player.selectTrack(trackId)
}
TRACK_TYPE_AUDIO -> {
hasAudioOverride = true
player.selectTrack(trackId)
}
TRACK_TYPE_TEXT -> {
hasTextOverride = true
player.selectTrack(trackId)
@@ -709,7 +755,12 @@ class VlcPlayer(ctx: Context) :
val preferredLabels = labels.filter { it.isNotEmpty() }
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
.filter { it.type == trackType }
@@ -759,7 +810,8 @@ class VlcPlayer(ctx: Context) :
// timelines are immutable
val items = mediaItems
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
return object : Timeline() {
private fun durationUsForIndex(index: Int): Long =
@@ -814,7 +866,11 @@ class VlcPlayer(ctx: Context) :
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 {
val duration = getDuration()
@@ -897,30 +953,22 @@ class VlcPlayer(ctx: Context) :
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.
videoOutputStale = (
player.playerState == IMedia.State.Playing ||
player.playerState == IMedia.State.Paused
)
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
if (!videoOutputStale || !player.isPlaying || !vlcVout.areViewsAttached()) return
videoOutputStale = false
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)
@@ -952,7 +1000,8 @@ class VlcPlayer(ctx: Context) :
}
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
val par = if (videoTrack.sarNum > 0 && videoTrack.sarDen > 0) {
videoTrack.sarNum.toFloat() / videoTrack.sarDen.toFloat()
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
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
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+2 -14
View File
@@ -1,13 +1,7 @@
import { createPlayer } from "@videojs/react";
import { GoogleCast } from "@videojs/react/media/google-cast";
import { videoFeatures } from "@videojs/react/video";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useRef,
} from "react";
import { createContext, type ReactNode, useContext, useEffect } from "react";
import { usePlayerState } from "./events";
import { WebOmniPlayer } from "./player.web";
import type { OmniPlayer, PlayerBackend } from "./types/player";
@@ -58,16 +52,10 @@ const PlayerInitializer = ({
}) => {
const store = VideoPlayer.usePlayer();
const player = useLazyRef(() => new WebOmniPlayer(store));
const seekedForSrc = useRef<string | undefined>(undefined);
useEffect(() => {
player.source = source;
const uri = source?.src?.uri;
if (uri !== seekedForSrc.current) {
seekedForSrc.current = uri;
if (source?.startTime) store.seek(source.startTime);
}
}, [source, store]);
}, [source]);
useEffect(() => {
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
// selection); skip rendering them locally on the idle <video>.
const castStatus = usePlayerState("castStatus");