/*
* Copyright 2023 Dora Lee
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.sanghun.compose.video
import android.annotation.SuppressLint
import android.content.pm.ActivityInfo
import android.graphics.Color
import android.os.Handler
import android.os.Looper
import android.widget.ImageButton
import androidx.activity.compose.BackHandler
import androidx.annotation.FloatRange
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.SecureFlagPolicy
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.C.AUDIO_CONTENT_TYPE_MOVIE
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.util.RepeatModeUtil.REPEAT_TOGGLE_MODE_ALL
import androidx.media3.common.util.RepeatModeUtil.REPEAT_TOGGLE_MODE_NONE
import androidx.media3.common.util.RepeatModeUtil.REPEAT_TOGGLE_MODE_ONE
import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.HttpDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.MediaSession
import androidx.media3.ui.PlayerView
import io.sanghun.compose.video.cache.VideoPlayerCacheManager
import io.sanghun.compose.video.controller.VideoPlayerControllerConfig
import io.sanghun.compose.video.controller.applyToExoPlayerView
import io.sanghun.compose.video.pip.enterPIPMode
import io.sanghun.compose.video.pip.isActivityStatePipMode
import io.sanghun.compose.video.uri.VideoPlayerMediaItem
import io.sanghun.compose.video.uri.toUri
import io.sanghun.compose.video.util.findActivity
import io.sanghun.compose.video.util.setFullScreen
import kotlinx.coroutines.delay
import java.util.*
/**
* [VideoPlayer] is UI component that can play video in Jetpack Compose. It works based on ExoPlayer.
* You can play local (e.g. asset files, resource files) files and all video files in the network environment.
* For all video formats supported by the [VideoPlayer] component, see the ExoPlayer link below.
*
* If you rotate the screen, the default action is to reset the player state.
* To prevent this happening, put the following options in the `android:configChanges` option of your app's AndroidManifest.xml to keep the settings.
* ```
* keyboard|keyboardHidden|orientation|screenSize|screenLayout|smallestScreenSize|uiMode
* ```
*
* This component is linked with Compose [androidx.compose.runtime.DisposableEffect].
* This means that it move out of the Composable Scope, the ExoPlayer instance will automatically be destroyed as well.
*
* @see Exoplayer Support Formats
*
* @param modifier Modifier to apply to this layout node.
* @param mediaItems [VideoPlayerMediaItem] to be played by the video player. The reason for receiving media items as an array is to configure multi-track. If it's a single track, provide a single list (e.g. listOf(mediaItem)).
* @param handleLifecycle Sets whether to automatically play/stop the player according to the activity lifecycle. Default is true.
* @param autoPlay Autoplay when media item prepared. Default is true.
* @param usePlayerController Using player controller. Default is true.
* @param controllerConfig Player controller config. You can customize the Video Player Controller UI.
* @param seekBeforeMilliSeconds The seek back increment, in milliseconds. Default is 10sec (10000ms). Read-only props (Changes in values do not take effect.)
* @param seekAfterMilliSeconds The seek forward increment, in milliseconds. Default is 10sec (10000ms). Read-only props (Changes in values do not take effect.)
* @param repeatMode Sets the content repeat mode.
* @param volume Sets thie player volume. It's possible from 0.0 to 1.0.
* @param onCurrentTimeChanged A callback that returned once every second for player current time when the player is playing.
* @param fullScreenSecurePolicy Windows security settings to apply when full screen. Default is off. (For example, avoid screenshots that are not DRM-applied.)
* @param onFullScreenEnter A callback that occurs when the player is full screen. (The [VideoPlayerControllerConfig.showFullScreenButton] must be true to trigger a callback.)
* @param onFullScreenExit A callback that occurs when the full screen is turned off. (The [VideoPlayerControllerConfig.showFullScreenButton] must be true to trigger a callback.)
* @param enablePip Enable PIP (Picture-in-Picture).
* @param enablePipWhenBackPressed With [enablePip] is `true`, set whether to enable PIP mode even when you press Back. Default is false.
* @param handleAudioFocus Set whether to handle the video playback control automatically when it is playing in PIP mode and media is played in another app. Default is true.
* @param playerInstance Return exoplayer instance. This instance allows you to add [androidx.media3.exoplayer.analytics.AnalyticsListener] to receive various events from the player.
* @param httpDataSourceFactory set the HttpDataSourceFactory.
*/
@SuppressLint("SourceLockedOrientationActivity", "UnsafeOptInUsageError")
@Composable
fun VideoPlayer(
modifier: Modifier = Modifier,
mediaItems: List,
handleLifecycle: Boolean = true,
autoPlay: Boolean = true,
usePlayerController: Boolean = true,
controllerConfig: VideoPlayerControllerConfig = VideoPlayerControllerConfig.Default,
seekBeforeMilliSeconds: Long = 10000L,
seekAfterMilliSeconds: Long = 10000L,
repeatMode: RepeatMode = RepeatMode.NONE,
resizeMode: ResizeMode = ResizeMode.FIT,
@FloatRange(from = 0.0, to = 1.0) volume: Float = 1f,
onCurrentTimeChanged: (Long) -> Unit = {},
fullScreenSecurePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit,
onFullScreenEnter: () -> Unit = {},
onFullScreenExit: () -> Unit = {},
enablePip: Boolean = false,
enablePipWhenBackPressed: Boolean = false,
handleAudioFocus: Boolean = true,
playerInstance: ExoPlayer.() -> Unit = {},
httpDataSourceFactory: HttpDataSource.Factory = remember {
DefaultHttpDataSource.Factory()
},
) {
val context = LocalContext.current
var currentTime by remember { mutableStateOf(0L) }
var mediaSession = remember { null }
val player = remember {
ExoPlayer.Builder(context)
.setSeekBackIncrementMs(seekBeforeMilliSeconds)
.setSeekForwardIncrementMs(seekAfterMilliSeconds)
.setAudioAttributes(
AudioAttributes.Builder()
.setContentType(AUDIO_CONTENT_TYPE_MOVIE)
.setUsage(C.USAGE_MEDIA)
.build(),
handleAudioFocus,
)
.apply {
val cache = VideoPlayerCacheManager.getCache()
if (cache != null) {
val cacheDataSourceFactory = CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(DefaultDataSource.Factory(context, httpDataSourceFactory))
setMediaSourceFactory(DefaultMediaSourceFactory(cacheDataSourceFactory))
} else {
setMediaSourceFactory(DefaultMediaSourceFactory(httpDataSourceFactory))
}
}
.build()
.also(playerInstance)
}
val defaultPlayerView = remember {
PlayerView(context)
}
BackHandler(enablePip && enablePipWhenBackPressed) {
enterPIPMode(context, defaultPlayerView)
player.play()
}
LaunchedEffect(Unit) {
while (true) {
delay(1000)
if (currentTime != player.currentPosition) {
onCurrentTimeChanged(currentTime)
}
currentTime = player.currentPosition
}
}
LaunchedEffect(usePlayerController) {
defaultPlayerView.useController = usePlayerController
}
LaunchedEffect(player) {
defaultPlayerView.player = player
}
LaunchedEffect(mediaItems, player) {
mediaSession?.release()
mediaSession = MediaSession.Builder(context, ForwardingPlayer(player))
.setId("VideoPlayerMediaSession_${UUID.randomUUID().toString().lowercase().split("-").first()}")
.build()
val exoPlayerMediaItems = mediaItems.map {
val uri = it.toUri(context)
MediaItem.Builder().apply {
setUri(uri)
setMediaMetadata(it.mediaMetadata)
setMimeType(it.mimeType)
setDrmConfiguration(
if (it is VideoPlayerMediaItem.NetworkMediaItem) {
it.drmConfiguration
} else {
null
},
)
}.build()
}
player.setMediaItems(exoPlayerMediaItems)
player.prepare()
if (autoPlay) {
player.play()
}
}
var isFullScreenModeEntered by remember { mutableStateOf(false) }
LaunchedEffect(controllerConfig) {
controllerConfig.applyToExoPlayerView(defaultPlayerView) {
isFullScreenModeEntered = it
if (it) {
onFullScreenEnter()
}
}
}
LaunchedEffect(controllerConfig, repeatMode) {
defaultPlayerView.setRepeatToggleModes(
if (controllerConfig.showRepeatModeButton) {
REPEAT_TOGGLE_MODE_ALL or REPEAT_TOGGLE_MODE_ONE
} else {
REPEAT_TOGGLE_MODE_NONE
},
)
player.repeatMode = repeatMode.toExoPlayerRepeatMode()
}
LaunchedEffect(volume) {
player.volume = volume
}
VideoPlayerSurface(
modifier = modifier,
defaultPlayerView = defaultPlayerView,
player = player,
usePlayerController = usePlayerController,
handleLifecycle = handleLifecycle,
enablePip = enablePip,
surfaceResizeMode = resizeMode,
)
if (isFullScreenModeEntered) {
var fullScreenPlayerView by remember { mutableStateOf(null) }
VideoPlayerFullScreenDialog(
player = player,
currentPlayerView = defaultPlayerView,
controllerConfig = controllerConfig,
repeatMode = repeatMode,
resizeMode = resizeMode,
onDismissRequest = {
fullScreenPlayerView?.let {
PlayerView.switchTargetView(player, it, defaultPlayerView)
defaultPlayerView.findViewById(androidx.media3.ui.R.id.exo_fullscreen)
.performClick()
val currentActivity = context.findActivity()
currentActivity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
currentActivity.setFullScreen(false)
onFullScreenExit()
}
isFullScreenModeEntered = false
},
securePolicy = fullScreenSecurePolicy,
enablePip = enablePip,
fullScreenPlayerView = {
fullScreenPlayerView = this
},
)
}
}
@SuppressLint("UnsafeOptInUsageError")
@Composable
internal fun VideoPlayerSurface(
modifier: Modifier = Modifier,
defaultPlayerView: PlayerView,
player: ExoPlayer,
usePlayerController: Boolean,
handleLifecycle: Boolean,
enablePip: Boolean,
surfaceResizeMode: ResizeMode,
onPipEntered: () -> Unit = {},
autoDispose: Boolean = true,
) {
val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current)
val context = LocalContext.current
var isPendingPipMode by remember { mutableStateOf(false) }
DisposableEffect(
AndroidView(
modifier = modifier,
factory = {
defaultPlayerView.apply {
useController = usePlayerController
resizeMode = surfaceResizeMode.toPlayerViewResizeMode()
setBackgroundColor(Color.BLACK)
}
},
),
) {
val observer = LifecycleEventObserver { _, event ->
when (event) {
Lifecycle.Event.ON_PAUSE -> {
if (handleLifecycle) {
player.pause()
}
if (enablePip && player.playWhenReady) {
isPendingPipMode = true
Handler(Looper.getMainLooper()).post {
enterPIPMode(context, defaultPlayerView)
onPipEntered()
Handler(Looper.getMainLooper()).postDelayed({
isPendingPipMode = false
}, 500)
}
}
}
Lifecycle.Event.ON_RESUME -> {
if (handleLifecycle) {
player.play()
}
if (enablePip && player.playWhenReady) {
defaultPlayerView.useController = usePlayerController
}
}
Lifecycle.Event.ON_STOP -> {
val isPipMode = context.isActivityStatePipMode()
if (handleLifecycle || (enablePip && isPipMode && !isPendingPipMode)) {
player.stop()
}
}
else -> {}
}
}
val lifecycle = lifecycleOwner.value.lifecycle
lifecycle.addObserver(observer)
onDispose {
if (autoDispose) {
player.release()
lifecycle.removeObserver(observer)
}
}
}
}