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? + ): 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(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(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 @@ + + 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 @@ + + 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( 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( 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 export type ReactVideoSourceProperties = { uri?: string; + audioUri?: string; isNetwork?: boolean; isAsset?: boolean; isLocalAssetFile?: boolean;