mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-28 21:38:46 +00:00
842 lines
38 KiB
Diff
842 lines
38 KiB
Diff
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 @@
|
|
+<?xml version="1.0" encoding="UTF-8"?>
|
|
+<classpath>
|
|
+ <classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-17/"/>
|
|
+ <classpathentry kind="con" path="org.eclipse.buildship.core.gradleclasspathcontainer"/>
|
|
+ <classpathentry kind="output" path="bin/default"/>
|
|
+</classpath>
|
|
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 @@
|
|
+<?xml version="1.0" encoding="UTF-8"?>
|
|
+<projectDescription>
|
|
+ <name>react-native-video</name>
|
|
+ <comment>Project react-native-video created by Buildship.</comment>
|
|
+ <projects>
|
|
+ </projects>
|
|
+ <buildSpec>
|
|
+ <buildCommand>
|
|
+ <name>org.eclipse.jdt.core.javabuilder</name>
|
|
+ <arguments>
|
|
+ </arguments>
|
|
+ </buildCommand>
|
|
+ <buildCommand>
|
|
+ <name>org.eclipse.buildship.core.gradleprojectbuilder</name>
|
|
+ <arguments>
|
|
+ </arguments>
|
|
+ </buildCommand>
|
|
+ </buildSpec>
|
|
+ <natures>
|
|
+ <nature>org.eclipse.jdt.core.javanature</nature>
|
|
+ <nature>org.eclipse.buildship.core.gradleprojectnature</nature>
|
|
+ </natures>
|
|
+ <filteredResources>
|
|
+ <filter>
|
|
+ <id>1772755755997</id>
|
|
+ <name></name>
|
|
+ <type>30</type>
|
|
+ <matcher>
|
|
+ <id>org.eclipse.core.resources.regexFilterMatcher</id>
|
|
+ <arguments>node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__</arguments>
|
|
+ </matcher>
|
|
+ </filter>
|
|
+ </filteredResources>
|
|
+</projectDescription>
|
|
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<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..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<DefaultTimeBar?>(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 @@
|
|
+<?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" />
|