diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt index be48711bf0..44392e27b9 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/LocalMediaView.kt @@ -7,79 +7,17 @@ package io.element.android.libraries.mediaviewer.impl.local -import android.annotation.SuppressLint -import android.net.Uri -import android.view.ViewGroup.LayoutParams.MATCH_PARENT -import android.widget.FrameLayout -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.wrapContentSize -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.GraphicEq -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.lifecycle.Lifecycle -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.Timeline -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.media3.ui.PlayerView -import io.element.android.compound.theme.ElementTheme -import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.mimetype.MimeTypes -import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeImage import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo -import io.element.android.libraries.designsystem.theme.components.Icon -import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.utils.CommonDrawables -import io.element.android.libraries.designsystem.utils.KeepScreenOn -import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.mediaviewer.api.MediaInfo -import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize import io.element.android.libraries.mediaviewer.api.local.LocalMedia -import io.element.android.libraries.mediaviewer.impl.local.exoplayer.ExoPlayerWrapper -import io.element.android.libraries.mediaviewer.impl.local.pdf.PdfViewer -import io.element.android.libraries.mediaviewer.impl.local.pdf.rememberPdfViewerState -import io.element.android.libraries.mediaviewer.impl.player.MediaPlayerControllerState -import io.element.android.libraries.mediaviewer.impl.player.MediaPlayerControllerView -import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.coroutines.delay -import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage -import me.saket.telephoto.zoomable.rememberZoomableImageState -import kotlin.time.Duration.Companion.seconds +import io.element.android.libraries.mediaviewer.impl.local.file.MediaFileView +import io.element.android.libraries.mediaviewer.impl.local.image.MediaImageView +import io.element.android.libraries.mediaviewer.impl.local.pdf.MediaPdfView +import io.element.android.libraries.mediaviewer.impl.local.video.MediaVideoView @Composable fun LocalMediaView( @@ -102,7 +40,7 @@ fun LocalMediaView( localMedia = localMedia, modifier = modifier, ) - mimeType == MimeTypes.Pdf -> MediaPDFView( + mimeType == MimeTypes.Pdf -> MediaPdfView( localMediaViewState = localMediaViewState, localMedia = localMedia, modifier = modifier, @@ -118,303 +56,3 @@ fun LocalMediaView( ) } } - -@Composable -private fun MediaImageView( - localMediaViewState: LocalMediaViewState, - localMedia: LocalMedia?, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - if (LocalInspectionMode.current) { - Image( - painter = painterResource(id = CommonDrawables.sample_background), - modifier = modifier, - contentDescription = null, - ) - } else { - val zoomableImageState = rememberZoomableImageState(localMediaViewState.zoomableState) - localMediaViewState.isReady = zoomableImageState.isImageDisplayed - ZoomableAsyncImage( - modifier = modifier, - state = zoomableImageState, - model = localMedia?.uri, - contentDescription = stringResource(id = CommonStrings.common_image), - contentScale = ContentScale.Fit, - onClick = { onClick() } - ) - } -} - -@Composable -private fun MediaVideoView( - localMediaViewState: LocalMediaViewState, - localMedia: LocalMedia?, - modifier: Modifier = Modifier, -) { - if (LocalInspectionMode.current) { - Text( - modifier = modifier - .background(ElementTheme.colors.bgSubtlePrimary) - .wrapContentSize(), - text = "A Video Player will render here", - ) - } else { - ExoPlayerMediaVideoView( - localMediaViewState = localMediaViewState, - localMedia = localMedia, - modifier = modifier, - ) - } -} - -@SuppressLint("UnsafeOptInUsageError") -@Composable -private fun ExoPlayerMediaVideoView( - localMediaViewState: LocalMediaViewState, - localMedia: LocalMedia?, - modifier: Modifier = Modifier, -) { - var mediaPlayerControllerState: MediaPlayerControllerState by remember { - mutableStateOf( - MediaPlayerControllerState( - isVisible = false, - isPlaying = false, - progressInMillis = 0, - durationInMillis = 0, - isMuted = false, - ) - ) - } - - val playableState: PlayableState.Playable by remember { - derivedStateOf { - PlayableState.Playable( - isShowingControls = mediaPlayerControllerState.isVisible, - ) - } - } - - localMediaViewState.playableState = playableState - - val context = LocalContext.current - val exoPlayer = remember { - ExoPlayerWrapper.create(context) - } - val playerListener = object : Player.Listener { - override fun onRenderedFirstFrame() { - localMediaViewState.isReady = true - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - mediaPlayerControllerState = mediaPlayerControllerState.copy( - isPlaying = isPlaying, - ) - } - - override fun onVolumeChanged(volume: Float) { - mediaPlayerControllerState = mediaPlayerControllerState.copy( - isMuted = volume == 0f, - ) - } - - override fun onTimelineChanged(timeline: Timeline, reason: Int) { - if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) { - mediaPlayerControllerState = mediaPlayerControllerState.copy( - durationInMillis = exoPlayer.duration, - ) - } - } - } - - LaunchedEffect(Unit) { - exoPlayer.addListener(playerListener) - exoPlayer.prepare() - } - - var autoHideController by remember { mutableIntStateOf(0) } - - LaunchedEffect(autoHideController) { - delay(5.seconds) - if (exoPlayer.isPlaying) { - mediaPlayerControllerState = mediaPlayerControllerState.copy( - isVisible = false, - ) - } - } - - LaunchedEffect(exoPlayer.isPlaying) { - if (exoPlayer.isPlaying) { - while (true) { - mediaPlayerControllerState = mediaPlayerControllerState.copy( - progressInMillis = exoPlayer.currentPosition, - ) - delay(200) - } - } else { - // Ensure we render the final state - mediaPlayerControllerState = mediaPlayerControllerState.copy( - progressInMillis = exoPlayer.currentPosition, - ) - } - } - if (localMedia?.uri != null) { - LaunchedEffect(localMedia.uri) { - val mediaItem = MediaItem.fromUri(localMedia.uri) - exoPlayer.setMediaItem(mediaItem) - } - } else { - exoPlayer.setMediaItems(emptyList()) - } - KeepScreenOn(mediaPlayerControllerState.isPlaying) - Box( - modifier = modifier - .background(ElementTheme.colors.bgSubtlePrimary) - .wrapContentSize(), - ) { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { - PlayerView(context).apply { - player = exoPlayer - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT - layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) - setOnClickListener { - autoHideController++ - mediaPlayerControllerState = mediaPlayerControllerState.copy( - isVisible = !mediaPlayerControllerState.isVisible, - ) - } - useController = false - } - }, - onRelease = { playerView -> - playerView.setOnClickListener(null) - playerView.setControllerVisibilityListener(null as PlayerView.ControllerVisibilityListener?) - playerView.player = null - }, - ) - MediaPlayerControllerView( - state = mediaPlayerControllerState, - onTogglePlay = { - autoHideController++ - if (exoPlayer.isPlaying) { - exoPlayer.pause() - } else { - if (exoPlayer.playbackState == Player.STATE_ENDED) { - exoPlayer.seekTo(0) - } else { - exoPlayer.play() - } - } - }, - onSeekChange = { - autoHideController++ - if (exoPlayer.isPlaying.not()) { - exoPlayer.play() - } - exoPlayer.seekTo(it.toLong()) - }, - onToggleMute = { - autoHideController++ - exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f - }, - modifier = Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter), - ) - } - - OnLifecycleEvent { _, event -> - when (event) { - Lifecycle.Event.ON_RESUME -> exoPlayer.play() - Lifecycle.Event.ON_PAUSE -> exoPlayer.pause() - Lifecycle.Event.ON_DESTROY -> { - exoPlayer.release() - exoPlayer.removeListener(playerListener) - } - else -> Unit - } - } -} - -@Composable -private fun MediaPDFView( - localMediaViewState: LocalMediaViewState, - localMedia: LocalMedia?, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val pdfViewerState = rememberPdfViewerState( - model = localMedia?.uri, - zoomableState = localMediaViewState.zoomableState, - ) - localMediaViewState.isReady = pdfViewerState.isLoaded - PdfViewer( - pdfViewerState = pdfViewerState, - onClick = onClick, - modifier = modifier, - ) -} - -@Composable -private fun MediaFileView( - localMediaViewState: LocalMediaViewState, - uri: Uri?, - info: MediaInfo?, - onClick: () -> Unit, - modifier: Modifier = Modifier, -) { - val isAudio = info?.mimeType.isMimeTypeAudio().orFalse() - localMediaViewState.isReady = uri != null - - val interactionSource = remember { MutableInteractionSource() } - Box( - modifier = modifier - .padding(horizontal = 8.dp) - .clickable( - onClick = onClick, - interactionSource = interactionSource, - indication = null - ), - contentAlignment = Alignment.Center - ) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Box( - modifier = Modifier - .size(72.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.onBackground), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = if (isAudio) Icons.Outlined.GraphicEq else CompoundIcons.Attachment(), - contentDescription = null, - tint = MaterialTheme.colorScheme.background, - modifier = Modifier - .size(32.dp) - .rotate(if (isAudio) 0f else -45f), - ) - } - if (info != null) { - Spacer(modifier = Modifier.height(20.dp)) - Text( - text = info.filename, - maxLines = 2, - style = ElementTheme.typography.fontBodyLgRegular, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - color = MaterialTheme.colorScheme.primary - ) - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize), - style = ElementTheme.typography.fontBodyMdRegular, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.primary - ) - } - } - } -} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt new file mode 100644 index 0000000000..f0663a5d23 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/file/MediaFileView.kt @@ -0,0 +1,103 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.file + +import android.net.Uri +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.GraphicEq +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.core.bool.orFalse +import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeAudio +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.mediaviewer.api.MediaInfo +import io.element.android.libraries.mediaviewer.api.helper.formatFileExtensionAndSize +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState + +@Composable +fun MediaFileView( + localMediaViewState: LocalMediaViewState, + uri: Uri?, + info: MediaInfo?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val isAudio = info?.mimeType.isMimeTypeAudio().orFalse() + localMediaViewState.isReady = uri != null + + val interactionSource = remember { MutableInteractionSource() } + Box( + modifier = modifier + .padding(horizontal = 8.dp) + .clickable( + onClick = onClick, + interactionSource = interactionSource, + indication = null + ), + contentAlignment = Alignment.Center + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.onBackground), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = if (isAudio) Icons.Outlined.GraphicEq else CompoundIcons.Attachment(), + contentDescription = null, + tint = MaterialTheme.colorScheme.background, + modifier = Modifier + .size(32.dp) + .rotate(if (isAudio) 0f else -45f), + ) + } + if (info != null) { + Spacer(modifier = Modifier.height(20.dp)) + Text( + text = info.filename, + maxLines = 2, + style = ElementTheme.typography.fontBodyLgRegular, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = formatFileExtensionAndSize(info.fileExtension, info.formattedFileSize), + style = ElementTheme.typography.fontBodyMdRegular, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/image/MediaImageView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/image/MediaImageView.kt new file mode 100644 index 0000000000..c5792a9828 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/image/MediaImageView.kt @@ -0,0 +1,49 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.image + +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState +import io.element.android.libraries.ui.strings.CommonStrings +import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage +import me.saket.telephoto.zoomable.rememberZoomableImageState + +@Composable +fun MediaImageView( + localMediaViewState: LocalMediaViewState, + localMedia: LocalMedia?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (LocalInspectionMode.current) { + Image( + painter = painterResource(id = CommonDrawables.sample_background), + modifier = modifier, + contentDescription = null, + ) + } else { + val zoomableImageState = rememberZoomableImageState(localMediaViewState.zoomableState) + localMediaViewState.isReady = zoomableImageState.isImageDisplayed + ZoomableAsyncImage( + modifier = modifier, + state = zoomableImageState, + model = localMedia?.uri, + contentDescription = stringResource(id = CommonStrings.common_image), + contentScale = ContentScale.Fit, + onClick = { onClick() } + ) + } +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/MediaPdfView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/MediaPdfView.kt new file mode 100644 index 0000000000..f2694fb277 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/pdf/MediaPdfView.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.pdf + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState + +@Composable +fun MediaPdfView( + localMediaViewState: LocalMediaViewState, + localMedia: LocalMedia?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val pdfViewerState = rememberPdfViewerState( + model = localMedia?.uri, + zoomableState = localMediaViewState.zoomableState, + ) + localMediaViewState.isReady = pdfViewerState.isLoaded + PdfViewer( + pdfViewerState = pdfViewerState, + onClick = onClick, + modifier = modifier, + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/exoplayer/ExoPlayerWrapper.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/ExoPlayerWrapper.kt similarity index 93% rename from libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/exoplayer/ExoPlayerWrapper.kt rename to libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/ExoPlayerWrapper.kt index 41d59b395b..fef307c155 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/exoplayer/ExoPlayerWrapper.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/ExoPlayerWrapper.kt @@ -5,7 +5,7 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.libraries.mediaviewer.impl.local.exoplayer +package io.element.android.libraries.mediaviewer.impl.local.video import android.content.Context import androidx.media3.common.Player diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/player/MediaPlayerControllerState.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerState.kt similarity index 83% rename from libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/player/MediaPlayerControllerState.kt rename to libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerState.kt index d922483ff1..c4e4b913a7 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/player/MediaPlayerControllerState.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerState.kt @@ -5,7 +5,7 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.libraries.mediaviewer.impl.player +package io.element.android.libraries.mediaviewer.impl.local.video data class MediaPlayerControllerState( val isVisible: Boolean, diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/player/MediaPlayerControllerStateProvider.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerStateProvider.kt similarity index 94% rename from libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/player/MediaPlayerControllerStateProvider.kt rename to libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerStateProvider.kt index 6aa93547f6..78059bd4eb 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/player/MediaPlayerControllerStateProvider.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerStateProvider.kt @@ -5,7 +5,7 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.libraries.mediaviewer.impl.player +package io.element.android.libraries.mediaviewer.impl.local.video import androidx.compose.ui.tooling.preview.PreviewParameterProvider diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/player/MediaPlayerControllerView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerView.kt similarity index 98% rename from libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/player/MediaPlayerControllerView.kt rename to libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerView.kt index c9f548aab2..9cee07728f 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/player/MediaPlayerControllerView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaPlayerControllerView.kt @@ -5,7 +5,7 @@ * Please see LICENSE in the repository root for full details. */ -package io.element.android.libraries.mediaviewer.impl.player +package io.element.android.libraries.mediaviewer.impl.local.video import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt new file mode 100644 index 0000000000..7c7d798e93 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/video/MediaVideoView.kt @@ -0,0 +1,237 @@ +/* + * Copyright 2024 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only + * Please see LICENSE in the repository root for full details. + */ + +package io.element.android.libraries.mediaviewer.impl.local.video + +import android.annotation.SuppressLint +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.Lifecycle +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import androidx.media3.ui.AspectRatioFrameLayout +import androidx.media3.ui.PlayerView +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.utils.KeepScreenOn +import io.element.android.libraries.designsystem.utils.OnLifecycleEvent +import io.element.android.libraries.mediaviewer.api.local.LocalMedia +import io.element.android.libraries.mediaviewer.impl.local.LocalMediaViewState +import io.element.android.libraries.mediaviewer.impl.local.PlayableState +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds + +@Composable +fun MediaVideoView( + localMediaViewState: LocalMediaViewState, + localMedia: LocalMedia?, + modifier: Modifier = Modifier, +) { + if (LocalInspectionMode.current) { + Text( + modifier = modifier + .background(ElementTheme.colors.bgSubtlePrimary) + .wrapContentSize(), + text = "A Video Player will render here", + ) + } else { + ExoPlayerMediaVideoView( + localMediaViewState = localMediaViewState, + localMedia = localMedia, + modifier = modifier, + ) + } +} + +@SuppressLint("UnsafeOptInUsageError") +@Composable +private fun ExoPlayerMediaVideoView( + localMediaViewState: LocalMediaViewState, + localMedia: LocalMedia?, + modifier: Modifier = Modifier, +) { + var mediaPlayerControllerState: MediaPlayerControllerState by remember { + mutableStateOf( + MediaPlayerControllerState( + isVisible = false, + isPlaying = false, + progressInMillis = 0, + durationInMillis = 0, + isMuted = false, + ) + ) + } + + val playableState: PlayableState.Playable by remember { + derivedStateOf { + PlayableState.Playable( + isShowingControls = mediaPlayerControllerState.isVisible, + ) + } + } + + localMediaViewState.playableState = playableState + + val context = LocalContext.current + val exoPlayer = remember { + ExoPlayerWrapper.create(context) + } + val playerListener = object : Player.Listener { + override fun onRenderedFirstFrame() { + localMediaViewState.isReady = true + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + isPlaying = isPlaying, + ) + } + + override fun onVolumeChanged(volume: Float) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + isMuted = volume == 0f, + ) + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + if (reason == Player.TIMELINE_CHANGE_REASON_SOURCE_UPDATE) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + durationInMillis = exoPlayer.duration, + ) + } + } + } + + LaunchedEffect(Unit) { + exoPlayer.addListener(playerListener) + exoPlayer.prepare() + } + + var autoHideController by remember { mutableIntStateOf(0) } + + LaunchedEffect(autoHideController) { + delay(5.seconds) + if (exoPlayer.isPlaying) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + isVisible = false, + ) + } + } + + LaunchedEffect(exoPlayer.isPlaying) { + if (exoPlayer.isPlaying) { + while (true) { + mediaPlayerControllerState = mediaPlayerControllerState.copy( + progressInMillis = exoPlayer.currentPosition, + ) + delay(200) + } + } else { + // Ensure we render the final state + mediaPlayerControllerState = mediaPlayerControllerState.copy( + progressInMillis = exoPlayer.currentPosition, + ) + } + } + if (localMedia?.uri != null) { + LaunchedEffect(localMedia.uri) { + val mediaItem = MediaItem.fromUri(localMedia.uri) + exoPlayer.setMediaItem(mediaItem) + } + } else { + exoPlayer.setMediaItems(emptyList()) + } + KeepScreenOn(mediaPlayerControllerState.isPlaying) + Box( + modifier = modifier + .background(ElementTheme.colors.bgSubtlePrimary) + .wrapContentSize(), + ) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { + PlayerView(context).apply { + player = exoPlayer + resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT + layoutParams = FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT) + setOnClickListener { + autoHideController++ + mediaPlayerControllerState = mediaPlayerControllerState.copy( + isVisible = !mediaPlayerControllerState.isVisible, + ) + } + useController = false + } + }, + onRelease = { playerView -> + playerView.setOnClickListener(null) + playerView.setControllerVisibilityListener(null as PlayerView.ControllerVisibilityListener?) + playerView.player = null + }, + ) + MediaPlayerControllerView( + state = mediaPlayerControllerState, + onTogglePlay = { + autoHideController++ + if (exoPlayer.isPlaying) { + exoPlayer.pause() + } else { + if (exoPlayer.playbackState == Player.STATE_ENDED) { + exoPlayer.seekTo(0) + } else { + exoPlayer.play() + } + } + }, + onSeekChange = { + autoHideController++ + if (exoPlayer.isPlaying.not()) { + exoPlayer.play() + } + exoPlayer.seekTo(it.toLong()) + }, + onToggleMute = { + autoHideController++ + exoPlayer.volume = if (exoPlayer.volume == 1f) 0f else 1f + }, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter), + ) + } + + OnLifecycleEvent { _, event -> + when (event) { + Lifecycle.Event.ON_RESUME -> exoPlayer.play() + Lifecycle.Event.ON_PAUSE -> exoPlayer.pause() + Lifecycle.Event.ON_DESTROY -> { + exoPlayer.release() + exoPlayer.removeListener(playerListener) + } + else -> Unit + } + } +}