diff --git a/node_modules/react-native-video/android/.classpath b/node_modules/react-native-video/android/.classpath new file mode 100644 index 0000000..bbe97e5 --- /dev/null +++ b/node_modules/react-native-video/android/.classpath @@ -0,0 +1,6 @@ + + + + + + diff --git a/node_modules/react-native-video/android/.project b/node_modules/react-native-video/android/.project new file mode 100644 index 0000000..2633130 --- /dev/null +++ b/node_modules/react-native-video/android/.project @@ -0,0 +1,34 @@ + + + react-native-video + Project react-native-video created by Buildship. + + + + + org.eclipse.jdt.core.javabuilder + + + + + org.eclipse.buildship.core.gradleprojectbuilder + + + + + + org.eclipse.jdt.core.javanature + org.eclipse.buildship.core.gradleprojectnature + + + + 1772755755997 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + + diff --git a/node_modules/react-native-video/android/.settings/org.eclipse.buildship.core.prefs b/node_modules/react-native-video/android/.settings/org.eclipse.buildship.core.prefs new file mode 100644 index 0000000..1675490 --- /dev/null +++ b/node_modules/react-native-video/android/.settings/org.eclipse.buildship.core.prefs @@ -0,0 +1,2 @@ +connection.project.dir=../../../android +eclipse.preferences.version=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..6e5cf08 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 @@ -67,8 +67,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..5a6b554 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,43 @@ 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.CaptionStyleCompat import androidx.media3.ui.DefaultTimeBar 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 +50,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 +90,193 @@ 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 -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT + ResizeMode.RESIZE_MODE_FIXED_WIDTH -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH + ResizeMode.RESIZE_MODE_FIXED_HEIGHT -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT + ResizeMode.RESIZE_MODE_FILL -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FILL + ResizeMode.RESIZE_MODE_CENTER_CROP -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM + else -> androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT + } + if (playerView.width > 0 && playerView.height > 0) { + playerView.resizeMode = resizeMode + } else { + pendingResizeMode = resizeMode } - playerView.player = player + // Re-assert subtitle rendering mode for the current style. + updateSubtitleRenderingMode() + applySubtitleStyle(localStyle) + } - if (player != null) { - player.addListener(playerListener) + fun getPlayerView(): PlayerView = playerView - // Apply pending resize mode if we have one - pendingResizeMode?.let { resizeMode -> - playerView.resizeMode = resizeMode - } - } + fun isPlaying(): Boolean = playerView.player?.isPlaying == true + + fun setControllerVisibilityListener(listener: PlayerView.ControllerVisibilityListener?) { + controllerVisibilityListener = listener + playerView.setControllerVisibilityListener(listener) } - fun getPlayerView(): PlayerView = playerView + 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 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 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 + } + + // windowColor MUST be transparent to avoid the "caption window" background. + val captionStyle = CaptionStyleCompat( + resolvedTextColor, + resolvedBackgroundColor, + Color.TRANSPARENT, + resolvedEdgeType, + resolvedEdgeColor, + null + ) + subtitleView.setStyle(captionStyle) - // Apply custom styling + // 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,157 +284,125 @@ class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: Attribute subtitleView.visibility = android.view.View.GONE } } - localStyle = style - } - - fun setShutterColor(color: Int) { - playerView.setShutterBackgroundColor(color) - } - - fun updateSurfaceView(viewType: Int) { - // TODO: Implement proper surface type switching if needed - } - - 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() - } + // Apply the same styling to the overlay subtitle view. + run { + val subtitleView = overlaySubtitleView - fun setControllerShowTimeoutMs(showTimeoutMs: Int) { - playerView.controllerShowTimeoutMs = showTimeoutMs - } + val resolvedTextColor = style.textColor ?: CaptionStyleCompat.DEFAULT.foregroundColor + val resolvedBackgroundColor = style.backgroundColor ?: Color.TRANSPARENT + val resolvedEdgeColor = style.edgeColor ?: Color.BLACK - 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() { + playerView.post { + playerView.requestLayout() + } } private val playerListener = object : Player.Listener { + override fun onCues(cueGroup: CueGroup) { + // Keep overlay subtitles in sync. This does NOT interfere with PlayerView's own subtitle rendering. + // When subtitlesFollowVideo=false, overlaySubtitleView is the visible one. + 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 - } - playerView.requestLayout() - requestLayout() + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + val width = MeasureSpec.getSize(widthMeasureSpec) + val height = MeasureSpec.getSize(heightMeasureSpec) + if (width > 0 && height > 0) { + pendingResizeMode?.let { resizeMode -> + playerView.resizeMode = resizeMode } + // Re-apply bottomPaddingFraction once we have a concrete height. + updateSubtitleRenderingMode() + applySubtitleStyle(localStyle) } } - 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]). - */ - private val layoutRunnable = Runnable { - measure( - MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) - ) - layout(left, top, right, bottom) - } - - 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) + 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 + } - if (changed) { - pendingResizeMode?.let { resizeMode -> - playerView.resizeMode = resizeMode + 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..773535a 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); @@ -1567,6 +1567,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 +1758,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 +2136,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 +2156,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 +2173,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/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 @@ + +