* docs: enable Android PIP * chore: change comments * feat(android): implement Android PictureInPicture * refactor: minor refactor code and apply lint * fix: rewrite pip action intent code for Android14 * fix: remove redundant codes * feat: add isInPictureInPicture flag for lifecycle handling - activity provide helper method for same purpose, but this flag makes code simple * feat: add pipFullscreenPlayerView for makes PIP include video only * fix: add manifest value checker for prevent crash * docs: add pictureInPicture prop's Android guide * fix: sync controller visibility * refactor: refining variable name * fix: check multi window mode when host pause - some OS version call onPause on multi-window mode * fix: handling when onStop is called while in multi-window mode * refactor: enhance PIP util codes * fix: fix FullscreenPlayerView constructor * refactor: add enterPictureInPictureOnLeave prop and pip methods - remove pictureInPicture boolean prop - add enterPictureInPictureOnLeave boolean prop - add enterPictureInPicture method - add exitPictureInPicture method * fix: fix lint error * fix: prevent audio play in background without playInBackground prop * fix: fix onDetachedFromWindow * docs: update docs for pip * fix(android): sync pip controller with external controller state - for media session * fix(ios): fix pip active fn variable reference * refactor(ios): refactor code * refactor(android): refactor codes * fix(android): fix lint error * refactor(android): refactor android pip logics * fix(android): fix flickering issue when stop picture in picture * fix(android): fix import * fix(android): fix picture in picture with fullscreen mode * fix(ios): fix syntax error * fix(android): fix Fragment managed code * refactor(android): remove redundant override lifecycle * fix(js): add PIP type definition for codegen * fix(android): fix syntax * chore(android): fix lint error * fix(ios): fix enter background handler * refactor(ios): remove redundant code * fix(ios): fix applicationDidEnterBackground for PIP * fix(android): fix onPictureInPictureStatusChanged * fix(ios): fix RCTPictureInPicture * refactor(android): Ignore exception for some device ignore pip checker - some device ignore PIP availability check, so we need to handle exception to prevent crash * fix(android): add hideWithoutPlayer fn into Kotlin ver * refactor(android): remove redundant code * fix(android): fix pip ratio to be calculated with correct ratio value * fix(android): fix crash issue when unmounting in PIP mode * fix(android): fix lint error * Update android/src/main/java/com/brentvatne/react/VideoManagerModule.kt * fix(android): fix lint error * fix(ios): fix lint error * fix(ios): fix lint error * feat(expo): add android picture in picture config within expo plugin * fix: Replace Fragment with androidx.activity - remove code that uses Fragment, which is a tricky implementation * fix: fix lint error * fix(android): disable auto enter when player released * fix(android): fix event handler to check based on Activity it's bound to --------- Co-authored-by: jonghun <jonghun@toss.im> Co-authored-by: Olivier Bouillet <62574056+freeboub@users.noreply.github.com>
336 lines
10 KiB
Kotlin
336 lines
10 KiB
Kotlin
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 {
|
|
|
|
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
|
|
|
|
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)
|
|
|
|
layout.addView(shutterView, 1, layoutParams)
|
|
if (localStyle.subtitlesFollowVideo) {
|
|
layout.addView(subtitleLayout, 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()
|
|
}
|
|
}
|
|
}
|
|
|
|
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) {
|
|
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)
|
|
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"
|
|
}
|
|
}
|