diff --git a/.gitignore b/.gitignore index e37851c0..b953572e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,9 @@ # dependencies node_modules/ +# Un-ignore specific react-native-video source files we patch +!node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt +!node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt !node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ReactExoplayerView.java # Expo 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 new file mode 100644 index 00000000..953eb59e --- /dev/null +++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/common/api/SubtitleStyle.kt @@ -0,0 +1,81 @@ +package com.brentvatne.common.api + +import android.graphics.Color +import com.brentvatne.common.toolbox.ReactBridgeUtils +import com.facebook.react.bridge.ReadableMap + +/** + * Helper file to parse SubtitleStyle prop and build a dedicated class + */ +class SubtitleStyle public constructor() { + var fontSize = -1 + private set + var paddingLeft = 0 + private set + var paddingRight = 0 + private set + var paddingTop = 0 + private set + var paddingBottom = 0 + private set + var opacity = 1f + private set + 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" + private const val PROP_PADDING_TOP = "paddingTop" + private const val PROP_PADDING_LEFT = "paddingLeft" + private const val PROP_PADDING_RIGHT = "paddingRight" + 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() + subtitleStyle.fontSize = ReactBridgeUtils.safeGetInt(src, PROP_FONT_SIZE_TRACK, -1) + subtitleStyle.paddingBottom = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_BOTTOM, 0) + subtitleStyle.paddingTop = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_TOP, 0) + subtitleStyle.paddingLeft = ReactBridgeUtils.safeGetInt(src, PROP_PADDING_LEFT, 0) + 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/ExoPlayerView.kt b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt new file mode 100644 index 00000000..943b1ce5 --- /dev/null +++ b/node_modules/react-native-video/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt @@ -0,0 +1,327 @@ +package com.brentvatne.exoplayer + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.util.AttributeSet +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.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 com.brentvatne.common.api.ResizeMode +import com.brentvatne.common.api.SubtitleStyle + +@UnstableApi +class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : + FrameLayout(context, attrs, defStyleAttr) { + + private var localStyle = SubtitleStyle() + private var pendingResizeMode: Int? = null + private val liveBadge: TextView = TextView(context).apply { + text = "LIVE" + setTextColor(Color.WHITE) + textSize = 12f + val drawable = GradientDrawable() + drawable.setColor(Color.RED) + drawable.cornerRadius = 6f + background = drawable + setPadding(12, 4, 12, 4) + visibility = View.GONE + } + + private val playerView = PlayerView(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 + } + + init { + // Add PlayerView with explicit layout parameters + val playerViewLayoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT) + addView(playerView, playerViewLayoutParams) + + // 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) + } + + fun setPlayer(player: ExoPlayer?) { + val currentPlayer = playerView.player + + if (currentPlayer != null) { + currentPlayer.removeListener(playerListener) + } + + playerView.player = player + + if (player != null) { + player.addListener(playerListener) + + // Apply pending resize mode if we have one + pendingResizeMode?.let { resizeMode -> + playerView.resizeMode = resizeMode + } + } + } + + 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 + } + + // Apply the resize mode to PlayerView immediately + playerView.resizeMode = targetResizeMode + + // Store it for reapplication if needed + pendingResizeMode = targetResizeMode + + // Force PlayerView to recalculate its layout + playerView.requestLayout() + + // Also request layout on the parent to ensure proper sizing + requestLayout() + } + + fun setSubtitleStyle(style: SubtitleStyle) { + localStyle = style + applySubtitleStyle(localStyle) + } + + private fun applySubtitleStyle(style: SubtitleStyle) { + playerView.subtitleView?.let { subtitleView -> + // 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) + + // 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()) + } else { + subtitleView.setUserDefaultTextSize() + } + + // Horizontal padding is still useful (safe area); vertical offset is handled via bottomPaddingFraction. + subtitleView.setPadding( + style.paddingLeft, + style.paddingTop, + style.paddingRight, + 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.5f) + subtitleView.setBottomPaddingFraction(fraction) + } + + if (style.opacity != 0.0f) { + subtitleView.alpha = style.opacity + subtitleView.visibility = android.view.View.VISIBLE + } else { + subtitleView.visibility = android.view.View.GONE + } + } + } + + 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() + } + + fun setControllerShowTimeoutMs(showTimeoutMs: Int) { + playerView.controllerShowTimeoutMs = showTimeoutMs + } + + fun setControllerAutoShow(autoShow: Boolean) { + playerView.controllerAutoShow = autoShow + } + + fun setControllerHideOnTouch(hideOnTouch: Boolean) { + playerView.controllerHideOnTouch = hideOnTouch + } + + fun setFullscreenButtonClickListener(listener: PlayerView.FullscreenButtonClickListener?) { + playerView.setFullscreenButtonClickListener(listener) + } + + fun setShowSubtitleButton(show: Boolean) { + playerView.setShowSubtitleButton(show) + } + + fun isControllerVisible(): Boolean = playerView.isControllerFullyVisible + + fun setControllerVisibilityListener(listener: PlayerView.ControllerVisibilityListener?) { + playerView.setControllerVisibilityListener(listener) + } + + override fun addOnLayoutChangeListener(listener: View.OnLayoutChangeListener) { + playerView.addOnLayoutChangeListener(listener) + } + + override fun setFocusable(focusable: Boolean) { + playerView.isFocusable = focusable + } + + 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 + } + + private val playerListener = object : Player.Listener { + 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() + } + } + } + + 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) + + if (changed) { + pendingResizeMode?.let { resizeMode -> + playerView.resizeMode = resizeMode + } + // Re-apply bottomPaddingFraction once we have a concrete height. + applySubtitleStyle(localStyle) + } + } +} diff --git a/src/components/SplashScreen.tsx b/src/components/SplashScreen.tsx index a610f9d3..1ce4a594 100644 --- a/src/components/SplashScreen.tsx +++ b/src/components/SplashScreen.tsx @@ -10,7 +10,7 @@ const SplashScreen = ({ onFinish }: SplashScreenProps) => { // TEMPORARILY DISABLED useEffect(() => { // Immediately call onFinish to skip splash screen - onFinish(); + onFinish(); }, [onFinish]); return null;