feat(android): replace custom VideoView with media3 PlayerView (#4581)

This commit is contained in:
Bartłomiej Piekarz
2025-07-02 08:46:19 +02:00
committed by GitHub
parent 1a4e3b0637
commit 978683b645
12 changed files with 1243 additions and 1061 deletions

View File

@@ -1,276 +1,275 @@
package com.brentvatne.exoplayer
import android.content.Context
import android.util.Log
import android.util.TypedValue
import android.view.Gravity
import android.graphics.Color
import android.graphics.drawable.GradientDrawable
import android.util.AttributeSet
import android.view.SurfaceView
import android.view.TextureView
import android.view.View
import android.view.ViewGroup
import android.view.View.MeasureSpec
import android.widget.FrameLayout
import androidx.core.content.ContextCompat
import androidx.media3.common.AdViewProvider
import androidx.media3.common.C
import android.widget.TextView
import androidx.media3.common.Player
import androidx.media3.common.Tracks
import androidx.media3.common.VideoSize
import androidx.media3.common.text.Cue
import androidx.media3.common.util.Assertions
import androidx.media3.common.Timeline
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.SubtitleView
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.PlayerView
import com.brentvatne.common.api.ResizeMode
import com.brentvatne.common.api.SubtitleStyle
import com.brentvatne.common.api.ViewType
import com.brentvatne.common.toolbox.DebugLog
@UnstableApi
class ExoPlayerView(private val context: Context) :
FrameLayout(context, null, 0),
AdViewProvider {
var surfaceView: View? = null
private set
private var shutterView: View
private var subtitleLayout: SubtitleView
private var layout: AspectRatioFrameLayout
private var componentListener: ComponentListener
private var player: ExoPlayer? = null
private var layoutParams: ViewGroup.LayoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
private var adOverlayFrameLayout: FrameLayout? = null
val isPlaying: Boolean
get() = player != null && player?.isPlaying == true
@ViewType.ViewType
private var viewType = ViewType.VIEW_TYPE_SURFACE
private var hideShutterView = false
class ExoPlayerView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) :
FrameLayout(context, attrs, defStyleAttr) {
private var localStyle = SubtitleStyle()
private var surfaceView: View? = 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
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_FIT
}
init {
componentListener = ComponentListener()
val aspectRatioParams = LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT
)
aspectRatioParams.gravity = Gravity.CENTER
layout = AspectRatioFrameLayout(context)
layout.layoutParams = aspectRatioParams
shutterView = View(context)
shutterView.layoutParams = layoutParams
shutterView.setBackgroundColor(ContextCompat.getColor(context, android.R.color.black))
subtitleLayout = SubtitleView(context)
subtitleLayout.layoutParams = layoutParams
subtitleLayout.setUserDefaultStyle()
subtitleLayout.setUserDefaultTextSize()
updateSurfaceView(viewType)
layout.addView(shutterView, 1, layoutParams)
if (localStyle.subtitlesFollowVideo) {
layout.addView(subtitleLayout, layoutParams)
}
addViewInLayout(layout, 0, aspectRatioParams)
if (!localStyle.subtitlesFollowVideo) {
addViewInLayout(subtitleLayout, 1, layoutParams)
}
addView(playerView)
val lp = LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT)
lp.setMargins(16, 16, 16, 16)
addView(liveBadge, lp)
}
private fun clearVideoView() {
when (val view = surfaceView) {
is TextureView -> player?.clearVideoTextureView(view)
fun setPlayer(player: ExoPlayer?) {
val currentPlayer = playerView.player
is SurfaceView -> player?.clearVideoSurfaceView(view)
if (currentPlayer != null) {
currentPlayer.removeListener(playerListener)
// Clear any existing surface from the player
when (surfaceView) {
is SurfaceView -> currentPlayer.clearVideoSurfaceView(surfaceView as SurfaceView)
is TextureView -> currentPlayer.clearVideoTextureView(surfaceView as TextureView)
}
}
else -> {
Log.w(
"clearVideoView",
"Unexpected surfaceView type: ${surfaceView?.javaClass?.name}"
)
playerView.player = player
if (player != null) {
player.addListener(playerListener)
// Set the surface view for the new player
when (surfaceView) {
is SurfaceView -> player.setVideoSurfaceView(surfaceView as SurfaceView)
is TextureView -> player.setVideoTextureView(surfaceView as TextureView)
}
}
}
private fun setVideoView() {
when (val view = surfaceView) {
is TextureView -> player?.setVideoTextureView(view)
fun getPlayerView(): PlayerView = playerView
is SurfaceView -> player?.setVideoSurfaceView(view)
else -> {
Log.w(
"setVideoView",
"Unexpected surfaceView type: ${surfaceView?.javaClass?.name}"
)
}
fun setResizeMode(@ResizeMode.Mode resizeMode: Int) {
playerView.resizeMode = 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 setSubtitleStyle(style: SubtitleStyle) {
// ensure we reset subtitle style before reapplying it
subtitleLayout.setUserDefaultStyle()
subtitleLayout.setUserDefaultTextSize()
playerView.subtitleView?.let { subtitleView ->
// Reset to defaults
subtitleView.setUserDefaultStyle()
subtitleView.setUserDefaultTextSize()
if (style.fontSize > 0) {
subtitleLayout.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSize.toFloat())
}
subtitleLayout.setPadding(
style.paddingLeft,
style.paddingTop,
style.paddingTop,
style.paddingBottom
)
if (style.opacity != 0.0f) {
subtitleLayout.alpha = style.opacity
subtitleLayout.visibility = View.VISIBLE
} else {
subtitleLayout.visibility = View.GONE
}
if (localStyle.subtitlesFollowVideo != style.subtitlesFollowVideo) {
// No need to manipulate layout if value didn't change
if (style.subtitlesFollowVideo) {
removeViewInLayout(subtitleLayout)
layout.addView(subtitleLayout, layoutParams)
} else {
layout.removeViewInLayout(subtitleLayout)
addViewInLayout(subtitleLayout, 1, layoutParams, false)
// Apply custom styling
if (style.fontSize > 0) {
subtitleView.setFixedTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, style.fontSize.toFloat())
}
subtitleView.setPadding(
style.paddingLeft,
style.paddingTop,
style.paddingRight,
style.paddingBottom
)
if (style.opacity != 0.0f) {
subtitleView.alpha = style.opacity
subtitleView.visibility = android.view.View.VISIBLE
} else {
subtitleView.visibility = android.view.View.GONE
}
requestLayout()
}
localStyle = style
}
fun setShutterColor(color: Int) {
shutterView.setBackgroundColor(color)
playerView.setShutterBackgroundColor(color)
}
fun updateSurfaceView(@ViewType.ViewType viewType: Int) {
this.viewType = viewType
var viewNeedRefresh = false
when (viewType) {
ViewType.VIEW_TYPE_SURFACE, ViewType.VIEW_TYPE_SURFACE_SECURE -> {
if (surfaceView !is SurfaceView) {
surfaceView = SurfaceView(context)
viewNeedRefresh = true
fun updateSurfaceView(viewType: Int) {
val currentSurfaceView = surfaceView
val newSurfaceView: View = when (viewType) {
ViewType.VIEW_TYPE_TEXTURE -> TextureView(context)
ViewType.VIEW_TYPE_SURFACE_SECURE -> SurfaceView(context).apply {
// Requires API 17+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) {
(this as SurfaceView).setSecure(true)
}
(surfaceView as SurfaceView).setSecure(viewType == ViewType.VIEW_TYPE_SURFACE_SECURE)
}
ViewType.VIEW_TYPE_TEXTURE -> {
if (surfaceView !is TextureView) {
surfaceView = TextureView(context)
viewNeedRefresh = true
}
// Support opacity properly:
(surfaceView as TextureView).isOpaque = false
}
else -> {
DebugLog.wtf(TAG, "Unexpected texture view type: $viewType")
}
else -> SurfaceView(context)
}
if (viewNeedRefresh) {
surfaceView?.layoutParams = layoutParams
if (layout.getChildAt(0) != null) {
layout.removeViewAt(0)
}
layout.addView(surfaceView, 0, layoutParams)
if (this.player != null) {
setVideoView()
}
}
}
var adsShown = false
fun showAds() {
if (!adsShown) {
adOverlayFrameLayout = FrameLayout(context)
layout.addView(adOverlayFrameLayout, layoutParams)
adsShown = true
}
}
fun hideAds() {
if (adsShown) {
layout.removeView(adOverlayFrameLayout)
adOverlayFrameLayout = null
adsShown = false
}
}
fun updateShutterViewVisibility() {
shutterView.visibility = if (this.hideShutterView) {
View.INVISIBLE
} else {
View.VISIBLE
}
}
override fun requestLayout() {
super.requestLayout()
post(measureAndLayout)
}
// AdsLoader.AdViewProvider implementation.
override fun getAdViewGroup(): ViewGroup =
Assertions.checkNotNull(
adOverlayFrameLayout,
"exo_ad_overlay must be present for ad playback"
)
/**
* Set the {@link ExoPlayer} to use. The {@link ExoPlayer#addListener} method of the
* player will be called and previous
* assignments are overridden.
*
* @param player The {@link ExoPlayer} to use.
*/
fun setPlayer(player: ExoPlayer?) {
if (this.player == player) {
if (currentSurfaceView === newSurfaceView) {
return
}
if (this.player != null) {
this.player!!.removeListener(componentListener)
clearVideoView()
}
this.player = player
updateShutterViewVisibility()
if (player != null) {
setVideoView()
player.addListener(componentListener)
// Remove the old surface view
if (currentSurfaceView != null) {
removeView(currentSurfaceView)
}
// Add the new surface view
surfaceView = newSurfaceView
val layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
addView(newSurfaceView, 0, layoutParams)
// Attach player to the new surface
playerView.player?.let {
when (newSurfaceView) {
is SurfaceView -> it.setVideoSurfaceView(newSurfaceView)
is TextureView -> it.setVideoTextureView(newSurfaceView)
}
}
}
val isPlaying: Boolean
get() = playerView.player?.isPlaying ?: false
fun invalidateAspectRatio() {
// PlayerView handles aspect ratio automatically
requestLayout()
}
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<DefaultTimeBar?>(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() }
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()
}
}
}
companion object {
private const val TAG = "ExoPlayerView"
}
/**
* Sets the resize mode which can be of value {@link ResizeMode.Mode}
*
* @param resizeMode The resize mode.
* 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]).
*/
fun setResizeMode(@ResizeMode.Mode resizeMode: Int) {
if (layout.resizeMode != resizeMode) {
layout.resizeMode = resizeMode
post(measureAndLayout)
}
}
fun setHideShutterView(hideShutterView: Boolean) {
this.hideShutterView = hideShutterView
updateShutterViewVisibility()
}
private val measureAndLayout: Runnable = Runnable {
private val layoutRunnable = Runnable {
measure(
MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY),
MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY)
@@ -278,60 +277,9 @@ class ExoPlayerView(private val context: Context) :
layout(left, top, right, bottom)
}
private fun updateForCurrentTrackSelections(tracks: Tracks?) {
if (tracks == null) {
return
}
val groups = tracks.groups
for (group in groups) {
if (group.type == C.TRACK_TYPE_VIDEO && group.length > 0) {
// get the first track of the group to identify aspect ratio
val format = group.getTrackFormat(0)
if (format.width > 0 || format.height > 0) {
layout.updateAspectRatio(format)
}
return
}
}
// no video tracks, in that case refresh shutterView visibility
updateShutterViewVisibility()
}
fun invalidateAspectRatio() {
// Resetting aspect ratio will force layout refresh on next video size changed
layout.invalidateAspectRatio()
}
private inner class ComponentListener : Player.Listener {
override fun onCues(cues: List<Cue>) {
subtitleLayout.setCues(cues)
}
override fun onVideoSizeChanged(videoSize: VideoSize) {
if (videoSize.height == 0 || videoSize.width == 0) {
// When changing video track we receive an ghost state with height / width = 0
// No need to resize the view in that case
return
}
// Here we use updateForCurrentTrackSelections to have a consistent behavior.
// according to: https://github.com/androidx/media/issues/1207
// sometimes media3 send bad Video size information
player?.let {
updateForCurrentTrackSelections(it.currentTracks)
}
}
override fun onRenderedFirstFrame() {
shutterView.visibility = INVISIBLE
}
override fun onTracksChanged(tracks: Tracks) {
updateForCurrentTrackSelections(tracks)
}
}
companion object {
private const val TAG = "ExoPlayerView"
override fun requestLayout() {
super.requestLayout()
// Post a second layout pass so the ExoPlayer internal views get correct bounds.
post(layoutRunnable)
}
}

View File

@@ -10,8 +10,6 @@ import android.view.ViewGroup
import android.view.Window
import android.view.WindowManager
import android.widget.FrameLayout
import android.widget.ImageButton
import android.widget.LinearLayout
import androidx.activity.OnBackPressedCallback
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
@@ -91,7 +89,6 @@ class FullScreenPlayerView(
parent?.removeView(exoPlayerView)
containerView.addView(exoPlayerView, generateDefaultLayoutParams())
playerControlView?.let {
updateFullscreenButton(playerControlView, true)
parent?.removeView(it)
containerView.addView(it, generateDefaultLayoutParams())
}
@@ -104,7 +101,6 @@ class FullScreenPlayerView(
containerView.removeView(exoPlayerView)
parent?.addView(exoPlayerView, generateDefaultLayoutParams())
playerControlView?.let {
updateFullscreenButton(playerControlView, false)
containerView.removeView(it)
parent?.addView(it, generateDefaultLayoutParams())
}
@@ -141,20 +137,6 @@ class FullScreenPlayerView(
androidx.media3.ui.R.drawable.exo_icon_fullscreen_enter
}
private fun updateFullscreenButton(playerControlView: LegacyPlayerControlView, isFullscreen: Boolean) {
val imageButton = playerControlView.findViewById<ImageButton?>(com.brentvatne.react.R.id.exo_fullscreen)
imageButton?.let {
val imgResource = getFullscreenIconResource(isFullscreen)
val desc = if (isFullscreen) {
context.getString(androidx.media3.ui.R.string.exo_controls_fullscreen_exit_description)
} else {
context.getString(androidx.media3.ui.R.string.exo_controls_fullscreen_enter_description)
}
imageButton.setImageResource(imgResource)
imageButton.contentDescription = desc
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
if (reactExoplayerView.preventsDisplaySleepDuringVideoPlayback) {
@@ -226,13 +208,7 @@ class FullScreenPlayerView(
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
)
}
if (controlsConfig.hideNotificationBarOnFullScreenMode) {
val liveContainer = playerControlView?.findViewById<LinearLayout?>(com.brentvatne.react.R.id.exo_live_container)
liveContainer?.let {
val layoutParams = it.layoutParams as LinearLayout.LayoutParams
layoutParams.topMargin = 40
it.layoutParams = layoutParams
}
}
// Note: Live container adjustment is no longer needed since we're using PlayerView's built-in controls
// PlayerView handles UI adjustments automatically
}
}

View File

@@ -141,9 +141,10 @@ object PictureInPictureUtil {
@RequiresApi(Build.VERSION_CODES.O)
private fun calcRectHint(playerView: ExoPlayerView): Rect {
val hint = Rect()
playerView.surfaceView?.getGlobalVisibleRect(hint)
// Use the PlayerView itself since surfaceView is private
playerView.getGlobalVisibleRect(hint)
val location = IntArray(2)
playerView.surfaceView?.getLocationOnScreen(location)
playerView.getLocationOnScreen(location)
val height = hint.bottom - hint.top
hint.top = location[1]

View File

@@ -103,7 +103,6 @@ import androidx.media3.extractor.metadata.emsg.EventMessage;
import androidx.media3.extractor.metadata.id3.Id3Frame;
import androidx.media3.extractor.metadata.id3.TextInformationFrame;
import androidx.media3.session.MediaSessionService;
import androidx.media3.ui.LegacyPlayerControlView;
import com.brentvatne.common.api.AdsProps;
import com.brentvatne.common.api.BufferConfig;
@@ -178,8 +177,6 @@ public class ReactExoplayerView extends FrameLayout implements
protected final VideoEventEmitter eventEmitter;
private final ReactExoplayerConfig config;
private DefaultBandwidthMeter bandwidthMeter;
private LegacyPlayerControlView playerControlView;
private View playPauseControlContainer;
private Player.Listener eventListener;
private ExoPlayerView exoPlayerView;
@@ -248,7 +245,7 @@ public class ReactExoplayerView extends FrameLayout implements
private float mProgressUpdateInterval = 250.0f;
protected boolean playInBackground = false;
private boolean mReportBandwidth = false;
private boolean controls;
private boolean controls = false;
private boolean showNotificationControls = false;
// \ End props
@@ -278,8 +275,8 @@ public class ReactExoplayerView extends FrameLayout implements
private void updateProgress() {
if (player != null) {
if (playerControlView != null && isPlayingAd() && controls) {
playerControlView.hide();
if (exoPlayerView != null && isPlayingAd() && controls) {
exoPlayerView.hideController();
}
long bufferedDuration = player.getBufferedPercentage() * player.getDuration() / 100;
long duration = player.getDuration();
@@ -423,103 +420,45 @@ public class ReactExoplayerView extends FrameLayout implements
*/
private void togglePlayerControlVisibility() {
if (player == null) return;
reLayoutControls();
if (playerControlView.isVisible()) {
playerControlView.hide();
if (exoPlayerView.isControllerVisible()) {
exoPlayerView.hideController();
} else {
playerControlView.show();
exoPlayerView.showController();
}
}
/**
* Initializing Player control
*/
private void initializePlayerControl() {
if (playerControlView == null) {
playerControlView = new LegacyPlayerControlView(getContext());
playerControlView.addVisibilityListener(new LegacyPlayerControlView.VisibilityListener() {
@Override
public void onVisibilityChange(int visibility) {
eventEmitter.onControlsVisibilityChange.invoke(visibility == View.VISIBLE);
}
});
}
exoPlayerView.setPlayer(player);
// Setting the player for the playerControlView
playerControlView.setPlayer(player);
playPauseControlContainer = playerControlView.findViewById(R.id.exo_play_pause_container);
// Invoking onClick event for exoplayerView
exoPlayerView.setOnClickListener((View v) -> {
if (!isPlayingAd()) {
togglePlayerControlVisibility();
}
exoPlayerView.setControllerVisibilityListener(visibility -> {
boolean isVisible = visibility == View.VISIBLE;
eventEmitter.onControlsVisibilityChange.invoke(isVisible);
});
//Handling the playButton click event
ImageButton playButton = playerControlView.findViewById(R.id.exo_play);
playButton.setOnClickListener((View v) -> {
if (player != null && player.getPlaybackState() == Player.STATE_ENDED) {
player.seekTo(0);
}
setPausedModifier(false);
exoPlayerView.setFullscreenButtonClickListener(isFullscreen -> {
setFullscreen(!this.isFullscreen);
});
//Handling the rewind and forward button click events
ImageButton exoRewind = playerControlView.findViewById(R.id.exo_rew);
ImageButton exoForward = playerControlView.findViewById(R.id.exo_ffwd);
exoRewind.setOnClickListener((View v) -> {
seekTo(player.getCurrentPosition() - controlsConfig.getSeekIncrementMS());
});
exoForward.setOnClickListener((View v) -> {
seekTo(player.getCurrentPosition() + controlsConfig.getSeekIncrementMS());
});
//Handling the pauseButton click event
ImageButton pauseButton = playerControlView.findViewById(R.id.exo_pause);
pauseButton.setOnClickListener((View v) ->
setPausedModifier(true)
);
//Handling the settingButton click event
final ImageButton settingButton = playerControlView.findViewById(R.id.exo_settings);
settingButton.setOnClickListener(v -> openSettings());
//Handling the fullScreenButton click event
final ImageButton fullScreenButton = playerControlView.findViewById(R.id.exo_fullscreen);
fullScreenButton.setOnClickListener(v -> setFullscreen(!isFullscreen));
updateFullScreenButtonVisibility();
refreshControlsStyles();
// Invoking onPlaybackStateChanged and onPlayWhenReadyChanged events for Player
eventListener = new Player.Listener() {
@Override
public void onPlaybackStateChanged(int playbackState) {
View playButton = playerControlView.findViewById(R.id.exo_play);
View pauseButton = playerControlView.findViewById(R.id.exo_pause);
if (playButton != null && playButton.getVisibility() == GONE) {
playButton.setVisibility(INVISIBLE);
}
if (pauseButton != null && pauseButton.getVisibility() == GONE) {
pauseButton.setVisibility(INVISIBLE);
}
reLayout(playPauseControlContainer);
//Remove this eventListener once its executed. since UI will work fine once after the reLayout is done
player.removeListener(eventListener);
}
@Override
public void onPlayWhenReadyChanged(boolean playWhenReady, int reason) {
reLayout(playPauseControlContainer);
//Remove this eventListener once its executed. since UI will work fine once after the reLayout is done
player.removeListener(eventListener);
}
};
player.addListener(eventListener);
updateControllerConfig();
}
private void updateControllerConfig() {
if (exoPlayerView == null) return;
exoPlayerView.setControllerShowTimeoutMs(5000);
exoPlayerView.setControllerAutoShow(true);
exoPlayerView.setControllerHideOnTouch(true);
updateControllerVisibility();
}
private void updateControllerVisibility() {
if (exoPlayerView == null) return;
exoPlayerView.setUseController(!controlsConfig.getHideFullscreen());
}
private void openSettings() {
AlertDialog.Builder builder = new AlertDialog.Builder(themedReactContext);
builder.setTitle(R.string.settings);
@@ -558,21 +497,8 @@ public class ReactExoplayerView extends FrameLayout implements
builder.show();
}
/**
* Adding Player control to the frame layout
*/
private void addPlayerControl() {
if (playerControlView == null) return;
LayoutParams layoutParams = new LayoutParams(
LayoutParams.MATCH_PARENT,
LayoutParams.MATCH_PARENT);
playerControlView.setLayoutParams(layoutParams);
int indexOfPC = indexOfChild(playerControlView);
if (indexOfPC != -1) {
removeViewAt(indexOfPC);
}
addView(playerControlView, 1, layoutParams);
reLayout(playerControlView);
updateControllerConfig();
}
/**
@@ -589,81 +515,16 @@ public class ReactExoplayerView extends FrameLayout implements
}
private void refreshControlsStyles() {
if (playerControlView == null || player == null || !controls) return;
updateLiveContent();
updatePlayPauseButtons();
updateButtonVisibility(controlsConfig.getHideForward(), R.id.exo_ffwd);
updateButtonVisibility(controlsConfig.getHideRewind(), R.id.exo_rew);
updateButtonVisibility(controlsConfig.getHideNext(), R.id.exo_next);
updateButtonVisibility(controlsConfig.getHidePrevious(), R.id.exo_prev);
updateViewVisibility(playerControlView.findViewById(R.id.exo_fullscreen), controlsConfig.getHideFullscreen(), GONE);
updateViewVisibility(playerControlView.findViewById(R.id.exo_position), controlsConfig.getHidePosition(), GONE);
updateViewVisibility(playerControlView.findViewById(R.id.exo_progress), controlsConfig.getHideSeekBar(), INVISIBLE);
updateViewVisibility(playerControlView.findViewById(R.id.exo_duration), controlsConfig.getHideDuration(), GONE);
updateViewVisibility(playerControlView.findViewById(R.id.exo_settings), controlsConfig.getHideSettingButton(), GONE );
if (exoPlayerView == null || player == null || !controls) return;
updateControllerVisibility();
}
private void updateLiveContent() {
LinearLayout exoLiveContainer = playerControlView.findViewById(R.id.exo_live_container);
TextView exoLiveLabel = playerControlView.findViewById(R.id.exo_live_label);
boolean isLive = false;
Timeline timeline = player.getCurrentTimeline();
// Determine if the content is live
if (!timeline.isEmpty()) {
Timeline.Window window = new Timeline.Window();
timeline.getWindow(player.getCurrentMediaItemIndex(), window);
isLive = window.isLive();
}
if (isLive && controlsConfig.getLiveLabel() != null) {
exoLiveLabel.setText(controlsConfig.getLiveLabel());
exoLiveContainer.setVisibility(VISIBLE);
} else {
exoLiveContainer.setVisibility(GONE);
}
}
private void updatePlayPauseButtons() {
final ImageButton playButton = playerControlView.findViewById(R.id.exo_play);
final ImageButton pauseButton = playerControlView.findViewById(R.id.exo_pause);
if (controlsConfig.getHidePlayPause()) {
playPauseControlContainer.setAlpha(0);
playButton.setClickable(false);
pauseButton.setClickable(false);
} else {
playPauseControlContainer.setAlpha(1.0f);
playButton.setClickable(true);
pauseButton.setClickable(true);
}
}
private void updateButtonVisibility(boolean hide, int buttonID) {
ImageButton button = playerControlView.findViewById(buttonID);
if (hide) {
button.setImageAlpha(0);
button.setClickable(false);
} else {
button.setImageAlpha(255);
button.setClickable(true);
}
}
private void updateViewVisibility(View view, boolean hide, int hideVisibility) {
if (hide) {
view.setVisibility(hideVisibility);
} else if (view.getVisibility() == hideVisibility) {
view.setVisibility(VISIBLE);
}
}
// Note: The following methods for live content and button visibility are no longer needed
// as PlayerView handles controls automatically. Some functionality may need to be
// reimplemented using PlayerView's APIs if custom behavior is required.
private void reLayoutControls() {
reLayout(exoPlayerView);
reLayout(playerControlView);
}
/// returns true is adaptive bitrate shall be used
@@ -791,7 +652,6 @@ public class ReactExoplayerView extends FrameLayout implements
}
if (playerNeedsSource) {
// Will force display of shutter view if needed
exoPlayerView.updateShutterViewVisibility();
exoPlayerView.invalidateAspectRatio();
// DRM session manager creation must be done on a different thread to prevent crashes so we start a new thread
ExecutorService es = Executors.newSingleThreadExecutor();
@@ -875,7 +735,7 @@ public class ReactExoplayerView extends FrameLayout implements
mediaSourceFactory.setDataSourceFactory(RNVSimpleCache.INSTANCE.getCacheFactory(buildHttpDataSourceFactory(true)));
}
mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView);
mediaSourceFactory.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView.getPlayerView());
player = new ExoPlayer.Builder(getContext(), renderersFactory)
.setTrackSelector(self.trackSelector)
@@ -910,7 +770,6 @@ public class ReactExoplayerView extends FrameLayout implements
if (adProps != null && uri != null) {
Uri adTagUrl = adProps.getAdTagUrl();
if (adTagUrl != null) {
exoPlayerView.showAds();
// Create an AdsLoader.
ImaAdsLoader.Builder imaLoaderBuilder = new ImaAdsLoader
.Builder(themedReactContext)
@@ -926,16 +785,16 @@ public class ReactExoplayerView extends FrameLayout implements
adsLoader.setPlayer(player);
if (adsLoader != null) {
DefaultMediaSourceFactory mediaSourceFactory = new DefaultMediaSourceFactory(mediaDataSourceFactory)
.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView);
.setLocalAdInsertionComponents(unusedAdTagUri -> adsLoader, exoPlayerView.getPlayerView());
DataSpec adTagDataSpec = new DataSpec(adTagUrl);
return new AdsMediaSource(videoSource,
adTagDataSpec,
ImmutableList.of(uri, adTagUrl),
mediaSourceFactory, adsLoader, exoPlayerView);
mediaSourceFactory, adsLoader, exoPlayerView.getPlayerView());
}
}
}
exoPlayerView.hideAds();
return null;
}
@@ -981,7 +840,7 @@ public class ReactExoplayerView extends FrameLayout implements
DebugLog.e(TAG, "Failed to initialize DRM Session Manager Framework!");
return;
}
// init source to manage ads and external text tracks
// init source to manage ads (external text tracks are now handled in MediaItem)
MediaSource videoSource = buildMediaSource(runningSource.getUri(),
runningSource.getExtension(),
drmSessionManager,
@@ -990,12 +849,6 @@ public class ReactExoplayerView extends FrameLayout implements
MediaSource mediaSourceWithAds = initializeAds(videoSource, runningSource);
MediaSource mediaSource = Objects.requireNonNullElse(mediaSourceWithAds, videoSource);
MediaSource subtitlesSource = buildTextSource();
if (subtitlesSource != null) {
MediaSource[] mediaSourceArray = {mediaSource, subtitlesSource};
mediaSource = new MergingMediaSource(mediaSourceArray);
}
// wait for player to be set
while (player == null) {
try {
@@ -1151,6 +1004,13 @@ public class ReactExoplayerView extends FrameLayout implements
if (customMetadata != null) {
mediaItemBuilder.setMediaMetadata(customMetadata);
}
// Add external subtitles to MediaItem
List<MediaItem.SubtitleConfiguration> subtitleConfigurations = buildSubtitleConfigurations();
if (subtitleConfigurations != null) {
mediaItemBuilder.setSubtitleConfigurations(subtitleConfigurations);
}
if (source.getAdsProps() != null) {
Uri adTagUrl = source.getAdsProps().getAdTagUrl();
if (adTagUrl != null) {
@@ -1285,29 +1145,59 @@ public class ReactExoplayerView extends FrameLayout implements
}
@Nullable
private MediaSource buildTextSource() {
if (source.getSideLoadedTextTracks() == null) {
private List<MediaItem.SubtitleConfiguration> buildSubtitleConfigurations() {
if (source.getSideLoadedTextTracks() == null || source.getSideLoadedTextTracks().getTracks().isEmpty()) {
return null;
}
List<MediaItem.SubtitleConfiguration> subtitleConfigurations = new ArrayList<>();
int trackIndex = 0;
for (SideLoadedTextTrack track : source.getSideLoadedTextTracks().getTracks()) {
MediaItem.SubtitleConfiguration subtitleConfiguration = new MediaItem.SubtitleConfiguration.Builder(track.getUri())
.setMimeType(track.getType())
.setLanguage(track.getLanguage())
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
.setRoleFlags(C.ROLE_FLAG_SUBTITLE)
.setLabel(track.getTitle())
.build();
subtitleConfigurations.add(subtitleConfiguration);
try {
// Create a more descriptive ID that PlayerView can use
String trackId = "external-subtitle-" + trackIndex;
String label = track.getTitle();
if (label == null || label.isEmpty()) {
label = "External " + (trackIndex + 1);
if (track.getLanguage() != null && !track.getLanguage().isEmpty()) {
label += " (" + track.getLanguage() + ")";
}
}
MediaItem.SubtitleConfiguration.Builder configBuilder = new MediaItem.SubtitleConfiguration.Builder(track.getUri())
.setId(trackId)
.setMimeType(track.getType())
.setLabel(label)
.setRoleFlags(C.ROLE_FLAG_SUBTITLE);
// Set language if available
if (track.getLanguage() != null && !track.getLanguage().isEmpty()) {
configBuilder.setLanguage(track.getLanguage());
}
// Set selection flags - make first track default if no specific track is selected
if (trackIndex == 0 && (textTrackType == null || "disabled".equals(textTrackType))) {
configBuilder.setSelectionFlags(C.SELECTION_FLAG_DEFAULT);
} else {
configBuilder.setSelectionFlags(0);
}
MediaItem.SubtitleConfiguration subtitleConfiguration = configBuilder.build();
subtitleConfigurations.add(subtitleConfiguration);
DebugLog.d(TAG, "Created subtitle configuration: " + trackId + " - " + label + " (" + track.getType() + ")");
trackIndex++;
} catch (Exception e) {
DebugLog.e(TAG, "Error creating SubtitleConfiguration for URI " + track.getUri() + ": " + e.getMessage());
}
}
MediaItem subtitlesMediaItem = new MediaItem.Builder()
.setUri(source.getUri())
.setSubtitleConfigurations(subtitleConfigurations).build();
if (!subtitleConfigurations.isEmpty()) {
DebugLog.d(TAG, "Built " + subtitleConfigurations.size() + " external subtitle configurations");
}
return new DefaultMediaSourceFactory(mediaDataSourceFactory).createMediaSource(subtitlesMediaItem);
return subtitleConfigurations.isEmpty() ? null : subtitleConfigurations;
}
private void releasePlayer() {
@@ -1530,9 +1420,9 @@ public class ReactExoplayerView extends FrameLayout implements
selectTrackWhenReady = false;
setSelectedTrack(C.TRACK_TYPE_VIDEO, videoTrackType, videoTrackValue);
}
// Setting the visibility for the playerControlView
if (playerControlView != null) {
playerControlView.show();
// Setting the visibility for the player controls
if (exoPlayerView != null) {
exoPlayerView.showController();
}
setKeepScreenOn(preventsDisplaySleepDuringVideoPlayback);
break;
@@ -1599,6 +1489,7 @@ public class ReactExoplayerView extends FrameLayout implements
eventEmitter.onVideoLoad.invoke(duration, currentPosition, width, height,
audioTracks, textTracks, videoTracks, trackId );
updateSubtitleButtonVisibility();
});
return;
}
@@ -1607,6 +1498,8 @@ public class ReactExoplayerView extends FrameLayout implements
eventEmitter.onVideoLoad.invoke(duration, currentPosition, width, height,
audioTracks, textTracks, videoTracks, trackId);
updateSubtitleButtonVisibility();
refreshControlsStyles();
}
}
@@ -1620,7 +1513,6 @@ public class ReactExoplayerView extends FrameLayout implements
private ArrayList<Track> getAudioTrackInfo() {
ArrayList<Track> audioTracks = new ArrayList<>();
if (trackSelector == null) {
// Likely player is unmounting so no audio tracks are available anymore
return audioTracks;
}
@@ -1631,15 +1523,25 @@ public class ReactExoplayerView extends FrameLayout implements
}
TrackGroupArray groups = info.getTrackGroups(index);
TrackSelectionArray selectionArray = player.getCurrentTrackSelections();
TrackSelection selection = selectionArray.get( C.TRACK_TYPE_AUDIO );
TrackSelection selection = selectionArray.get(C.TRACK_TYPE_AUDIO);
for (int i = 0; i < groups.length; ++i) {
TrackGroup group = groups.get(i);
for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) {
TrackGroup group = groups.get(groupIndex);
Format format = group.getFormat(0);
Track audioTrack = exoplayerTrackToGenericTrack(format, i, selection, group);
// Check if this specific group is the currently selected one
boolean isSelected = false;
if (selection != null && selection.getTrackGroup() == group) {
isSelected = true;
}
Track audioTrack = exoplayerTrackToGenericTrack(format, groupIndex, selection, group);
audioTrack.setBitrate(format.bitrate == Format.NO_VALUE ? 0 : format.bitrate);
audioTrack.setSelected(isSelected);
audioTracks.add(audioTrack);
}
return audioTracks;
}
@@ -1765,20 +1667,113 @@ public class ReactExoplayerView extends FrameLayout implements
if (trackSelector == null) {
return textTracks;
}
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
int index = getTrackRendererIndex(C.TRACK_TYPE_TEXT);
if (info == null || index == C.INDEX_UNSET) {
return textTracks;
}
TrackSelectionArray selectionArray = player.getCurrentTrackSelections();
TrackSelection selection = selectionArray.get( C.TRACK_TYPE_VIDEO );
TrackSelection selection = selectionArray.get(C.TRACK_TYPE_TEXT);
TrackGroupArray groups = info.getTrackGroups(index);
for (int i = 0; i < groups.length; ++i) {
TrackGroup group = groups.get(i);
for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) {
TrackGroup group = groups.get(groupIndex);
for (int trackIndex = 0; trackIndex < group.length; trackIndex++) {
Format format = group.getFormat(trackIndex);
Track textTrack = exoplayerTrackToGenericTrack(format, trackIndex, selection, group);
boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-");
boolean isSelected = isTrackSelected(selection, group, trackIndex);
textTrack.setIndex(textTracks.size());
if (textTrack.getTitle() == null || textTrack.getTitle().isEmpty()) {
if (isExternal) {
textTrack.setTitle("External " + (trackIndex + 1));
} else {
textTrack.setTitle("Track " + (textTracks.size() + 1));
}
}
textTracks.add(textTrack);
}
}
return textTracks;
}
private ArrayList<Track> getBasicAudioTrackInfo() {
ArrayList<Track> tracks = new ArrayList<>();
if (trackSelector == null) {
return tracks;
}
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
int index = getTrackRendererIndex(C.TRACK_TYPE_AUDIO);
if (info == null || index == C.INDEX_UNSET) {
return tracks;
}
TrackGroupArray groups = info.getTrackGroups(index);
for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) {
TrackGroup group = groups.get(groupIndex);
Format format = group.getFormat(0);
Track textTrack = exoplayerTrackToGenericTrack(format, i, selection, group);
textTracks.add(textTrack);
// Create track without trying to determine selection status
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));
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);
tracks.add(track);
}
DebugLog.d(TAG, "getBasicAudioTrackInfo: returning " + tracks.size() + " audio tracks (no selection status)");
return tracks;
}
private ArrayList<Track> getBasicTextTrackInfo() {
ArrayList<Track> textTracks = new ArrayList<>();
if (trackSelector == null) {
return textTracks;
}
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
int index = getTrackRendererIndex(C.TRACK_TYPE_TEXT);
if (info == null || index == C.INDEX_UNSET) {
return textTracks;
}
TrackGroupArray groups = info.getTrackGroups(index);
for (int groupIndex = 0; groupIndex < groups.length; ++groupIndex) {
TrackGroup group = groups.get(groupIndex);
for (int trackIndex = 0; trackIndex < group.length; trackIndex++) {
Format format = group.getFormat(trackIndex);
Track textTrack = new Track();
textTrack.setIndex(textTracks.size());
if (format.sampleMimeType != null) textTrack.setMimeType(format.sampleMimeType);
if (format.language != null) textTrack.setLanguage(format.language);
boolean isExternal = format.id != null && format.id.startsWith("external-subtitle-");
if (format.label != null && !format.label.isEmpty()) {
textTrack.setTitle(format.label);
} else if (isExternal) {
textTrack.setTitle("External " + (trackIndex + 1));
} else {
textTrack.setTitle("Track " + (textTracks.size() + 1));
}
textTrack.setSelected(false); // Don't report selection status - let PlayerView handle it
textTracks.add(textTrack);
}
}
return textTracks;
}
@@ -1835,9 +1830,70 @@ public class ReactExoplayerView extends FrameLayout implements
@Override
public void onTracksChanged(@NonNull Tracks tracks) {
eventEmitter.onTextTracks.invoke(getTextTrackInfo());
eventEmitter.onAudioTracks.invoke(getAudioTrackInfo());
eventEmitter.onVideoTracks.invoke(getVideoTrackInfo());
DebugLog.d(TAG, "onTracksChanged called - updating track information, controls=" + controls);
if (controls) {
ArrayList<Track> textTracks = getBasicTextTrackInfo();
ArrayList<Track> audioTracks = getBasicAudioTrackInfo();
ArrayList<VideoTrack> videoTracks = getVideoTrackInfo();
eventEmitter.onTextTracks.invoke(textTracks);
eventEmitter.onAudioTracks.invoke(audioTracks);
eventEmitter.onVideoTracks.invoke(videoTracks);
} else {
ArrayList<Track> textTracks = getTextTrackInfo();
ArrayList<Track> audioTracks = getAudioTrackInfo();
ArrayList<VideoTrack> videoTracks = getVideoTrackInfo();
eventEmitter.onTextTracks.invoke(textTracks);
eventEmitter.onAudioTracks.invoke(audioTracks);
eventEmitter.onVideoTracks.invoke(videoTracks);
int selectedAudioTracks = 0;
for (Track track : audioTracks) {
if (track.isSelected()) {
selectedAudioTracks++;
}
}
}
updateSubtitleButtonVisibility();
}
private boolean hasBuiltInTextTracks() {
if (player == null || trackSelector == null) return false;
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
if (info == null) return false;
int textRendererIndex = getTrackRendererIndex(C.TRACK_TYPE_TEXT);
if (textRendererIndex == C.INDEX_UNSET) return false;
TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
// Check if any groups have tracks that are NOT external subtitles
for (int i = 0; i < groups.length; i++) {
TrackGroup group = groups.get(i);
for (int j = 0; j < group.length; j++) {
Format format = group.getFormat(j);
// If track ID doesn't start with "external-subtitle-", it's built-in
if (format.id == null || !format.id.startsWith("external-subtitle-")) {
return true;
}
}
}
return false;
}
private void updateSubtitleButtonVisibility() {
if (exoPlayerView == null) return;
boolean hasTextTracks = (source.getSideLoadedTextTracks() != null &&
!source.getSideLoadedTextTracks().getTracks().isEmpty()) ||
hasBuiltInTextTracks();
exoPlayerView.setShowSubtitleButton(hasTextTracks);
}
@Override
@@ -1988,7 +2044,7 @@ public class ReactExoplayerView extends FrameLayout implements
player.clearMediaItems();
}
}
exoPlayerView.hideAds();
this.source = new Source();
this.mediaDataSourceFactory = null;
clearResumePosition();
@@ -2029,6 +2085,8 @@ public class ReactExoplayerView extends FrameLayout implements
}
public void disableTrack(int rendererIndex) {
if (trackSelector == null) return;
DefaultTrackSelector.Parameters disableParameters = trackSelector.getParameters()
.buildUpon()
.setRendererDisabled(rendererIndex, true)
@@ -2036,12 +2094,89 @@ public class ReactExoplayerView extends FrameLayout implements
trackSelector.setParameters(disableParameters);
}
private void selectTextTrackInternal(String type, String value) {
if (player == null || trackSelector == null) return;
DebugLog.d(TAG, "selectTextTrackInternal: type=" + type + ", value=" + value);
DefaultTrackSelector.Parameters.Builder parametersBuilder = trackSelector.getParameters().buildUpon();
if ("disabled".equals(type) || value == null) {
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true);
} else {
parametersBuilder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false);
parametersBuilder.clearOverridesOfType(C.TRACK_TYPE_TEXT);
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
if (info != null) {
int textRendererIndex = getTrackRendererIndex(C.TRACK_TYPE_TEXT);
if (textRendererIndex != C.INDEX_UNSET) {
TrackGroupArray groups = info.getTrackGroups(textRendererIndex);
boolean trackFound = false;
for (int groupIndex = 0; groupIndex < groups.length; groupIndex++) {
TrackGroup group = groups.get(groupIndex);
for (int trackIndex = 0; trackIndex < group.length; trackIndex++) {
Format format = group.getFormat(trackIndex);
boolean isMatch = false;
if ("language".equals(type) && format.language != null && format.language.equals(value)) {
isMatch = true;
} else if ("title".equals(type) && format.label != null && format.label.equals(value)) {
isMatch = true;
} else if ("index".equals(type)) {
int targetIndex = ReactBridgeUtils.safeParseInt(value, -1);
if (targetIndex == trackIndex) {
isMatch = true;
}
}
if (isMatch) {
TrackSelectionOverride override = new TrackSelectionOverride(group,
java.util.Arrays.asList(trackIndex));
parametersBuilder.addOverride(override);
trackFound = true;
break;
}
}
if (trackFound) break;
}
if (!trackFound) {
DebugLog.w(TAG, "Text track not found for type=" + type + ", value=" + value +
". Keeping current selection.");
}
}
}
}
try {
trackSelector.setParameters(parametersBuilder.build());
// Give PlayerView time to update its controls
mainHandler.postDelayed(() -> {
if (exoPlayerView != null) {
updateSubtitleButtonVisibility();
}
}, 100);
} catch (Exception e) {
DebugLog.e(TAG, "Error setting text track parameters: " + e.getMessage());
}
}
public void setSelectedTrack(int trackType, String type, String value) {
if (player == null) return;
if (player == null || trackSelector == null) return;
if (controls) {
return;
}
int rendererIndex = getTrackRendererIndex(trackType);
if (rendererIndex == C.INDEX_UNSET) {
return;
}
MappingTrackSelector.MappedTrackInfo info = trackSelector.getCurrentMappedTrackInfo();
if (info == null) {
return;
@@ -2146,7 +2281,7 @@ public class ReactExoplayerView extends FrameLayout implements
if (captioningManager != null && captioningManager.isEnabled()) {
groupIndex = getGroupIndexForDefaultLocale(groups);
}
} else if (rendererIndex == C.TRACK_TYPE_AUDIO) { // Audio default
} else if (trackType == C.TRACK_TYPE_AUDIO) { // Audio default
groupIndex = getGroupIndexForDefaultLocale(groups);
}
@@ -2186,23 +2321,42 @@ public class ReactExoplayerView extends FrameLayout implements
return;
}
TrackSelectionOverride selectionOverride = new TrackSelectionOverride(groups.get(groupIndex), tracks);
try {
TrackSelectionOverride selectionOverride = new TrackSelectionOverride(groups.get(groupIndex), tracks);
DefaultTrackSelector.Parameters.Builder selectionParameters = trackSelector.getParameters()
.buildUpon()
.setExceedAudioConstraintsIfNecessary(true)
.setExceedRendererCapabilitiesIfNecessary(true)
.setExceedVideoConstraintsIfNecessary(true)
.setRendererDisabled(rendererIndex, false)
.clearOverridesOfType(selectionOverride.getType());
DefaultTrackSelector.Parameters.Builder selectionParameters = trackSelector.getParameters()
.buildUpon()
.setExceedAudioConstraintsIfNecessary(true)
.setExceedRendererCapabilitiesIfNecessary(true)
.setExceedVideoConstraintsIfNecessary(true)
.setRendererDisabled(rendererIndex, false);
if (trackType == C.TRACK_TYPE_VIDEO && isUsingVideoABR()) {
selectionParameters.setMaxVideoBitrate(maxBitRate == 0 ? Integer.MAX_VALUE : maxBitRate);
} else {
selectionParameters.addOverride(selectionOverride);
// Clear existing overrides for this track type to avoid conflicts
// But be careful with audio tracks - don't clear unless explicitly selecting a different track
if (trackType != C.TRACK_TYPE_AUDIO || !type.equals("default")) {
selectionParameters.clearOverridesOfType(selectionOverride.getType());
}
if (trackType == C.TRACK_TYPE_VIDEO && isUsingVideoABR()) {
selectionParameters.setMaxVideoBitrate(maxBitRate == 0 ? Integer.MAX_VALUE : maxBitRate);
} else {
selectionParameters.addOverride(selectionOverride);
}
// For audio tracks, ensure audio renderer stays enabled
if (trackType == C.TRACK_TYPE_AUDIO) {
selectionParameters.setForceHighestSupportedBitrate(false);
selectionParameters.setForceLowestBitrate(false);
DebugLog.d(TAG, "Audio track selection: group=" + groupIndex + ", tracks=" + tracks +
", override=" + selectionOverride);
}
trackSelector.setParameters(selectionParameters.build());
DebugLog.d(TAG, "Applied track selection for type: " + trackType + ", group: " + groupIndex);
} catch (Exception e) {
DebugLog.e(TAG, "Error applying track selection: " + e.getMessage());
e.printStackTrace();
}
trackSelector.setParameters(selectionParameters.build());
}
private boolean isFormatSupported(Format format) {
@@ -2252,13 +2406,17 @@ public class ReactExoplayerView extends FrameLayout implements
public void setSelectedAudioTrack(String type, String value) {
audioTrackType = type;
audioTrackValue = value;
setSelectedTrack(C.TRACK_TYPE_AUDIO, audioTrackType, audioTrackValue);
if (!controls && player != null && trackSelector != null) {
setSelectedTrack(C.TRACK_TYPE_AUDIO, audioTrackType, audioTrackValue);
}
}
public void setSelectedTextTrack(String type, String value) {
textTrackType = type;
textTrackValue = value;
setSelectedTrack(C.TRACK_TYPE_TEXT, textTrackType, textTrackValue);
selectTextTrackInternal(type, value);
}
public void setPausedModifier(boolean paused) {
@@ -2452,18 +2610,6 @@ public class ReactExoplayerView extends FrameLayout implements
return preventsDisplaySleepDuringVideoPlayback;
}
private void updateFullScreenButtonVisibility() {
if (playerControlView != null) {
final ImageButton fullScreenButton = playerControlView.findViewById(R.id.exo_fullscreen);
//Handling the fullScreenButton click event
if (isFullscreen && fullScreenPlayerView != null && !fullScreenPlayerView.isShowing()) {
fullScreenButton.setVisibility(GONE);
} else {
fullScreenButton.setVisibility(VISIBLE);
}
}
}
public void setDisableDisconnectError(boolean disableDisconnectError) {
this.disableDisconnectError = disableDisconnectError;
}
@@ -2480,7 +2626,7 @@ public class ReactExoplayerView extends FrameLayout implements
}
if (isFullscreen) {
fullScreenPlayerView = new FullScreenPlayerView(getContext(), exoPlayerView, this, playerControlView, new OnBackPressedCallback(true) {
fullScreenPlayerView = new FullScreenPlayerView(getContext(), exoPlayerView, this, null, new OnBackPressedCallback(true) {
@Override
public void handleOnBackPressed() {
setFullscreen(false);
@@ -2504,12 +2650,6 @@ public class ReactExoplayerView extends FrameLayout implements
eventEmitter.onVideoFullscreenPlayerDidDismiss.invoke();
});
}
// need to be done at the end to avoid hiding fullscreen control button when fullScreenPlayerView is shown
updateFullScreenButtonVisibility();
}
public void setHideShutterView(boolean hideShutterView) {
exoPlayerView.setHideShutterView(hideShutterView);
}
@Override
@@ -2550,14 +2690,17 @@ public class ReactExoplayerView extends FrameLayout implements
*/
public void setControls(boolean controls) {
this.controls = controls;
if (exoPlayerView != null) {
exoPlayerView.setUseController(controls);
// Additional configuration for proper touch handling
if (controls) {
exoPlayerView.setControllerAutoShow(true);
exoPlayerView.setControllerHideOnTouch(true); // Show controls on touch, don't hide
exoPlayerView.setControllerShowTimeoutMs(5000);
}
}
if (controls) {
addPlayerControl();
updateFullScreenButtonVisibility();
} else {
int indexOfPC = indexOfChild(playerControlView);
if (indexOfPC != -1) {
removeViewAt(indexOfPC);
}
}
refreshControlsStyles();
}

View File

@@ -52,7 +52,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
private const val PROP_SELECTED_VIDEO_TRACK = "selectedVideoTrack"
private const val PROP_SELECTED_VIDEO_TRACK_TYPE = "type"
private const val PROP_SELECTED_VIDEO_TRACK_VALUE = "value"
private const val PROP_HIDE_SHUTTER_VIEW = "hideShutterView"
private const val PROP_CONTROLS = "controls"
private const val PROP_SUBTITLE_STYLE = "subtitleStyle"
private const val PROP_SHUTTER_COLOR = "shutterColor"
@@ -227,11 +226,6 @@ class ReactExoplayerViewManager(private val config: ReactExoplayerConfig) : View
videoView.setViewType(viewType)
}
@ReactProp(name = PROP_HIDE_SHUTTER_VIEW, defaultBoolean = false)
fun setHideShutterView(videoView: ReactExoplayerView, hideShutterView: Boolean) {
videoView.setHideShutterView(hideShutterView)
}
@ReactProp(name = PROP_CONTROLS, defaultBoolean = false)
fun setControls(videoView: ReactExoplayerView, controls: Boolean) {
videoView.setControls(controls)

View File

@@ -75,6 +75,10 @@ const BasicExample = () => {
useState(false);
const [isSeeking, setIsSeeking] = useState(false);
// Add refs to store previous track data for comparison
const previousAudioTracksRef = useRef<AudioTrack[]>([]);
const previousTextTracksRef = useRef<TextTrack[]>([]);
const videoRef = useRef<VideoRef>(null);
const viewStyle = fullscreen ? styles.fullScreen : styles.halfScreen;
const currentSrc = srcList[srcListId];
@@ -107,7 +111,36 @@ const BasicExample = () => {
const onAudioTracks = (data: OnAudioTracksData) => {
console.log('onAudioTracks', data);
const selectedTrack = data.audioTracks?.find((x: AudioTrack) => {
// Check if audio tracks have actually changed
const currentTracks = data.audioTracks || [];
const previousTracks = previousAudioTracksRef.current;
// Simple comparison - check if tracks array length or selected track changed
const tracksChanged =
currentTracks.length !== previousTracks.length ||
JSON.stringify(
currentTracks.map((t) => ({
index: t.index,
selected: t.selected,
language: t.language,
})),
) !==
JSON.stringify(
previousTracks.map((t) => ({
index: t.index,
selected: t.selected,
language: t.language,
})),
);
if (!tracksChanged) {
return; // Skip if tracks haven't changed
}
previousAudioTracksRef.current = currentTracks;
const selectedTrack = currentTracks.find((x: AudioTrack) => {
return x.selected;
});
let value;
@@ -118,7 +151,7 @@ const BasicExample = () => {
} else if (audioTracksSelectionBy === SelectedTrackType.TITLE) {
value = selectedTrack?.title;
}
setAudioTracks(data.audioTracks);
setAudioTracks(currentTracks);
setSelectedAudioTrack({
type: audioTracksSelectionBy,
value: value,
@@ -131,11 +164,39 @@ const BasicExample = () => {
};
const onTextTracks = (data: OnTextTracksData) => {
const selectedTrack = data.textTracks?.find((x: TextTrack) => {
// Check if text tracks have actually changed
const currentTracks = data.textTracks || [];
const previousTracks = previousTextTracksRef.current;
// Simple comparison - check if tracks array length or selected track changed
const tracksChanged =
currentTracks.length !== previousTracks.length ||
JSON.stringify(
currentTracks.map((t) => ({
index: t.index,
selected: t.selected,
language: t.language,
})),
) !==
JSON.stringify(
previousTracks.map((t) => ({
index: t.index,
selected: t.selected,
language: t.language,
})),
);
if (!tracksChanged) {
return; // Skip if tracks haven't changed
}
previousTextTracksRef.current = currentTracks;
const selectedTrack = currentTracks.find((x: TextTrack) => {
return x?.selected;
});
setTextTracks(data.textTracks);
setTextTracks(currentTracks);
let value;
if (textTracksSelectionBy === SelectedTrackType.INDEX) {
value = selectedTrack?.index;

View File

@@ -28,7 +28,7 @@ export const AudioTrackSelector = ({
style={styles.picker}
itemStyle={styles.pickerItem}
selectedValue={selectedAudioTrack?.value}
onValueChange={itemValue => {
onValueChange={(itemValue) => {
if (itemValue !== 'empty') {
console.log('on audio value change ' + itemValue);
onValueChange(itemValue);
@@ -39,7 +39,7 @@ export const AudioTrackSelector = ({
) : (
<Picker.Item label={'none'} value={'none'} key={'none'} />
)}
{audioTracks.map(track => {
{audioTracks.map((track) => {
if (!track) {
return;
}
@@ -53,7 +53,9 @@ export const AudioTrackSelector = ({
}
return (
<Picker.Item
label={`${value} - ${track.selected}`}
label={`${
track.title || track.language || `Track ${track.index}`
}${track.selected ? ' (Selected)' : ''}`}
value={`${value}`}
key={`${value}`}
/>

View File

@@ -28,7 +28,7 @@ export const TextTrackSelector = ({
style={styles.picker}
itemStyle={styles.pickerItem}
selectedValue={`${selectedTextTrack?.value}`}
onValueChange={itemValue => {
onValueChange={(itemValue) => {
if (itemValue !== 'empty') {
onValueChange(itemValue);
}
@@ -38,7 +38,7 @@ export const TextTrackSelector = ({
) : (
<Picker.Item label={'none'} value={'none'} key={'none'} />
)}
{textTracks.map(track => {
{textTracks.map((track) => {
if (!track) {
return;
}
@@ -50,7 +50,15 @@ export const TextTrackSelector = ({
} else if (textTracksSelectionBy === SelectedTrackType.TITLE) {
value = track.title;
}
return <Picker.Item label={`${value}`} value={value} key={value} />;
return (
<Picker.Item
label={`${
track.title || track.language || `Subtitle ${track.index}`
}${track.selected ? ' (Selected)' : ''}`}
value={value}
key={value}
/>
);
})}
</Picker>
</>

View File

@@ -120,9 +120,28 @@ export const srcAllPlatformList = [
ad: {
adTagUrl:
'https://pubads.g.doubleclick.net/gampad/ads?iu=/21775744923/external/vmap_ad_samples&sz=640x480&cust_params=sample_ar%3Dpremidpostoptimizedpodbumper&ciu_szs=300x250&gdfp_req=1&ad_rule=1&output=vmap&unviewed_position_start=1&env=vp&impl=s&cmsid=496&vid=short_onecue&correlator=',
},
},
uri: 'https://d23dyxeqlo5psv.cloudfront.net/big_buck_bunny.mp4',
},
{
description: 'sintel with subtitles',
uri: 'https://bitmovin-a.akamaihd.net/content/sintel/hls/playlist.m3u8',
},
{
description: '(hls) Apple Advanced',
uri: 'https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8',
type: 'm3u8',
},
{
description: 'elephants dream - multi-language audio & subtitles',
uri: 'https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/index.m3u8',
type: 'm3u8',
},
{
description: 'tears of steel',
uri: 'https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8',
type: 'm3u8',
},
];
export const srcIosList: SampleVideoSource[] = [];
@@ -172,6 +191,25 @@ export const srcAndroidList: SampleVideoSource[] = [
uri: 'rtsp://rtspstream:3cfa3c36a9c00f4aa38f3cd35816b287@zephyr.rtsp.stream/movie',
type: 'rtsp',
},
{
description: 'sintel with subtitles',
uri: 'https://bitmovin-a.akamaihd.net/content/sintel/hls/playlist.m3u8',
},
{
description: '(hls) Apple Advanced',
uri: 'https://devstreaming-cdn.apple.com/videos/streaming/examples/img_bipbop_adv_example_ts/master.m3u8',
type: 'm3u8',
},
{
description: 'elephants dream - multi-language audio & subtitles',
uri: 'https://playertest.longtailvideo.com/adaptive/elephants_dream_v4/index.m3u8',
type: 'm3u8',
},
{
description: 'tears of steel',
uri: 'https://demo.unified-streaming.com/k8s/features/stable/video/tears-of-steel/tears-of-steel.ism/.m3u8',
type: 'm3u8',
},
];
const platformSrc: SampleVideoSource[] = isAndroid

View File

@@ -357,11 +357,11 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.ExpoExample;
PRODUCT_NAME = "ExpoExample";
PRODUCT_NAME = ExpoExample;
SWIFT_OBJC_BRIDGING_HEADER = "ExpoExample/ExpoExample-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1";
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Debug;
@@ -385,10 +385,10 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.anonymous.ExpoExample;
PRODUCT_NAME = "ExpoExample";
PRODUCT_NAME = ExpoExample;
SWIFT_OBJC_BRIDGING_HEADER = "ExpoExample/ExpoExample-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1";
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
};
name = Release;

File diff suppressed because it is too large Load Diff

View File

@@ -640,7 +640,11 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
DebugLog("setSrc Stopping playback")
return
}
self.removePlayerLayer()
// Ensure UI operations are performed on main thread
DispatchQueue.main.sync {
self.removePlayerLayer()
}
self._playerObserver.player = nil
self._drmManager = nil
self._playerObserver.playerItem = nil
@@ -1226,14 +1230,18 @@ class RCTVideo: UIView, RCTVideoPlayerViewControllerDelegate, RCTPlayerObserverH
if _controls != controls || ((_playerLayer == nil) && (_playerViewController == nil)) {
_controls = controls
if _controls {
self.removePlayerLayer()
self.usePlayerViewController()
DispatchQueue.main.async {
self.removePlayerLayer()
self.usePlayerViewController()
}
} else {
_playerViewController?.view.removeFromSuperview()
_playerViewController?.removeFromParent()
_playerViewController = nil
_playerObserver.playerViewController = nil
self.usePlayerLayer()
DispatchQueue.main.async {
self._playerViewController?.view.removeFromSuperview()
self._playerViewController?.removeFromParent()
self._playerViewController = nil
self._playerObserver.playerViewController = nil
self.usePlayerLayer()
}
}
}
}