mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-17 23:42:04 +00:00
1210 lines
55 KiB
Diff
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;
|