From 78f4f0480d70d209fea9e0579963e347c965fd6e Mon Sep 17 00:00:00 2001 From: "HyunWoo Lee (Nunu Lee)" <54518925+l2hyunwoo@users.noreply.github.com> Date: Fri, 11 Oct 2024 16:08:13 +0900 Subject: [PATCH] feat(exoplayerview): Migrate ExoPlayerView to kotlin (#4038) --- .../brentvatne/exoplayer/ExoPlayerView.java | 334 ---------------- .../com/brentvatne/exoplayer/ExoPlayerView.kt | 355 ++++++++++++++++++ 2 files changed, 355 insertions(+), 334 deletions(-) delete mode 100644 android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java create mode 100644 android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt diff --git a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java deleted file mode 100644 index 78d15763..00000000 --- a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.java +++ /dev/null @@ -1,334 +0,0 @@ -package com.brentvatne.exoplayer; - -import android.annotation.SuppressLint; -import android.content.Context; - -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; -import androidx.media3.common.AdViewProvider; -import androidx.media3.common.C; -import androidx.media3.common.Format; -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.exoplayer.ExoPlayer; -import androidx.media3.ui.SubtitleView; - -import android.util.TypedValue; -import android.view.Gravity; -import android.view.SurfaceView; -import android.view.TextureView; -import android.view.View; -import android.view.ViewGroup; -import android.widget.FrameLayout; - -import com.brentvatne.common.api.ResizeMode; -import com.brentvatne.common.api.SubtitleStyle; -import com.brentvatne.common.api.ViewType; -import com.brentvatne.common.toolbox.DebugLog; -import com.google.common.collect.ImmutableList; - -import java.util.List; - -@SuppressLint("ViewConstructor") -public final class ExoPlayerView extends FrameLayout implements AdViewProvider { - private final static String TAG = "ExoPlayerView"; - private View surfaceView; - private final View shutterView; - private final SubtitleView subtitleLayout; - private final AspectRatioFrameLayout layout; - private final ComponentListener componentListener; - private ExoPlayer player; - private final Context context; - private final ViewGroup.LayoutParams layoutParams; - private final FrameLayout adOverlayFrameLayout; - - private @ViewType.ViewType int viewType = ViewType.VIEW_TYPE_SURFACE; - private boolean hideShutterView = false; - - private SubtitleStyle localStyle = new SubtitleStyle(); - - public ExoPlayerView(Context context) { - super(context, null, 0); - - this.context = context; - - layoutParams = new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT); - - componentListener = new ComponentListener(); - - FrameLayout.LayoutParams aspectRatioParams = new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT); - aspectRatioParams.gravity = Gravity.CENTER; - layout = new AspectRatioFrameLayout(context); - layout.setLayoutParams(aspectRatioParams); - - shutterView = new View(getContext()); - shutterView.setLayoutParams(layoutParams); - shutterView.setBackgroundColor(ContextCompat.getColor(context, android.R.color.black)); - - subtitleLayout = new SubtitleView(context); - subtitleLayout.setLayoutParams(layoutParams); - subtitleLayout.setUserDefaultStyle(); - subtitleLayout.setUserDefaultTextSize(); - - updateSurfaceView(viewType); - - adOverlayFrameLayout = new FrameLayout(context); - - layout.addView(shutterView, 1, layoutParams); - if (localStyle.getSubtitlesFollowVideo()) { - layout.addView(subtitleLayout, layoutParams); - layout.addView(adOverlayFrameLayout, layoutParams); - } - - addViewInLayout(layout, 0, aspectRatioParams); - if (!localStyle.getSubtitlesFollowVideo()) { - addViewInLayout(subtitleLayout, 1, layoutParams); - } - } - - public void showAds() { - adOverlayFrameLayout.setVisibility(View.GONE); - } - public void hideAds() { - adOverlayFrameLayout.setVisibility(View.VISIBLE); - } - - private void clearVideoView() { - if (surfaceView instanceof TextureView) { - player.clearVideoTextureView((TextureView) surfaceView); - } else if (surfaceView instanceof SurfaceView) { - player.clearVideoSurfaceView((SurfaceView) surfaceView); - } - } - - private void setVideoView() { - if (surfaceView instanceof TextureView) { - player.setVideoTextureView((TextureView) surfaceView); - } else if (surfaceView instanceof SurfaceView) { - player.setVideoSurfaceView((SurfaceView) surfaceView); - } - } - - public boolean isPlaying() { - return player != null && player.isPlaying(); - } - - public void setSubtitleStyle(SubtitleStyle style) { - // ensure we reset subtitle style before reapplying it - subtitleLayout.setUserDefaultStyle(); - subtitleLayout.setUserDefaultTextSize(); - - if (style.getFontSize() > 0) { - subtitleLayout.setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.getFontSize()); - } - subtitleLayout.setPadding(style.getPaddingLeft(), style.getPaddingTop(), style.getPaddingRight(), style.getPaddingBottom()); - if (style.getOpacity() != 0) { - subtitleLayout.setAlpha(style.getOpacity()); - subtitleLayout.setVisibility(View.VISIBLE); - } else { - subtitleLayout.setVisibility(View.GONE); - } - if (localStyle.getSubtitlesFollowVideo() != style.getSubtitlesFollowVideo()) { - // No need to manipulate layout if value didn't change - if (style.getSubtitlesFollowVideo()) { - removeViewInLayout(subtitleLayout); - layout.addView(subtitleLayout, layoutParams); - } else { - layout.removeViewInLayout(subtitleLayout); - addViewInLayout(subtitleLayout, 1, layoutParams, false); - } - requestLayout(); - } - localStyle = style; - } - - public void setShutterColor(Integer color) { - shutterView.setBackgroundColor(color); - } - - public void updateSurfaceView(@ViewType.ViewType int viewType) { - this.viewType = viewType; - boolean viewNeedRefresh = false; - if (viewType == ViewType.VIEW_TYPE_SURFACE || viewType == ViewType.VIEW_TYPE_SURFACE_SECURE) { - if (!(surfaceView instanceof SurfaceView)) { - surfaceView = new SurfaceView(context); - viewNeedRefresh = true; - } - ((SurfaceView)surfaceView).setSecure(viewType == ViewType.VIEW_TYPE_SURFACE_SECURE); - } else if (viewType == ViewType.VIEW_TYPE_TEXTURE) { - if (!(surfaceView instanceof TextureView)) { - surfaceView = new TextureView(context); - viewNeedRefresh = true; - } - // Support opacity properly: - ((TextureView) surfaceView).setOpaque(false); - } else { - DebugLog.wtf(TAG, "wtf is this texture " + viewType); - } - if (viewNeedRefresh) { - surfaceView.setLayoutParams(layoutParams); - - if (layout.getChildAt(0) != null) { - layout.removeViewAt(0); - } - layout.addView(surfaceView, 0, layoutParams); - - if (this.player != null) { - setVideoView(); - } - } - } - - private void hideShutterView() { - shutterView.setVisibility(INVISIBLE); - surfaceView.setAlpha(1); - } - - private void showShutterView() { - shutterView.setVisibility(VISIBLE); - surfaceView.setAlpha(0); - } - - public void updateShutterViewVisibility() { - if (this.hideShutterView) { - hideShutterView(); - } else { - showShutterView(); - } - } - - @Override - public void requestLayout() { - super.requestLayout(); - post(measureAndLayout); - } - - // AdsLoader.AdViewProvider implementation. - - @Override - public ViewGroup getAdViewGroup() { - return 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. - */ - public void setPlayer(ExoPlayer player) { - if (this.player == player) { - return; - } - if (this.player != null) { - this.player.removeListener(componentListener); - clearVideoView(); - } - this.player = player; - - updateShutterViewVisibility(); - - if (player != null) { - setVideoView(); - player.addListener(componentListener); - } - } - - /** - * Sets the resize mode which can be of value {@link ResizeMode.Mode} - * - * @param resizeMode The resize mode. - */ - public void setResizeMode(@ResizeMode.Mode int resizeMode) { - if (layout != null && layout.getResizeMode() != resizeMode) { - layout.setResizeMode(resizeMode); - post(measureAndLayout); - } - } - - public void setHideShutterView(boolean hideShutterView) { - this.hideShutterView = hideShutterView; - updateShutterViewVisibility(); - } - - private final Runnable measureAndLayout = () -> { - measure( - MeasureSpec.makeMeasureSpec(getWidth(), MeasureSpec.EXACTLY), - MeasureSpec.makeMeasureSpec(getHeight(), MeasureSpec.EXACTLY)); - layout(getLeft(), getTop(), getRight(), getBottom()); - }; - - private void updateForCurrentTrackSelections(Tracks tracks) { - if (tracks == null) { - return; - } - ImmutableList groups = tracks.getGroups(); - for (Tracks.Group group: groups) { - if (group.getType() == C.TRACK_TYPE_VIDEO && group.length > 0) { - // get the first track of the group to identify aspect ratio - Format format = group.getTrackFormat(0); - - // There are weird cases when video height and width did not change with rotation so we need change aspect ration to fix it - switch (format.rotationDegrees) { - // update aspect ratio ! - case 90: - case 270: - layout.setVideoAspectRatio(format.width == 0 ? 1 : (format.height * format.pixelWidthHeightRatio) / format.width); - break; - default: - layout.setVideoAspectRatio(format.height == 0 ? 1 : (format.width * format.pixelWidthHeightRatio) / format.height); - } - return; - } - } - // no video tracks, in that case refresh shutterView visibility - shutterView.setVisibility(this.hideShutterView ? View.INVISIBLE : View.VISIBLE); - } - - public void invalidateAspectRatio() { - // Resetting aspect ratio will force layout refresh on next video size changed - layout.invalidateAspectRatio(); - } - - private final class ComponentListener implements Player.Listener { - - @Override - public void onCues(@NonNull List cues) { - subtitleLayout.setCues(cues); - } - - @Override - public void onVideoSizeChanged(VideoSize videoSize) { - boolean isInitialRatio = layout.getVideoAspectRatio() == 0; - 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; - } - layout.setVideoAspectRatio((videoSize.width * videoSize.pixelWidthHeightRatio) / videoSize.height); - - // React native workaround for measuring and layout on initial load. - if (isInitialRatio) { - post(measureAndLayout); - } - } - - @Override - public void onRenderedFirstFrame() { - hideShutterView(); - } - - @Override - public void onTracksChanged(@NonNull Tracks tracks) { - updateForCurrentTrackSelections(tracks); - } - } -} diff --git a/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt new file mode 100644 index 00000000..a72381fc --- /dev/null +++ b/android/src/main/java/com/brentvatne/exoplayer/ExoPlayerView.kt @@ -0,0 +1,355 @@ +package com.brentvatne.exoplayer + +import android.content.Context +import android.util.Log +import android.util.TypedValue +import android.view.Gravity +import android.view.SurfaceView +import android.view.TextureView +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.core.content.ContextCompat +import androidx.media3.common.AdViewProvider +import androidx.media3.common.C +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.util.UnstableApi +import androidx.media3.exoplayer.ExoPlayer +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.common.toolbox.DebugLog + +@UnstableApi +class ExoPlayerView(private val context: Context) : + FrameLayout(context, null, 0), + AdViewProvider { + + private var surfaceView: View? = null + 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 + val isPlaying: Boolean + get() = player != null && player?.isPlaying == true + + @ViewType.ViewType + private var viewType = ViewType.VIEW_TYPE_SURFACE + private var hideShutterView = false + + private var localStyle = SubtitleStyle() + + 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) + + adOverlayFrameLayout = FrameLayout(context) + + layout.addView(shutterView, 1, layoutParams) + if (localStyle.subtitlesFollowVideo) { + layout.addView(subtitleLayout, layoutParams) + layout.addView(adOverlayFrameLayout, layoutParams) + } + + addViewInLayout(layout, 0, aspectRatioParams) + if (!localStyle.subtitlesFollowVideo) { + addViewInLayout(subtitleLayout, 1, layoutParams) + } + } + + private fun clearVideoView() { + when (val view = surfaceView) { + is TextureView -> player?.clearVideoTextureView(view) + + is SurfaceView -> player?.clearVideoSurfaceView(view) + + else -> { + Log.w( + "clearVideoView", + "Unexpected surfaceView type: ${surfaceView?.javaClass?.name}" + ) + } + } + } + + private fun setVideoView() { + when (val view = surfaceView) { + is TextureView -> player?.setVideoTextureView(view) + + is SurfaceView -> player?.setVideoSurfaceView(view) + + else -> { + Log.w( + "setVideoView", + "Unexpected surfaceView type: ${surfaceView?.javaClass?.name}" + ) + } + } + } + + fun setSubtitleStyle(style: SubtitleStyle) { + // ensure we reset subtitle style before reapplying it + subtitleLayout.setUserDefaultStyle() + subtitleLayout.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) + } + requestLayout() + } + localStyle = style + } + + fun setShutterColor(color: Int) { + shutterView.setBackgroundColor(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 + } + (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") + } + } + + if (viewNeedRefresh) { + surfaceView?.layoutParams = layoutParams + + if (layout.getChildAt(0) != null) { + layout.removeViewAt(0) + } + layout.addView(surfaceView, 0, layoutParams) + + if (this.player != null) { + setVideoView() + } + } + } + + private fun hideShutterView() { + shutterView.setVisibility(INVISIBLE) + surfaceView?.setAlpha(1f) + } + + private fun showShutterView() { + shutterView.setVisibility(VISIBLE) + surfaceView?.setAlpha(0f) + } + + fun showAds() { + adOverlayFrameLayout.setVisibility(View.VISIBLE) + } + + fun hideAds() { + adOverlayFrameLayout.setVisibility(View.GONE) + } + + 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) { + return + } + if (this.player != null) { + this.player!!.removeListener(componentListener) + clearVideoView() + } + this.player = player + + updateShutterViewVisibility() + if (player != null) { + setVideoView() + player.addListener(componentListener) + } + } + + /** + * Sets the resize mode which can be of value {@link ResizeMode.Mode} + * + * @param resizeMode The resize mode. + */ + 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 { + measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(height, MeasureSpec.EXACTLY) + ) + 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) + + // There are weird cases when video height and width did not change with rotation so we need change aspect ration to fix + layout.videoAspectRatio = when (format.rotationDegrees) { + // update aspect ratio ! + 90, 270 -> if (format.width == 0) { + 1f + } else { + (format.height * format.pixelWidthHeightRatio) / format.width + } + + else -> if (format.height == 0) { + 1f + } else { + (format.width * format.pixelWidthHeightRatio) / format.height + } + } + 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) { + subtitleLayout.setCues(cues) + } + + override fun onVideoSizeChanged(videoSize: VideoSize) { + val isInitialRatio = layout.videoAspectRatio == 0f + 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 + } + layout.videoAspectRatio = + ((videoSize.width * videoSize.pixelWidthHeightRatio) / videoSize.height) + + // React native workaround for measuring and layout on initial load. + if (isInitialRatio) { + post(measureAndLayout) + } + } + + override fun onRenderedFirstFrame() { + shutterView.visibility = INVISIBLE + } + + override fun onTracksChanged(tracks: Tracks) { + updateForCurrentTrackSelections(tracks) + } + } + + companion object { + private const val TAG = "ExoPlayerView" + } +}