package com.vitorpamplona.amethyst.ui.components import android.graphics.drawable.Drawable import android.util.Log import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.IconButton import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable 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.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalLifecycleOwner import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isFinite import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.datasource.DataSource import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.hls.HlsMediaSource import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView import coil.imageLoader import coil.request.ImageRequest import com.vitorpamplona.amethyst.VideoCache import com.vitorpamplona.amethyst.service.HttpClient import com.vitorpamplona.amethyst.service.connectivitystatus.ConnectivityStatus import com.vitorpamplona.amethyst.ui.note.MuteIcon import com.vitorpamplona.amethyst.ui.note.MutedIcon import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlin.time.ExperimentalTime import kotlin.time.measureTimedValue public var DefaultMutedSetting = mutableStateOf(true) @Composable fun LoadThumbAndThenVideoView( videoUri: String, description: String? = null, thumbUri: String, accountViewModel: AccountViewModel, onDialog: ((Boolean) -> Unit)? = null ) { var loadingFinished by remember { mutableStateOf>(Pair(false, null)) } val context = LocalContext.current LaunchedEffect(Unit) { launch(Dispatchers.IO) { try { val request = ImageRequest.Builder(context).data(thumbUri).build() val myCover = context.imageLoader.execute(request).drawable if (myCover != null) { loadingFinished = Pair(true, myCover) } else { loadingFinished = Pair(true, null) } } catch (e: Exception) { Log.e("VideoView", "Fail to load cover $thumbUri", e) loadingFinished = Pair(true, null) } } } if (loadingFinished.first) { if (loadingFinished.second != null) { VideoView(videoUri, description, VideoThumb(loadingFinished.second), accountViewModel, onDialog = onDialog) } else { VideoView(videoUri, description, null, accountViewModel, onDialog = onDialog) } } } @OptIn(ExperimentalTime::class) @Composable fun VideoView( videoUri: String, description: String? = null, thumb: VideoThumb? = null, accountViewModel: AccountViewModel, alwaysShowVideo: Boolean = false, onDialog: ((Boolean) -> Unit)? = null ) { val (value, elapsed) = measureTimedValue { VideoView1(videoUri, description, thumb, onDialog, accountViewModel, alwaysShowVideo) } Log.d("Rendering Metrics", "VideoView $elapsed $videoUri") } @Composable fun VideoView1( videoUri: String, description: String? = null, thumb: VideoThumb? = null, onDialog: ((Boolean) -> Unit)? = null, accountViewModel: AccountViewModel, alwaysShowVideo: Boolean = false ) { var exoPlayerData by remember { mutableStateOf(null) } val defaultToStart by remember { mutableStateOf(DefaultMutedSetting.value) } val context = LocalContext.current LaunchedEffect(key1 = videoUri) { if (exoPlayerData == null) { launch(Dispatchers.Default) { exoPlayerData = VideoPlayer(ExoPlayer.Builder(context).build()) } } } exoPlayerData?.let { VideoView(videoUri, description, it, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo) } DisposableEffect(Unit) { onDispose { exoPlayerData?.exoPlayer?.release() } } } @OptIn(ExperimentalTime::class) @Composable fun VideoView( videoUri: String, description: String? = null, exoPlayerData: VideoPlayer, defaultToStart: Boolean = false, thumb: VideoThumb? = null, onDialog: ((Boolean) -> Unit)? = null, accountViewModel: AccountViewModel, alwaysShowVideo: Boolean = false ) { val (_, elapsed) = measureTimedValue { VideoView1(videoUri, description, exoPlayerData, defaultToStart, thumb, onDialog, accountViewModel, alwaysShowVideo) } Log.d("Rendering Metrics", "VideoView $elapsed $videoUri") } @Composable @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) fun VideoView1( videoUri: String, description: String? = null, exoPlayerData: VideoPlayer, defaultToStart: Boolean = false, thumb: VideoThumb? = null, onDialog: ((Boolean) -> Unit)? = null, accountViewModel: AccountViewModel, alwaysShowVideo: Boolean = false ) { val lifecycleOwner = rememberUpdatedState(LocalLifecycleOwner.current) val media = remember { MediaItem.Builder().setUri(videoUri).build() } val settings = accountViewModel.account.settings val isMobile = ConnectivityStatus.isOnMobileData.value val automaticallyStartPlayback = remember { mutableStateOf( if (alwaysShowVideo) { true } else { when (settings.automaticallyStartPlayback) { true -> !isMobile false -> false else -> true } } ) } exoPlayerData.exoPlayer.apply { repeatMode = Player.REPEAT_MODE_ALL videoScalingMode = C.VIDEO_SCALING_MODE_SCALE_TO_FIT volume = if (defaultToStart) 0f else 1f if (videoUri.startsWith("file")) { setMediaItem(media) } else if (videoUri.endsWith("m3u8")) { // Should not use cache. val dataSourceFactory: DataSource.Factory = OkHttpDataSource.Factory(HttpClient.getHttpClient()) setMediaSource( HlsMediaSource.Factory(dataSourceFactory).createMediaSource( media ) ) } else { setMediaSource( ProgressiveMediaSource.Factory(VideoCache.get()).createMediaSource( media ) ) } prepare() } if (!automaticallyStartPlayback.value) { ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback) } else { RenderVideoPlayer(exoPlayerData, thumb, automaticallyStartPlayback, onDialog) } DisposableEffect(Unit) { val observer = LifecycleEventObserver { _, event -> when (event) { Lifecycle.Event.ON_PAUSE -> { exoPlayerData.exoPlayer.pause() } else -> {} } } val lifecycle = lifecycleOwner.value.lifecycle lifecycle.addObserver(observer) onDispose { lifecycle.removeObserver(observer) } } } @Stable data class VideoPlayer( val exoPlayer: ExoPlayer ) @Stable data class VideoThumb( val thumb: Drawable? ) @Composable @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) private fun RenderVideoPlayer( playerData: VideoPlayer, thumbData: VideoThumb?, automaticallyStartPlayback: MutableState, onDialog: ((Boolean) -> Unit)? ) { val context = LocalContext.current BoxWithConstraints() { AndroidView( modifier = Modifier .fillMaxWidth() .defaultMinSize(minHeight = 70.dp) .align(Alignment.Center) .onVisibilityChanges { visible -> if (!automaticallyStartPlayback.value) { playerData.exoPlayer.stop() } if (!automaticallyStartPlayback.value && visible && !playerData.exoPlayer.isPlaying) { playerData.exoPlayer.pause() } else if (visible && !playerData.exoPlayer.isPlaying) { playerData.exoPlayer.play() } else if (!visible && playerData.exoPlayer.isPlaying) { playerData.exoPlayer.pause() } }, factory = { PlayerView(context).apply { player = playerData.exoPlayer layoutParams = FrameLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ) controllerAutoShow = false thumbData?.thumb?.let { defaultArtwork = it } hideController() resizeMode = if (maxHeight.isFinite) AspectRatioFrameLayout.RESIZE_MODE_FIT else AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH onDialog?.let { innerOnDialog -> setFullscreenButtonClickListener { playerData.exoPlayer.pause() innerOnDialog(it) } } } } ) MuteButton() { mute: Boolean -> DefaultMutedSetting.value = mute playerData.exoPlayer.volume = if (mute) 0f else 1f } } } fun Modifier.onVisibilityChanges(onVisibilityChanges: (Boolean) -> Unit): Modifier = composed { val view = LocalView.current var isVisible: Boolean? by remember { mutableStateOf(null) } onGloballyPositioned { coordinates -> val newIsVisible = coordinates.isCompletelyVisible(view) if (isVisible != newIsVisible) { isVisible = newIsVisible onVisibilityChanges(isVisible == true) } } } fun LayoutCoordinates.isCompletelyVisible(view: View): Boolean { if (!isAttached) return false // Window relative bounds of our compose root view that are visible on the screen val globalRootRect = android.graphics.Rect() if (!view.getGlobalVisibleRect(globalRootRect)) { // we aren't visible at all. return false } val bounds = boundsInWindow() // Make sure we are completely in bounds. return bounds.top >= globalRootRect.top && bounds.left >= globalRootRect.left && bounds.right <= globalRootRect.right && bounds.bottom <= globalRootRect.bottom } @Composable private fun MuteButton(toggle: (Boolean) -> Unit) { Box(modifier = VolumeBottomIconSize) { Box( Modifier .clip(CircleShape) .fillMaxSize(0.6f) .align(Alignment.Center) .background(MaterialTheme.colors.background) ) val mutedInstance = remember { mutableStateOf(DefaultMutedSetting.value) } IconButton( onClick = { mutedInstance.value = !mutedInstance.value toggle(mutedInstance.value) }, modifier = Size50Modifier ) { if (mutedInstance.value) { MutedIcon() } else { MuteIcon() } } } }