Compare commits

...
12 Commits
12 changed files with 376 additions and 130 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()
)
}
@@ -1,6 +1,8 @@
package dev.zoriya.omni
import android.annotation.SuppressLint
import android.content.Context
import android.os.Looper
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
@@ -13,20 +15,27 @@ import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.Renderer
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.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
@@ -38,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
@@ -62,9 +72,26 @@ class OmniPlayer(
AndroidBackend.EXOPLAYER -> {
val http = DefaultHttpDataSource.Factory().setAllowCrossProtocolRedirects(true)
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(
DefaultMediaSourceFactory(DefaultDataSource.Factory(ctx, http))
.experimentalParseSubtitlesDuringExtraction(false)
)
.build()
}
@@ -75,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 }
@@ -100,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
@@ -116,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
}
}
@@ -187,6 +241,19 @@ class OmniPlayer(
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(
src: com.margelo.nitro.omni.VideoSrc,
metadata: com.margelo.nitro.omni.Metadata?,
@@ -212,7 +279,10 @@ class OmniPlayer(
.setId(subtitle.id)
.setLanguage(subtitle.language)
.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()
})
.setRequestMetadata(
@@ -233,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)
@@ -258,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,
@@ -343,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()
}
@@ -377,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()
@@ -407,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) {
@@ -441,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()
@@ -523,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) {
@@ -46,6 +46,7 @@ import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.interfaces.IMedia.VideoTrack
import org.videolan.libvlc.interfaces.IVLCVout
import java.security.MessageDigest
@SuppressLint("UnsafeOptInUsageError")
class VlcPlayer(ctx: Context) :
@@ -82,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)
@@ -97,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()
@@ -176,6 +192,7 @@ class VlcPlayer(ctx: Context) :
MediaPlayer.Event.Stopped -> {
abandonAudioFocus()
pendingSeekPosition = TIME_UNSET
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(STATE_IDLE)
}
@@ -183,6 +200,7 @@ class VlcPlayer(ctx: Context) :
MediaPlayer.Event.EndReached -> {
abandonAudioFocus()
pendingSeekPosition = TIME_UNSET
notifyListeners(EVENT_PLAYBACK_STATE_CHANGED) {
it.onPlaybackStateChanged(STATE_ENDED)
}
@@ -214,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.
@@ -292,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
@@ -345,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()
@@ -375,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)
@@ -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
private fun applyRequestHeaders(media: Media, extras: Bundle?) {
if (extras == null) return
@@ -415,6 +461,7 @@ class VlcPlayer(ctx: Context) :
mediaItems = emptyList()
currentMediaItemIndex = INDEX_UNSET
playlistMetadata = MediaMetadata.EMPTY
pendingSeekPosition = TIME_UNSET
player.stop()
val events = mutableListOf(
@@ -480,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) {
@@ -492,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) {
@@ -571,7 +623,10 @@ class VlcPlayer(ctx: Context) :
.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
}
@@ -590,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
}
@@ -599,14 +657,17 @@ class VlcPlayer(ctx: Context) :
}
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()
.setId(track.id)
.setLabel(track.name)
.setLanguage(track.language)
.setLabel(slave?.label ?: track.name)
.setLanguage(slave?.language ?: track.language)
.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))
}
@@ -644,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)
@@ -692,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 }
@@ -742,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 =
@@ -797,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()
@@ -880,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)
@@ -935,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");