kopia lustrzana https://github.com/vitorpamplona/amethyst
377 wiersze
13 KiB
Kotlin
377 wiersze
13 KiB
Kotlin
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<Boolean, Drawable?>>(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<VideoPlayer?>(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<Boolean>,
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
}
|