NuvioStreaming/patches/react-native-video+6.19.0.patch

1210 lines
55 KiB
Diff

diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/Source.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/Source.kt
index e9cf5ab..2f0f9f4 100644
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/Source.kt
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/Source.kt
@@ -29,6 +29,9 @@ class Source {
/** Parsed value of source to playback */
var uri: Uri? = null
+ /** Optional sidecar audio source merged with the main video source */
+ var audioUri: Uri? = null
+
/** True if source is a local JS asset */
var isLocalAssetFile: Boolean = false
@@ -89,13 +92,14 @@ class Source {
*/
var sideLoadedTextTracks: SideLoadedTextTrackList? = null
- override fun hashCode(): Int = Objects.hash(uriString, uri, startPositionMs, cropStartMs, cropEndMs, extension, metadata, headers, adsProps)
+ override fun hashCode(): Int = Objects.hash(uriString, uri, audioUri, startPositionMs, cropStartMs, cropEndMs, extension, metadata, headers, adsProps)
/** return true if this and src are equals */
override fun equals(other: Any?): Boolean {
if (other == null || other !is Source) return false
return (
uri == other.uri &&
+ audioUri == other.audioUri &&
cropStartMs == other.cropStartMs &&
cropEndMs == other.cropEndMs &&
startPositionMs == other.startPositionMs &&
@@ -164,6 +168,7 @@ class Source {
companion object {
private const val TAG = "Source"
private const val PROP_SRC_URI = "uri"
+ private const val PROP_SRC_AUDIO_URI = "audioUri"
private const val PROP_SRC_IS_LOCAL_ASSET_FILE = "isLocalAssetFile"
private const val PROP_SRC_IS_ASSET = "isAsset"
private const val PROP_SRC_START_POSITION = "startPosition"
@@ -226,6 +231,15 @@ class Source {
source.uri = uri
}
+ safeGetString(src, PROP_SRC_AUDIO_URI, null)
+ ?.takeIf { it.isNotBlank() }
+ ?.let { audioUriString ->
+ val audioUri = Uri.parse(audioUriString)
+ if (isValidScheme(audioUri.scheme)) {
+ source.audioUri = audioUri
+ }
+ }
+
source.isLocalAssetFile = safeGetBool(src, PROP_SRC_IS_LOCAL_ASSET_FILE, false)
source.isAsset = safeGetBool(src, PROP_SRC_IS_ASSET, false)
source.startPositionMs = safeGetInt(src, PROP_SRC_START_POSITION, -1)
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
index 1ac0fd0..953eb59 100644
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt
@@ -1,5 +1,6 @@
package com.brentvatne.common.api
+import android.graphics.Color
import com.brentvatne.common.toolbox.ReactBridgeUtils
import com.facebook.react.bridge.ReadableMap
@@ -22,6 +23,17 @@ class SubtitleStyle public constructor() {
var subtitlesFollowVideo = true
private set
+ // Extended styling (used by ExoPlayerView via Media3 SubtitleView)
+ // Stored as Android color ints to avoid parsing multiple times.
+ var textColor: Int? = null
+ private set
+ var backgroundColor: Int? = null
+ private set
+ var edgeType: String? = null
+ private set
+ var edgeColor: Int? = null
+ private set
+
companion object {
private const val PROP_FONT_SIZE_TRACK = "fontSize"
private const val PROP_PADDING_BOTTOM = "paddingBottom"
@@ -31,6 +43,21 @@ class SubtitleStyle public constructor() {
private const val PROP_OPACITY = "opacity"
private const val PROP_SUBTITLES_FOLLOW_VIDEO = "subtitlesFollowVideo"
+ // Extended props (optional)
+ private const val PROP_TEXT_COLOR = "textColor"
+ private const val PROP_BACKGROUND_COLOR = "backgroundColor"
+ private const val PROP_EDGE_TYPE = "edgeType"
+ private const val PROP_EDGE_COLOR = "edgeColor"
+
+ private fun parseColorOrNull(value: String?): Int? {
+ if (value.isNullOrBlank()) return null
+ return try {
+ Color.parseColor(value)
+ } catch (_: IllegalArgumentException) {
+ null
+ }
+ }
+
@JvmStatic
fun parse(src: ReadableMap?): SubtitleStyle {
val subtitleStyle = SubtitleStyle()
@@ -41,6 +68,13 @@ class SubtitleStyle public constructor() {
subtitleStyle.paddingRight = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_RIGHT, 0)
subtitleStyle.opacity = ReactBridgeUtils.safeGetFloat(src, PROP_OPACITY, 1f)
subtitleStyle.subtitlesFollowVideo = ReactBridgeUtils.safeGetBool(src, PROP_SUBTITLES_FOLLOW_VIDEO, true)
+
+ // Extended styling
+ subtitleStyle.textColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_TEXT_COLOR, null))
+ subtitleStyle.backgroundColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_BACKGROUND_COLOR, null))
+ subtitleStyle.edgeType = ReactBridgeUtils.safeGetString(src, PROP_EDGE_TYPE, null)
+ subtitleStyle.edgeColor = parseColorOrNull(ReactBridgeUtils.safeGetString(src, PROP_EDGE_COLOR, null))
+
return subtitleStyle
}
}
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/DataSourceUtil.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/DataSourceUtil.kt
index 96a7887..d3e4a65 100644
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/DataSourceUtil.kt
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/DataSourceUtil.kt
@@ -48,6 +48,16 @@ object DataSourceUtil {
return defaultHttpDataSourceFactory as HttpDataSource.Factory
}
+ @JvmStatic
+ fun buildYoutubeChunkedDataSourceFactory(
+ context: ReactContext,
+ bandwidthMeter: DefaultBandwidthMeter?,
+ requestHeaders: Map<String, String>?
+ ): DataSource.Factory {
+ val upstreamFactory = buildDataSourceFactory(context, bandwidthMeter, requestHeaders)
+ return YoutubeChunkedDataSourceFactory(upstreamFactory)
+ }
+
private fun buildDataSourceFactory(
context: ReactContext,
bandwidthMeter: DefaultBandwidthMeter?,
@@ -67,8 +77,21 @@ object DataSourceUtil {
.setTransferListener(bandwidthMeter)
if (requestHeaders != null) {
- okHttpDataSourceFactory.setDefaultRequestProperties(requestHeaders)
- if (!requestHeaders.containsKey("User-Agent")) {
+ // IMPORTANT:
+ // If `Accept-Encoding` is explicitly set (e.g. to "gzip"), OkHttp will not
+ // transparently decompress the response body. This can cause ExoPlayer to
+ // receive gzipped playlist bytes and fail HLS parsing with:
+ // "Input does not start with the #EXTM3U header".
+ // Remove any user-supplied `Accept-Encoding` so OkHttp can manage it.
+ val sanitizedHeaders = HashMap<String, String>(requestHeaders.size)
+ for ((k, v) in requestHeaders) {
+ if (!k.equals("Accept-Encoding", ignoreCase = true)) {
+ sanitizedHeaders[k] = v
+ }
+ }
+
+ okHttpDataSourceFactory.setDefaultRequestProperties(sanitizedHeaders)
+ if (!sanitizedHeaders.containsKey("User-Agent")) {
okHttpDataSourceFactory.setUserAgent(getUserAgent(context))
}
} else {
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
index bb945fe..3055bf0 100644
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt
@@ -1,29 +1,42 @@
package com.brentvatne.exoplayer
+import android.os.Build
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.util.AttributeSet
+import android.view.LayoutInflater
+import android.view.SurfaceView
import android.view.View
import android.view.View.MeasureSpec
import android.widget.FrameLayout
import android.widget.TextView
import androidx.media3.common.Player
import androidx.media3.common.Timeline
+import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.AspectRatioFrameLayout
-import androidx.media3.ui.DefaultTimeBar
+import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.PlayerView
+import androidx.media3.ui.SubtitleView
import com.brentvatne.common.api.ResizeMode
import com.brentvatne.common.api.SubtitleStyle
+import com.brentvatne.common.api.ViewType
+import com.brentvatne.react.R
@UnstableApi
class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
private var localStyle = SubtitleStyle()
+ private var currentViewType = ViewType.VIEW_TYPE_SURFACE
private var pendingResizeMode: Int? = null
+ private var player: ExoPlayer? = null
+ private var showSubtitleButton = false
+ private var shutterColor = Color.TRANSPARENT
+ private var controllerVisibilityListener: PlayerView.ControllerVisibilityListener? = null
+ private var fullscreenButtonClickListener: PlayerView.FullscreenButtonClickListener? = null
private val liveBadge: TextView = TextView(context).apply {
text = "LIVE"
setTextColor(Color.WHITE)
@@ -36,20 +49,39 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
visibility = View.GONE
}
- private val playerView = PlayerView(context).apply {
+ private var playerView = createPlayerView(currentViewType)
+
+ /**
+ * Subtitles rendered in a full-size overlay (NOT inside PlayerView's content frame).
+ * This keeps subtitles anchored in-place even when the video surface/content frame moves
+ * due to aspect ratio / resizeMode changes.
+ *
+ * Controlled by SubtitleStyle.subtitlesFollowVideo.
+ */
+ private val overlaySubtitleView = SubtitleView(context).apply {
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
- setShutterBackgroundColor(Color.TRANSPARENT)
- useController = true
- controllerAutoShow = true
- controllerHideOnTouch = true
- controllerShowTimeoutMs = 5000
- // Don't show subtitle button by default - will be enabled when tracks are available
- setShowSubtitleButton(false)
- // Enable proper surface view handling to prevent rendering issues
- setUseArtwork(false)
- setDefaultArtwork(null)
- // Ensure proper video scaling - start with FIT mode
- resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
+ visibility = View.GONE
+ // We control styling via SubtitleStyle; don't pull Android system caption defaults.
+ setApplyEmbeddedStyles(true)
+ setApplyEmbeddedFontSizes(true)
+ }
+
+ private fun updateSubtitleRenderingMode() {
+ val internalSubtitleView = playerView.subtitleView
+ val followVideo = localStyle.subtitlesFollowVideo
+ val shouldShow = localStyle.opacity != 0.0f
+
+ if (followVideo) {
+ internalSubtitleView?.visibility = if (shouldShow) View.VISIBLE else View.GONE
+ overlaySubtitleView.visibility = View.GONE
+ } else {
+ // Hard-disable PlayerView's internal subtitle view. PlayerView can recreate/toggle this view
+ // during resize/layout, so we re-assert this in multiple lifecycle points.
+ internalSubtitleView?.visibility = View.GONE
+ internalSubtitleView?.alpha = 0f
+ overlaySubtitleView.visibility = if (shouldShow) View.VISIBLE else View.GONE
+ overlaySubtitleView.alpha = 1f
+ }
}
init {
@@ -57,74 +89,192 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
val playerViewLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
addView(playerView, playerViewLayoutParams)
+ // Add overlay subtitles above PlayerView (so it doesn't move with video content frame)
+ val subtitleOverlayLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+ addView(overlaySubtitleView, subtitleOverlayLayoutParams)
+
// Add live badge with its own layout parameters
val liveBadgeLayoutParams = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
liveBadgeLayoutParams.setMargins(16, 16, 16, 16)
addView(liveBadge, liveBadgeLayoutParams)
+
+ // PlayerView may internally recreate its subtitle view during relayouts (e.g. resizeMode changes).
+ // Ensure our rendering mode is re-applied whenever PlayerView lays out.
+ playerView.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ updateSubtitleRenderingMode()
+ }
}
fun setPlayer(player: ExoPlayer?) {
- val currentPlayer = playerView.player
+ this.player?.removeListener(playerListener)
+ this.player = player
+ playerView.player = player
+ player?.addListener(playerListener)
+ }
- if (currentPlayer != null) {
- currentPlayer.removeListener(playerListener)
+ fun setResizeMode(@ResizeMode.Mode mode: Int) {
+ val resizeMode = when (mode) {
+ ResizeMode.RESIZE_MODE_FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
+ ResizeMode.RESIZE_MODE_FIXED_WIDTH -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
+ ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
+ ResizeMode.RESIZE_MODE_FILL -> AspectRatioFrameLayout.RESIZE_MODE_FILL
+ ResizeMode.RESIZE_MODE_CENTER_CROP -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
+ else -> AspectRatioFrameLayout.RESIZE_MODE_FIT
}
- playerView.player = player
-
- if (player != null) {
- player.addListener(playerListener)
+ playerView.resizeMode = resizeMode
+ pendingResizeMode = resizeMode
+ playerView.requestLayout()
+ requestLayout()
- // Apply pending resize mode if we have one
- pendingResizeMode?.let { resizeMode ->
- playerView.resizeMode = resizeMode
- }
- }
+ updateSubtitleRenderingMode()
+ applySubtitleStyle(localStyle)
}
fun getPlayerView(): PlayerView = playerView
- fun setResizeMode(@ResizeMode.Mode resizeMode: Int) {
- val targetResizeMode = when (resizeMode) {
- ResizeMode.RESIZE_MODE_FILL -> AspectRatioFrameLayout.RESIZE_MODE_FILL
- ResizeMode.RESIZE_MODE_CENTER_CROP -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM
- ResizeMode.RESIZE_MODE_FIT -> AspectRatioFrameLayout.RESIZE_MODE_FIT
- ResizeMode.RESIZE_MODE_FIXED_WIDTH -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH
- ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT
- else -> AspectRatioFrameLayout.RESIZE_MODE_FIT
+ fun isPlaying(): Boolean = playerView.player?.isPlaying == true
+
+ fun setControllerVisibilityListener(listener: PlayerView.ControllerVisibilityListener?) {
+ controllerVisibilityListener = listener
+ playerView.setControllerVisibilityListener(listener)
+ }
+
+ fun setFullscreenButtonClickListener(listener: PlayerView.FullscreenButtonClickListener?) {
+ fullscreenButtonClickListener = listener
+ playerView.setFullscreenButtonClickListener(listener)
+ }
+
+ fun setShowSubtitleButton(show: Boolean) {
+ showSubtitleButton = show
+ playerView.setShowSubtitleButton(show)
+ }
+
+ fun setUseController(useController: Boolean) {
+ playerView.useController = useController
+ }
+
+ fun setControllerHideOnTouch(hideOnTouch: Boolean) {
+ playerView.setControllerHideOnTouch(hideOnTouch)
+ }
+
+ fun setControllerAutoShow(autoShow: Boolean) {
+ playerView.setControllerAutoShow(autoShow)
+ }
+
+ fun setControllerShowTimeoutMs(timeoutMs: Int) {
+ playerView.controllerShowTimeoutMs = timeoutMs
+ }
+
+ fun isControllerVisible(): Boolean = playerView.isControllerFullyVisible
+
+ fun hideController() {
+ playerView.hideController()
+ }
+
+ fun showController() {
+ playerView.showController()
+ }
+
+ fun updateSurfaceView(@ViewType.ViewType viewType: Int) {
+ if (currentViewType == viewType) {
+ return
}
- // Apply the resize mode to PlayerView immediately
- playerView.resizeMode = targetResizeMode
+ currentViewType = viewType
- // Store it for reapplication if needed
- pendingResizeMode = targetResizeMode
+ val previousPlayerView = playerView
+ val previousLayoutParams = previousPlayerView.layoutParams ?: LayoutParams(
+ LayoutParams.MATCH_PARENT,
+ LayoutParams.MATCH_PARENT
+ )
+ val previousResizeMode = previousPlayerView.resizeMode
+ val previousUseController = previousPlayerView.useController
+ val previousControllerAutoShow = previousPlayerView.controllerAutoShow
+ val previousControllerHideOnTouch = previousPlayerView.controllerHideOnTouch
+ val previousControllerShowTimeoutMs = previousPlayerView.controllerShowTimeoutMs
+
+ val replacementPlayerView = createPlayerView(viewType).apply {
+ layoutParams = previousLayoutParams
+ resizeMode = previousResizeMode
+ useController = previousUseController
+ controllerAutoShow = previousControllerAutoShow
+ controllerHideOnTouch = previousControllerHideOnTouch
+ controllerShowTimeoutMs = previousControllerShowTimeoutMs
+ setShowSubtitleButton(showSubtitleButton)
+ setControllerVisibilityListener(controllerVisibilityListener)
+ setFullscreenButtonClickListener(fullscreenButtonClickListener)
+ setShutterBackgroundColor(shutterColor)
+ player = this@ExoPlayerView.player
+ }
- // Force PlayerView to recalculate its layout
- playerView.requestLayout()
+ removeView(previousPlayerView)
+ playerView = replacementPlayerView
+ addView(playerView, 0, previousLayoutParams)
- // Also request layout on the parent to ensure proper sizing
- requestLayout()
+ updateSubtitleRenderingMode()
+ applySubtitleStyle(localStyle)
+ playerView.requestLayout()
}
fun setSubtitleStyle(style: SubtitleStyle) {
+ localStyle = style
+ applySubtitleStyle(localStyle)
+ }
+
+ private fun applySubtitleStyle(style: SubtitleStyle) {
+ updateSubtitleRenderingMode()
+
playerView.subtitleView?.let { subtitleView ->
- // Reset to defaults
- subtitleView.setUserDefaultStyle()
- subtitleView.setUserDefaultTextSize()
+ // Important:
+ // Avoid inheriting Android system caption settings via setUserDefaultStyle(),
+ // because those can force a background/window that the app doesn't want.
+ val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
+ val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
+ val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
+
+ val resolvedEdgeType = when (style.edgeType?.lowercase()) {
+ "outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
+ "shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
+ else -> CaptionStyleCompat.EDGE_TYPE_NONE
+ }
- // Apply custom styling
+ // windowColor MUST be transparent to avoid the "caption window" background.
+ val captionStyle = CaptionStyleCompat(
+ resolvedTextColor,
+ resolvedBackgroundColor,
+ Color.TRANSPARENT,
+ resolvedEdgeType,
+ resolvedEdgeColor,
+ null
+ )
+ subtitleView.setStyle(captionStyle)
+
+ // Text size: if not provided, fall back to user default size.
if (style.fontSize > 0) {
- subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, style.fontSize.toFloat())
+ // Use DIP so the value matches React Native's dp-based fontSize more closely.
+ // SP would multiply by system fontScale and makes "30" look larger than RN "30".
+ subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
+ } else {
+ subtitleView.setUserDefaultTextSize()
}
+ // Horizontal padding is still useful (safe area); vertical offset is handled via bottomPaddingFraction.
subtitleView.setPadding(
style.paddingLeft,
style.paddingTop,
style.paddingRight,
- style.paddingBottom
+ 0
)
+ // Bottom offset for *internal* subtitles:
+ // Use Media3 SubtitleView's bottomPaddingFraction (moves cues up) rather than raw view padding.
+ if (style.paddingBottom > 0 && playerView.height > 0) {
+ val fraction = (style.paddingBottom.toFloat() / playerView.height.toFloat())
+ .coerceIn(0f, 0.9f)
+ subtitleView.setBottomPaddingFraction(fraction)
+ }
+
if (style.opacity != 0.0f) {
subtitleView.alpha = style.opacity
subtitleView.visibility = android.view.View.VISIBLE
@@ -132,116 +282,94 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
subtitleView.visibility = android.view.View.GONE
}
}
- localStyle = style
- }
- fun setShutterColor(color: Int) {
- playerView.setShutterBackgroundColor(color)
- }
+ // Apply the same styling to the overlay subtitle view.
+ run {
+ val subtitleView = overlaySubtitleView
- fun updateSurfaceView(viewType: Int) {
- // TODO: Implement proper surface type switching if needed
- }
+ val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor
+ val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT
+ val resolvedEdgeColor = style.edgeColor ?: Color.BLACK
- val isPlaying: Boolean
- get() = playerView.player?.isPlaying ?: false
-
- fun invalidateAspectRatio() {
- // PlayerView handles aspect ratio automatically through its internal AspectRatioFrameLayout
- playerView.requestLayout()
-
- // Reapply the current resize mode to ensure it's properly set
- pendingResizeMode?.let { resizeMode ->
- playerView.resizeMode = resizeMode
- }
- }
-
- fun setUseController(useController: Boolean) {
- playerView.useController = useController
- if (useController) {
- // Ensure proper touch handling when controls are enabled
- playerView.controllerAutoShow = true
- playerView.controllerHideOnTouch = true
- // Show controls immediately when enabled
- playerView.showController()
- }
- }
-
- fun showController() {
- playerView.showController()
- }
-
- fun hideController() {
- playerView.hideController()
- }
-
- fun setControllerShowTimeoutMs(showTimeoutMs: Int) {
- playerView.controllerShowTimeoutMs = showTimeoutMs
- }
-
- fun setControllerAutoShow(autoShow: Boolean) {
- playerView.controllerAutoShow = autoShow
- }
+ val resolvedEdgeType = when (style.edgeType?.lowercase()) {
+ "outline" -> CaptionStyleCompat.EDGE_TYPE_OUTLINE
+ "shadow" -> CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW
+ else -> CaptionStyleCompat.EDGE_TYPE_NONE
+ }
- fun setControllerHideOnTouch(hideOnTouch: Boolean) {
- playerView.controllerHideOnTouch = hideOnTouch
- }
+ val captionStyle = CaptionStyleCompat(
+ resolvedTextColor,
+ resolvedBackgroundColor,
+ Color.TRANSPARENT,
+ resolvedEdgeType,
+ resolvedEdgeColor,
+ null
+ )
+ subtitleView.setStyle(captionStyle)
- fun setFullscreenButtonClickListener(listener: PlayerView.FullscreenButtonClickListener?) {
- playerView.setFullscreenButtonClickListener(listener)
- }
+ if (style.fontSize > 0) {
+ // Use DIP so the value matches React Native's dp-based fontSize more closely.
+ subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_DIP, style.fontSize.toFloat())
+ } else {
+ subtitleView.setUserDefaultTextSize()
+ }
- fun setShowSubtitleButton(show: Boolean) {
- playerView.setShowSubtitleButton(show)
- }
+ subtitleView.setPadding(
+ style.paddingLeft,
+ style.paddingTop,
+ style.paddingRight,
+ 0
+ )
- fun isControllerVisible(): Boolean = playerView.isControllerFullyVisible
+ // Bottom offset relative to the full view height (stable even when video content frame moves).
+ val h = height.takeIf { it > 0 } ?: subtitleView.height
+ if (style.paddingBottom > 0 && h > 0) {
+ val fraction = (style.paddingBottom.toFloat() / h.toFloat())
+ .coerceIn(0f, 0.9f)
+ subtitleView.setBottomPaddingFraction(fraction)
+ } else {
+ subtitleView.setBottomPaddingFraction(0f)
+ }
- fun setControllerVisibilityListener(listener: PlayerView.ControllerVisibilityListener?) {
- playerView.setControllerVisibilityListener(listener)
+ if (style.opacity != 0.0f) {
+ subtitleView.alpha = style.opacity
+ }
+ }
}
- override fun addOnLayoutChangeListener(listener: View.OnLayoutChangeListener) {
- playerView.addOnLayoutChangeListener(listener)
+ fun setShutterColor(color: Int) {
+ shutterColor = color
+ playerView.setShutterBackgroundColor(color)
}
- override fun setFocusable(focusable: Boolean) {
- playerView.isFocusable = focusable
+ fun setShowLiveBadge(show: Boolean) {
+ liveBadge.visibility = if (show) View.VISIBLE else View.GONE
}
- private fun updateLiveUi() {
- val player = playerView.player ?: return
- val isLive = player.isCurrentMediaItemLive
- val seekable = player.isCurrentMediaItemSeekable
-
- // Show/hide badge
- liveBadge.visibility = if (isLive) View.VISIBLE else View.GONE
-
- // Disable/enable scrubbing based on seekable
- val timeBar = playerView.findViewById<DefaultTimeBar?>(androidx.media3.ui.R.id.exo_progress)
- timeBar?.isEnabled = !isLive || seekable
+ fun invalidateAspectRatio() {
+ pendingResizeMode?.let { resizeMode ->
+ playerView.resizeMode = resizeMode
+ }
+ playerView.requestLayout()
+ requestLayout()
}
private val playerListener = object : Player.Listener {
+ override fun onCues(cueGroup: CueGroup) {
+ updateSubtitleRenderingMode()
+ overlaySubtitleView.setCues(cueGroup.cues)
+ }
+
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
playerView.post {
playerView.requestLayout()
- // Reapply resize mode to ensure it's properly set after timeline changes
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
}
- updateLiveUi()
}
override fun onEvents(player: Player, events: Player.Events) {
- if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION) ||
- events.contains(Player.EVENT_IS_PLAYING_CHANGED)
- ) {
- updateLiveUi()
- }
-
- // Handle video size changes which affect aspect ratio
if (events.contains(Player.EVENT_VIDEO_SIZE_CHANGED)) {
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
@@ -252,16 +380,10 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
}
}
- companion object {
- private const val TAG = "ExoPlayerView"
- }
-
/**
- * React Native (Yoga) can sometimes defer layout passes that are required by
- * PlayerView for its child views (controller overlay, surface view, subtitle view, …).
- * This helper forces a second measure / layout after RN finishes, ensuring the
- * internal views receive the final size. The same approach is used in the v7
- * implementation (see VideoView.kt) and in React Native core (Toolbar example [link]).
+ * React Native (Yoga) can defer layout passes that PlayerView needs for its
+ * child views. This forces a second measure/layout after RN finishes, ensuring
+ * internal views receive the final size.
*/
private val layoutRunnable = Runnable {
measure(
@@ -273,17 +395,41 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute
override fun requestLayout() {
super.requestLayout()
- // Post a second layout pass so the ExoPlayer internal views get correct bounds.
post(layoutRunnable)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
-
if (changed) {
pendingResizeMode?.let { resizeMode ->
playerView.resizeMode = resizeMode
}
+ updateSubtitleRenderingMode()
+ applySubtitleStyle(localStyle)
+ }
+ }
+
+ private fun createPlayerView(@ViewType.ViewType viewType: Int): PlayerView {
+ val layoutRes = when (viewType) {
+ ViewType.VIEW_TYPE_TEXTURE -> R.layout.exo_player_view_texture
+ else -> R.layout.exo_player_view_surface
+ }
+
+ return (LayoutInflater.from(context).inflate(layoutRes, this, false) as PlayerView).apply {
+ layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
+ setShutterBackgroundColor(shutterColor)
+ useController = true
+ controllerAutoShow = true
+ controllerHideOnTouch = true
+ controllerShowTimeoutMs = 5000
+ setShowSubtitleButton(showSubtitleButton)
+ setUseArtwork(false)
+ setDefaultArtwork(null)
+ resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
+
+ if (viewType == ViewType.VIEW_TYPE_SURFACE_SECURE && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
+ (videoSurfaceView as? SurfaceView)?.setSecure(true)
+ }
}
}
}
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/FullScreenPlayerView.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/FullScreenPlayerView.kt
index b5d786b..3c7ed65 100644
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/FullScreenPlayerView.kt
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/FullScreenPlayerView.kt
@@ -50,7 +50,7 @@ class FullScreenPlayerView(
if (fullscreenVideoPlayer != null) {
val window = fullscreenVideoPlayer.window
if (window != null) {
- val isPlaying = fullscreenVideoPlayer.exoPlayerView.isPlaying
+ val isPlaying = fullscreenVideoPlayer.exoPlayerView.isPlaying()
if (isPlaying) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
} else {
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
index e16ac96..5da47d6 100644
--- a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java
@@ -385,7 +385,7 @@ public class ReactExoplayerView extends FrameLayout implements
Activity activity = themedReactContext.getCurrentActivity();
boolean isInPictureInPicture = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInPictureInPictureMode();
boolean isInMultiWindowMode = Util.SDK_INT >= Build.VERSION_CODES.N && activity != null && activity.isInMultiWindowMode();
- if (playInBackground || isInPictureInPicture || isInMultiWindowMode) {
+ if (playInBackground || isInPictureInPicture || isInMultiWindowMode || enterPictureInPictureOnLeave) {
return;
}
setPlayWhenReady(false);
@@ -864,8 +864,19 @@ public class ReactExoplayerView extends FrameLayout implements
drmSessionManager,
runningSource.getCropStartMs(),
runningSource.getCropEndMs());
- MediaSource mediaSourceWithAds = initializeAds(videoSource, runningSource);
- MediaSource mediaSource = Objects.requireNonNullElse(mediaSourceWithAds, videoSource);
+ MediaSource mergedSource = videoSource;
+ if (runningSource.getAudioUri() != null) {
+ MediaSource audioSource = buildMediaSource(
+ runningSource.getAudioUri(),
+ null,
+ null,
+ -1,
+ -1
+ );
+ mergedSource = new MergingMediaSource(true, videoSource, audioSource);
+ }
+ MediaSource mediaSourceWithAds = initializeAds(mergedSource, runningSource);
+ MediaSource mediaSource = Objects.requireNonNullElse(mediaSourceWithAds, mergedSource);
// wait for player to be set
while (player == null) {
@@ -1100,8 +1111,17 @@ public class ReactExoplayerView extends FrameLayout implements
}
} else if ("file".equals(uri.getScheme()) ||
!useCache) {
+ DataSource.Factory progressiveDataSourceFactory = mediaDataSourceFactory;
+ String host = uri.getHost();
+ if (host != null && host.contains("googlevideo.com")) {
+ progressiveDataSourceFactory = DataSourceUtil.buildYoutubeChunkedDataSourceFactory(
+ themedReactContext,
+ bandwidthMeter,
+ source.getHeaders()
+ );
+ }
mediaSourceFactory = new ProgressiveMediaSource.Factory(
- mediaDataSourceFactory
+ progressiveDataSourceFactory
);
} else {
mediaSourceFactory = new ProgressiveMediaSource.Factory(
@@ -1567,6 +1587,11 @@ public class ReactExoplayerView extends FrameLayout implements
Track audioTrack = exoplayerTrackToGenericTrack(format, groupIndex, selection, group);
audioTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate);
audioTrack.setSelected(isSelected);
+ // Encode channel count into title so JS can read it e.g. "English|ch:6"
+ if (format.channelCount != Format.NO_VALUE && format.channelCount > 0) {
+ String existing = audioTrack.getTitle() != null ? audioTrack.getTitle() : "";
+ audioTrack.setTitle(existing + "|ch:" + format.channelCount);
+ }
audioTracks.add(audioTrack);
}
@@ -1753,7 +1778,11 @@ public class ReactExoplayerView extends FrameLayout implements
Track track = new Track();
track.setIndex(groupIndex);
track.setLanguage(format.language != null ? format.language : "unknown");
- track.setTitle(format.label != null ? format.label : "Track " + (groupIndex + 1));
+ String baseTitle = format.label != null ? format.label : "";
+ if (format.channelCount != Format.NO_VALUE && format.channelCount > 0) {
+ baseTitle = baseTitle + "|ch:" + format.channelCount;
+ }
+ track.setTitle(baseTitle);
track.setSelected(false); // Don't report selection status - let PlayerView handle it
if (format.sampleMimeType != null) track.setMimeType(format.sampleMimeType);
track.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate);
@@ -2127,7 +2156,8 @@ public class ReactExoplayerView extends FrameLayout implements
}
private void selectTextTrackInternal(String type, String value) {
- if (player == null || trackSelector == null) return;
+ if (player == null || trackSelector == null)
+ return;
DebugLog.d(TAG, "selectTextTrackInternal: type=" + type + ", value=" + value);
@@ -2146,6 +2176,10 @@ public class ReactExoplayerView extends FrameLayout implements
if (textRendererIndex != C.INDEX_UNSET) {
TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
boolean trackFound = false;
+ // react-native-video uses a flattened `textTracks` list on the JS side.
+ // For HLS/DASH, each TrackGroup often contains a single track at index 0,
+ // so comparing against `trackIndex` alone makes only the first subtitle selectable.
+ int flattenedIndex = 0;
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
TrackGroup group = groups.get(groupIndex);
@@ -2159,11 +2193,13 @@ public class ReactExoplayerView extends FrameLayout implements
isMatch = true;
} else if ("index".equals(type)) {
int targetIndex = ReactBridgeUtils.safeParseInt(value, -1);
- if (targetIndex == trackIndex) {
+ if (targetIndex == flattenedIndex) {
isMatch = true;
}
}
+ flattenedIndex++;
+
if (isMatch) {
TrackSelectionOverride override = new TrackSelectionOverride(group,
java.util.Arrays.asList(trackIndex));
diff --git a/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/YoutubeChunkedDataSourceFactory.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/YoutubeChunkedDataSourceFactory.kt
new file mode 100644
index 0000000..c5f5152
--- /dev/null
+++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/YoutubeChunkedDataSourceFactory.kt
@@ -0,0 +1,135 @@
+package com.brentvatne.exoplayer
+
+import android.net.Uri
+import android.util.Log
+import androidx.media3.common.C
+import androidx.media3.common.util.UnstableApi
+import androidx.media3.datasource.DataSource
+import androidx.media3.datasource.DataSpec
+import androidx.media3.datasource.TransferListener
+
+/**
+ * Wraps the normal HTTP data source and splits YouTube CDN reads into
+ * `range=start-end` query requests. This avoids long-lived full-stream requests
+ * that googlevideo.com can throttle or terminate mid-playback.
+ */
+@UnstableApi
+class YoutubeChunkedDataSourceFactory(
+ private val upstreamFactory: DataSource.Factory,
+ private val chunkSizeBytes: Long = CHUNK_SIZE,
+) : DataSource.Factory {
+
+ companion object {
+ private const val TAG = "YTChunkedDS"
+ private const val CHUNK_SIZE = 10L * 1024 * 1024
+ }
+
+ override fun createDataSource(): DataSource =
+ YoutubeChunkedDataSource(upstreamFactory.createDataSource(), chunkSizeBytes)
+
+ private class YoutubeChunkedDataSource(
+ private val upstream: DataSource,
+ private val chunkSize: Long,
+ ) : DataSource {
+ private var currentUri: Uri? = null
+ private var isYouTubeStream = false
+ private var currentChunkStart = 0L
+ private var currentChunkEnd = 0L
+ private var bytesReadInChunk = 0L
+ private var originalDataSpec: DataSpec? = null
+ private var remainingLength = C.LENGTH_UNSET.toLong()
+
+ override fun addTransferListener(transferListener: TransferListener) {
+ upstream.addTransferListener(transferListener)
+ }
+
+ override fun open(dataSpec: DataSpec): Long {
+ val uri = dataSpec.uri
+ val host = uri.host.orEmpty()
+ isYouTubeStream = host.contains("googlevideo.com")
+
+ if (!isYouTubeStream) {
+ currentUri = uri
+ return upstream.open(dataSpec)
+ }
+
+ originalDataSpec = dataSpec
+ currentChunkStart = dataSpec.position
+ remainingLength = dataSpec.length
+ return openNextChunk()
+ }
+
+ private fun openNextChunk(): Long {
+ val spec = originalDataSpec ?: throw IllegalStateException("No DataSpec")
+ val hasKnownLength = remainingLength != C.LENGTH_UNSET.toLong()
+ currentChunkEnd = if (hasKnownLength) {
+ minOf(currentChunkStart + chunkSize - 1, currentChunkStart + remainingLength - 1)
+ } else {
+ currentChunkStart + chunkSize - 1
+ }
+
+ val rangedUri = spec.uri.buildUpon()
+ .appendQueryParameter("range", "$currentChunkStart-$currentChunkEnd")
+ .build()
+
+ currentUri = rangedUri
+
+ val chunkedSpec = spec.buildUpon()
+ .setUri(rangedUri)
+ .setPosition(0)
+ .setLength(C.LENGTH_UNSET.toLong())
+ .build()
+
+ bytesReadInChunk = 0L
+ upstream.open(chunkedSpec)
+ return if (hasKnownLength) remainingLength else C.LENGTH_UNSET.toLong()
+ }
+
+ override fun read(buffer: ByteArray, offset: Int, length: Int): Int {
+ if (!isYouTubeStream) {
+ return upstream.read(buffer, offset, length)
+ }
+
+ val bytesRead = upstream.read(buffer, offset, length)
+ if (bytesRead == C.RESULT_END_OF_INPUT) {
+ val chunkBytesReceived = bytesReadInChunk
+ upstream.close()
+
+ if (chunkBytesReceived < (currentChunkEnd - currentChunkStart + 1)) {
+ return C.RESULT_END_OF_INPUT
+ }
+
+ currentChunkStart += chunkBytesReceived
+ if (remainingLength != C.LENGTH_UNSET.toLong()) {
+ remainingLength -= chunkBytesReceived
+ if (remainingLength <= 0L) {
+ return C.RESULT_END_OF_INPUT
+ }
+ }
+
+ return try {
+ openNextChunk()
+ upstream.read(buffer, offset, length)
+ } catch (error: Exception) {
+ Log.w(TAG, "Failed to open next YouTube chunk at $currentChunkStart: ${error.message}")
+ C.RESULT_END_OF_INPUT
+ }
+ }
+
+ bytesReadInChunk += bytesRead
+ return bytesRead
+ }
+
+ override fun getUri(): Uri? = upstream.uri ?: currentUri
+
+ override fun close() {
+ upstream.close()
+ currentUri = null
+ originalDataSpec = null
+ remainingLength = C.LENGTH_UNSET.toLong()
+ bytesReadInChunk = 0L
+ currentChunkStart = 0L
+ currentChunkEnd = 0L
+ }
+ }
+}
diff --git a/node_modules/react-native-video/android/src/main/res/layout/exo_player_view_surface.xml b/node_modules/react-native-video/android/src/main/res/layout/exo_player_view_surface.xml
new file mode 100644
index 0000000..4ea3c30
--- /dev/null
+++ b/node_modules/react-native-video/android/src/main/res/layout/exo_player_view_surface.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.media3.ui.PlayerView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:surface_type="surface_view" />
diff --git a/node_modules/react-native-video/android/src/main/res/layout/exo_player_view_texture.xml b/node_modules/react-native-video/android/src/main/res/layout/exo_player_view_texture.xml
new file mode 100644
index 0000000..53c1909
--- /dev/null
+++ b/node_modules/react-native-video/android/src/main/res/layout/exo_player_view_texture.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.media3.ui.PlayerView xmlns:android="http://schemas.android.com/apk/res/android"
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ android:layout_width="match_parent"
+ android:layout_height="match_parent"
+ app:surface_type="texture_view" />
diff --git a/node_modules/react-native-video/ios/Video/DataStructures/VideoSource.swift b/node_modules/react-native-video/ios/Video/DataStructures/VideoSource.swift
index 0a5e890..9ec17bf 100644
--- a/node_modules/react-native-video/ios/Video/DataStructures/VideoSource.swift
+++ b/node_modules/react-native-video/ios/Video/DataStructures/VideoSource.swift
@@ -1,6 +1,7 @@
public struct VideoSource {
let type: String?
let uri: String?
+ let audioUri: String?
let isNetwork: Bool
let isAsset: Bool
let shouldCache: Bool
@@ -21,6 +22,7 @@ public struct VideoSource {
self.json = nil
self.type = nil
self.uri = nil
+ self.audioUri = nil
self.isNetwork = false
self.isAsset = false
self.shouldCache = false
@@ -36,6 +38,7 @@ public struct VideoSource {
self.json = json
self.type = json["type"] as? String
self.uri = json["uri"] as? String
+ self.audioUri = json["audioUri"] as? String
self.isNetwork = json["isNetwork"] as? Bool ?? false
self.isAsset = json["isAsset"] as? Bool ?? false
self.shouldCache = json["shouldCache"] as? Bool ?? false
diff --git a/node_modules/react-native-video/ios/Video/Features/RCTVideoUtils.swift b/node_modules/react-native-video/ios/Video/Features/RCTVideoUtils.swift
index 329b26f..bb86960 100644
--- a/node_modules/react-native-video/ios/Video/Features/RCTVideoUtils.swift
+++ b/node_modules/react-native-video/ios/Video/Features/RCTVideoUtils.swift
@@ -222,12 +222,18 @@ enum RCTVideoUtils {
}
static func generateMixComposition(_ asset: AVAsset) async -> AVMutableComposition {
- let videoTracks = await RCTVideoAssetsUtils.getTracks(asset: asset, withMediaType: .video)
let audioTracks = await RCTVideoAssetsUtils.getTracks(asset: asset, withMediaType: .audio)
+ let externalAudioTrack = audioTracks?.first
+ return await generateMixComposition(videoAsset: asset, externalAudioTrack: externalAudioTrack)
+ }
+
+ static func generateMixComposition(videoAsset: AVAsset, externalAudioTrack: AVAssetTrack?) async -> AVMutableComposition {
+ let videoTracks = await RCTVideoAssetsUtils.getTracks(asset: videoAsset, withMediaType: .video)
+ let audioTracks = await RCTVideoAssetsUtils.getTracks(asset: videoAsset, withMediaType: .audio)
let mixComposition = AVMutableComposition()
- if let videoAsset = videoTracks?.first, let audioAsset = audioTracks?.first {
+ if let videoAsset = videoTracks?.first {
let videoCompTrack: AVMutableCompositionTrack! = mixComposition.addMutableTrack(
withMediaType: AVMediaType.video,
preferredTrackID: kCMPersistentTrackID_Invalid
@@ -238,21 +244,38 @@ enum RCTVideoUtils {
at: .zero
)
- let audioCompTrack: AVMutableCompositionTrack! = mixComposition.addMutableTrack(
- withMediaType: AVMediaType.audio,
- preferredTrackID: kCMPersistentTrackID_Invalid
- )
+ if let audioAsset = externalAudioTrack ?? audioTracks?.first {
+ let audioCompTrack: AVMutableCompositionTrack! = mixComposition.addMutableTrack(
+ withMediaType: AVMediaType.audio,
+ preferredTrackID: kCMPersistentTrackID_Invalid
+ )
- try? audioCompTrack.insertTimeRange(
- CMTimeRangeMake(start: .zero, duration: audioAsset.timeRange.duration),
- of: audioAsset,
- at: .zero
- )
+ try? audioCompTrack.insertTimeRange(
+ CMTimeRangeMake(start: .zero, duration: videoAsset.timeRange.duration),
+ of: audioAsset,
+ at: .zero
+ )
+ }
}
return mixComposition
}
+ static func prepareAudioAsset(audioUri: String, requestHeaders: [String: Any]?) -> AVURLAsset? {
+ guard let url = URL(string: audioUri) else {
+ return nil
+ }
+
+ let assetOptions: NSMutableDictionary! = NSMutableDictionary()
+ if let requestHeaders, !requestHeaders.isEmpty {
+ assetOptions.setObject(requestHeaders, forKey: "AVURLAssetHTTPHeaderFieldsKey" as NSCopying)
+ }
+
+ let cookies: [AnyObject]! = HTTPCookieStorage.shared.cookies
+ assetOptions.setObject(cookies as Any, forKey: AVURLAssetHTTPCookiesKey as NSCopying)
+ return AVURLAsset(url: url, options: assetOptions as? [String: Any])
+ }
+
static func getValidTextTracks(asset: AVAsset, assetOptions: NSDictionary?, mixComposition: AVMutableComposition,
textTracks: [TextTrack]?) async -> [TextTrack] {
var validTextTracks: [TextTrack] = []
diff --git a/node_modules/react-native-video/ios/Video/RCTVideo.swift b/node_modules/react-native-video/ios/Video/RCTVideo.swift
index 48c4df7..1c3ea1f 100644
--- a/node_modules/react-native-video/ios/Video/RCTVideo.swift
+++ b/node_modules/react-native-video/ios/Video/RCTVideo.swift
@@ -544,9 +544,27 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
return AVPlayerItem(asset: overridePlayerAsset.asset)
}
+ if let audioUri = source.audioUri,
+ let audioAsset = RCTVideoUtils.prepareAudioAsset(audioUri: audioUri, requestHeaders: source.requestHeaders),
+ let audioTrack = await RCTVideoAssetsUtils.getTracks(asset: audioAsset, withMediaType: .audio)?.first
+ {
+ self._allowsExternalPlayback = false
+ let mixComposition = await RCTVideoUtils.generateMixComposition(videoAsset: overridePlayerAsset.asset, externalAudioTrack: audioTrack)
+ return await playerItemPrepareText(source: source, asset: mixComposition, assetOptions: assetOptions, uri: source.uri ?? "")
+ }
+
return await playerItemPrepareText(source: source, asset: overridePlayerAsset.asset, assetOptions: assetOptions, uri: source.uri ?? "")
}
+ if let audioUri = source.audioUri,
+ let audioAsset = RCTVideoUtils.prepareAudioAsset(audioUri: audioUri, requestHeaders: source.requestHeaders),
+ let audioTrack = await RCTVideoAssetsUtils.getTracks(asset: audioAsset, withMediaType: .audio)?.first
+ {
+ self._allowsExternalPlayback = false
+ let mixComposition = await RCTVideoUtils.generateMixComposition(videoAsset: asset, externalAudioTrack: audioTrack)
+ return await playerItemPrepareText(source: source, asset: mixComposition, assetOptions: assetOptions, uri: source.uri ?? "")
+ }
+
return await playerItemPrepareText(source: source, asset: asset, assetOptions: assetOptions, uri: source.uri ?? "")
}
diff --git a/node_modules/react-native-video/src/Video.tsx b/node_modules/react-native-video/src/Video.tsx
index 82b73ab..6951794 100644
--- a/node_modules/react-native-video/src/Video.tsx
+++ b/node_modules/react-native-video/src/Video.tsx
@@ -213,9 +213,13 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
const resolvedSource = resolveAssetSourceForVideo(_source);
let uri = resolvedSource.uri || '';
+ const audioUri =
+ typeof resolvedSource.audioUri === 'string' ? resolvedSource.audioUri : '';
if (uri && uri.match(/^\//)) {
uri = `file://${uri}`;
}
+ const normalizedAudioUri =
+ audioUri && audioUri.match(/^\//) ? `file://${audioUri}` : audioUri;
if (!uri && _source.ad?.type !== 'ssai') {
console.log('Trying to load empty source');
}
@@ -280,6 +284,7 @@ const Video = forwardRef<VideoRef, ReactVideoProps>(
const _bufferConfig = _source.bufferConfig || bufferConfig;
return {
uri,
+ audioUri: normalizedAudioUri,
isNetwork,
isAsset,
isLocalAssetFile,
diff --git a/node_modules/react-native-video/src/specs/VideoNativeComponent.ts b/node_modules/react-native-video/src/specs/VideoNativeComponent.ts
index 3dcbe01..d5ec907 100644
--- a/node_modules/react-native-video/src/specs/VideoNativeComponent.ts
+++ b/node_modules/react-native-video/src/specs/VideoNativeComponent.ts
@@ -41,6 +41,7 @@ export type AdsConfig = Readonly<{
export type VideoSrc = Readonly<{
uri?: string;
+ audioUri?: string;
isNetwork?: boolean;
isAsset?: boolean;
isLocalAssetFile?: boolean;
diff --git a/node_modules/react-native-video/src/types/video.ts b/node_modules/react-native-video/src/types/video.ts
index 0482944..8a737ac 100644
--- a/node_modules/react-native-video/src/types/video.ts
+++ b/node_modules/react-native-video/src/types/video.ts
@@ -22,6 +22,7 @@ export type EnumValues<T extends string | number> = T extends string
export type ReactVideoSourceProperties = {
uri?: string;
+ audioUri?: string;
isNetwork?: boolean;
isAsset?: boolean;
isLocalAssetFile?: boolean;