diff --git a/apps/android/app/build.gradle b/apps/android/app/build.gradle index 9b943bf990..f552566d3f 100644 --- a/apps/android/app/build.gradle +++ b/apps/android/app/build.gradle @@ -143,6 +143,9 @@ dependencies { implementation "io.coil-kt:coil-compose:2.1.0" implementation "io.coil-kt:coil-gif:2.1.0" + // Video support + implementation "com.google.android.exoplayer:exoplayer:2.17.1" + testImplementation 'junit:junit:4.13.2' androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' diff --git a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt index 734f41cbd0..1748581f93 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/MainActivity.kt @@ -128,6 +128,7 @@ class MainActivity: FragmentActivity() { override fun onStop() { super.onStop() + VideoPlayer.stopAll() enteredBackground.value = elapsedRealtime() } diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 8a64c39e94..e2e1446dad 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -1419,7 +1419,7 @@ data class ChatItem ( file = null ) } - + private const val TEMP_DELETED_CHAT_ITEM_ID = -1L const val TEMP_LIVE_CHAT_ITEM_ID = -2L @@ -1777,6 +1777,7 @@ sealed class MsgContent { @Serializable(with = MsgContentSerializer::class) class MCText(override val text: String): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCLink(override val text: String, val preview: LinkPreview): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCImage(override val text: String, val image: String): MsgContent() + @Serializable(with = MsgContentSerializer::class) class MCVideo(override val text: String, val image: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCVoice(override val text: String, val duration: Int): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCFile(override val text: String): MsgContent() @Serializable(with = MsgContentSerializer::class) class MCUnknown(val type: String? = null, override val text: String, val json: JsonElement): MsgContent() @@ -1830,6 +1831,11 @@ object MsgContentSerializer : KSerializer { element("text") element("image") }) + element("MCVideo", buildClassSerialDescriptor("MCVideo") { + element("text") + element("image") + element("duration") + }) element("MCFile", buildClassSerialDescriptor("MCFile") { element("text") }) @@ -1853,6 +1859,11 @@ object MsgContentSerializer : KSerializer { val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format" MsgContent.MCImage(text, image) } + "video" -> { + val image = json["image"]?.jsonPrimitive?.content ?: "unknown message format" + val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0 + MsgContent.MCVideo(text, image, duration) + } "voice" -> { val duration = json["duration"]?.jsonPrimitive?.intOrNull ?: 0 MsgContent.MCVoice(text, duration) @@ -1888,6 +1899,13 @@ object MsgContentSerializer : KSerializer { put("text", value.text) put("image", value.image) } + is MsgContent.MCVideo -> + buildJsonObject { + put("type", "video") + put("text", value.text) + put("image", value.image) + put("duration", value.duration) + } is MsgContent.MCVoice -> buildJsonObject { put("type", "voice") diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt index 4f7eb7fe5f..da019c7d7e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ChatView.kt @@ -1,5 +1,6 @@ package chat.simplex.app.views.chat +import android.app.Activity import android.content.res.Configuration import android.graphics.Bitmap import android.net.Uri @@ -578,6 +579,11 @@ fun BoxWithConstraintsScope.ChatItemsList( stopListening = true } } + DisposableEffectOnGone( + whenGone = { + VideoPlayer.releaseAll() + } + ) LazyColumn(Modifier.align(Alignment.BottomCenter), state = listState, reverseLayout = true) { itemsIndexed(reversedChatItems, key = { _, item -> item.id}) { i, cItem -> CompositionLocalProvider( @@ -935,21 +941,26 @@ private fun markUnreadChatAsRead(activeChat: MutableState, chatModel: Cha } } +sealed class ProviderMedia { + data class Image(val uri: Uri, val image: Bitmap): ProviderMedia() + data class Video(val uri: Uri, val preview: String): ProviderMedia() +} + private fun providerForGallery( listStateIndex: Int, chatItems: List, cItemId: Long, scrollTo: (Int) -> Unit ): ImageGalleryProvider { - fun canShowImage(item: ChatItem): Boolean = - item.content.msgContent is MsgContent.MCImage && item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null + fun canShowMedia(item: ChatItem): Boolean = + (item.content.msgContent is MsgContent.MCImage || item.content.msgContent is MsgContent.MCVideo) && (item.file?.loaded == true && getLoadedFilePath(SimplexApp.context, item.file) != null) fun item(skipInternalIndex: Int, initialChatId: Long): Pair? { var processedInternalIndex = -skipInternalIndex.sign val indexOfFirst = chatItems.indexOfFirst { it.id == initialChatId } for (chatItemsIndex in if (skipInternalIndex >= 0) indexOfFirst downTo 0 else indexOfFirst..chatItems.lastIndex) { val item = chatItems[chatItemsIndex] - if (canShowImage(item)) { + if (canShowMedia(item)) { processedInternalIndex += skipInternalIndex.sign } if (processedInternalIndex == skipInternalIndex) { @@ -963,16 +974,28 @@ private fun providerForGallery( var initialChatId = cItemId return object: ImageGalleryProvider { override val initialIndex: Int = initialIndex - override val totalImagesSize = mutableStateOf(Int.MAX_VALUE) - override fun getImage(index: Int): Pair? { + override val totalMediaSize = mutableStateOf(Int.MAX_VALUE) + override fun getMedia(index: Int): ProviderMedia? { val internalIndex = initialIndex - index - val file = item(internalIndex, initialChatId)?.second?.file - val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, file) - val filePath = getLoadedFilePath(SimplexApp.context, file) - return if (imageBitmap != null && filePath != null) { - val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) - imageBitmap to uri - } else null + val item = item(internalIndex, initialChatId)?.second ?: return null + return when (item.content.msgContent) { + is MsgContent.MCImage -> { + val imageBitmap: Bitmap? = getLoadedImage(SimplexApp.context, item.file) + val filePath = getLoadedFilePath(SimplexApp.context, item.file) + if (imageBitmap != null && filePath != null) { + val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) + ProviderMedia.Image(uri, imageBitmap) + } else null + } + is MsgContent.MCVideo -> { + val filePath = getLoadedFilePath(SimplexApp.context, item.file) + if (filePath != null) { + val uri = FileProvider.getUriForFile(SimplexApp.context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) + ProviderMedia.Video(uri, (item.content.msgContent as MsgContent.MCVideo).image) + } else null + } + else -> null + } } override fun currentPageChanged(index: Int) { @@ -984,7 +1007,7 @@ private fun providerForGallery( override fun scrollToStart() { initialIndex = 0 - initialChatId = chatItems.first { canShowImage(it) }.id + initialChatId = chatItems.first { canShowMedia(it) }.id } override fun onDismiss(index: Int) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt index 574e56f740..a47fa76195 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ComposeView.kt @@ -9,14 +9,13 @@ import android.content.* import android.content.pm.PackageManager import android.graphics.* import android.graphics.drawable.AnimatedImageDrawable +import android.media.MediaMetadataRetriever import android.net.Uri import android.os.Build import android.provider.MediaStore -import android.util.Log import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContract -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.* @@ -32,10 +31,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.unit.dp import androidx.core.content.ContextCompat -import androidx.core.net.toFile import chat.simplex.app.* import chat.simplex.app.R import chat.simplex.app.model.* @@ -53,6 +50,7 @@ sealed class ComposePreview { @Serializable object NoPreview: ComposePreview() @Serializable class CLinkPreview(val linkPreview: LinkPreview?): ComposePreview() @Serializable class ImagePreview(val images: List, val content: List): ComposePreview() + @Serializable class VideoPreview(val images: List, val content: List): ComposePreview() @Serializable data class VoicePreview(val voice: String, val durationMs: Int, val finished: Boolean): ComposePreview() @Serializable class FilePreview(val fileName: String, val uri: Uri): ComposePreview() } @@ -99,6 +97,7 @@ data class ComposeState( get() = { val hasContent = when (preview) { is ComposePreview.ImagePreview -> true + is ComposePreview.VideoPreview -> true is ComposePreview.VoicePreview -> true is ComposePreview.FilePreview -> true else -> message.isNotEmpty() || liveMessage != null @@ -112,6 +111,7 @@ data class ComposeState( get() = when (preview) { is ComposePreview.ImagePreview -> false + is ComposePreview.VideoPreview -> false is ComposePreview.VoicePreview -> false is ComposePreview.FilePreview -> false else -> useLinkPreviews @@ -162,6 +162,7 @@ fun chatItemPreview(chatItem: ChatItem): ComposePreview { is MsgContent.MCLink -> ComposePreview.CLinkPreview(linkPreview = mc.preview) // TODO: include correct type is MsgContent.MCImage -> ComposePreview.ImagePreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) + is MsgContent.MCVideo -> ComposePreview.VideoPreview(images = listOf(mc.image), listOf(UploadContent.SimpleImage(getAppFileUri(fileName)))) is MsgContent.MCVoice -> ComposePreview.VoicePreview(voice = fileName, mc.duration / 1000, true) is MsgContent.MCFile -> ComposePreview.FilePreview(fileName, getAppFileUri(fileName)) is MsgContent.MCUnknown, null -> ComposePreview.NoPreview @@ -235,6 +236,21 @@ fun ComposeView( composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.ImagePreview(imagesPreview, content)) } } + val processPickedVideo = { uris: List, text: String? -> + val content = ArrayList() + val imagesPreview = ArrayList() + uris.forEach { uri -> + val (bitmap: Bitmap?, durationMs: Long?) = getBitmapFromVideo(uri) + content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0)) + if (bitmap != null) { + imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000)) + } + } + + if (imagesPreview.isNotEmpty()) { + composeState.value = composeState.value.copy(message = text ?: composeState.value.message, preview = ComposePreview.VideoPreview(imagesPreview, content)) + } + } val processPickedFile = { uri: Uri?, text: String? -> if (uri != null) { val fileSize = getFileSize(context, uri) @@ -251,8 +267,10 @@ fun ComposeView( } } } - val galleryLauncher = rememberLauncherForActivityResult(contract = PickMultipleFromGallery()) { processPickedImage(it, null) } - val galleryLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) } + val galleryImageLauncher = rememberLauncherForActivityResult(contract = PickMultipleImagesFromGallery()) { processPickedImage(it, null) } + val galleryImageLauncherFallback = rememberGetMultipleContentsLauncher { processPickedImage(it, null) } + val galleryVideoLauncher = rememberLauncherForActivityResult(contract = PickMultipleVideosFromGallery()) { processPickedVideo(it, null) } + val galleryVideoLauncherFallback = rememberGetMultipleContentsLauncher { processPickedVideo(it, null) } val filesLauncher = rememberGetContentLauncher { processPickedFile(it, null) } val recState: MutableState = remember { mutableStateOf(RecordingState.NotStarted) } @@ -271,9 +289,17 @@ fun ComposeView( } AttachmentOption.PickImage -> { try { - galleryLauncher.launch(0) + galleryImageLauncher.launch(0) } catch (e: ActivityNotFoundException) { - galleryLauncherFallback.launch("image/*") + galleryImageLauncherFallback.launch("image/*") + } + attachmentOption.value = null + } + AttachmentOption.PickVideo -> { + try { + galleryVideoLauncher.launch(0) + } catch (e: ActivityNotFoundException) { + galleryVideoLauncherFallback.launch("video/*") } attachmentOption.value = null } @@ -394,6 +420,7 @@ fun ComposeView( is MsgContent.MCText -> checkLinkPreview() is MsgContent.MCLink -> checkLinkPreview() is MsgContent.MCImage -> MsgContent.MCImage(msgText, image = msgContent.image) + is MsgContent.MCVideo -> MsgContent.MCVideo(msgText, image = msgContent.image, duration = msgContent.duration) is MsgContent.MCVoice -> MsgContent.MCVoice(msgText, duration = msgContent.duration) is MsgContent.MCFile -> MsgContent.MCFile(msgText) is MsgContent.MCUnknown -> MsgContent.MCUnknown(type = msgContent.type, text = msgText, json = msgContent.json) @@ -438,6 +465,7 @@ fun ComposeView( val file = when (it) { is UploadContent.SimpleImage -> saveImage(context, it.uri) is UploadContent.AnimatedImage -> saveAnimImage(context, it.uri) + else -> return@forEachIndexed } if (file != null) { files.add(file) @@ -445,6 +473,18 @@ fun ComposeView( } } } + is ComposePreview.VideoPreview -> { + preview.content.forEachIndexed { index, it -> + val file = when (it) { + is UploadContent.Video -> saveFileFromUri(context, it.uri) + else -> return@forEachIndexed + } + if (file != null) { + files.add(file) + msgs.add(MsgContent.MCVideo(if (preview.content.lastIndex == index) msgText else "", preview.images[index], it.duration)) + } + } + } is ComposePreview.VoicePreview -> { val tmpFile = File(preview.voice) AudioPlayer.stop(tmpFile.absolutePath) @@ -475,7 +515,12 @@ fun ComposeView( if (content !is MsgContent.MCVoice && index == msgs.lastIndex) live else false ) } - if (sent == null && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview || cs.preview is ComposePreview.VoicePreview)) { + if (sent == null && + (cs.preview is ComposePreview.ImagePreview || + cs.preview is ComposePreview.VideoPreview || + cs.preview is ComposePreview.FilePreview || + cs.preview is ComposePreview.VoicePreview) + ) { sent = send(cInfo, MsgContent.MCText(msgText), quotedItemId, null, live) } } @@ -602,6 +647,11 @@ fun ComposeView( ::cancelImages, cancelEnabled = !composeState.value.editing ) + is ComposePreview.VideoPreview -> ComposeImageView( + preview.images, + ::cancelImages, + cancelEnabled = !composeState.value.editing + ) is ComposePreview.VoicePreview -> ComposeVoiceView( preview.voice, preview.durationMs, @@ -769,7 +819,7 @@ class PickFromGallery: ActivityResultContract() { override fun parseResult(resultCode: Int, intent: Intent?): Uri? = intent?.data } -class PickMultipleFromGallery: ActivityResultContract>() { +class PickMultipleImagesFromGallery: ActivityResultContract>() { override fun createIntent(context: Context, input: Int) = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.INTERNAL_CONTENT_URI).apply { putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) @@ -794,3 +844,30 @@ class PickMultipleFromGallery: ActivityResultContract>() { else emptyList() } + + +class PickMultipleVideosFromGallery: ActivityResultContract>() { + override fun createIntent(context: Context, input: Int) = + Intent(Intent.ACTION_PICK, MediaStore.Video.Media.INTERNAL_CONTENT_URI).apply { + putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) + type = "video/*" + } + + override fun parseResult(resultCode: Int, intent: Intent?): List = + if (intent?.data != null) + listOf(intent.data!!) + else if (intent?.clipData != null) + with(intent.clipData!!) { + val uris = ArrayList() + for (i in 0 until kotlin.math.min(itemCount, 10)) { + val uri = getItemAt(i).uri + if (uri != null) uris.add(uri) + } + if (itemCount > 10) { + AlertManager.shared.showAlertMsg(R.string.videos_limit_title, R.string.videos_limit_desc) + } + uris + } + else + emptyList() +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index f1b3b47db5..3136c239f8 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -75,7 +75,7 @@ fun SendMsgView( ) { Box(Modifier.padding(vertical = 8.dp)) { val cs = composeState.value - val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.FilePreview) + val showProgress = cs.inProgress && (cs.preview is ComposePreview.ImagePreview || cs.preview is ComposePreview.VideoPreview || cs.preview is ComposePreview.FilePreview) val showVoiceButton = cs.message.isEmpty() && showVoiceRecordIcon && !composeState.value.editing && cs.liveMessage == null && (cs.preview is ComposePreview.NoPreview || recState.value is RecordingState.Started) val showDeleteTextButton = rememberSaveable { mutableStateOf(false) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt new file mode 100644 index 0000000000..bceaa1b0bf --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVideoView.kt @@ -0,0 +1,320 @@ +package chat.simplex.app.views.chat.item + +import android.graphics.Bitmap +import android.graphics.Rect +import android.net.Uri +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.* +import androidx.compose.material.icons.outlined.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.* +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.* +import androidx.compose.ui.platform.* +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.* +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.FileProvider +import androidx.core.graphics.drawable.toDrawable +import chat.simplex.app.* +import chat.simplex.app.R +import chat.simplex.app.model.* +import chat.simplex.app.ui.theme.* +import chat.simplex.app.views.helpers.* +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH +import com.google.android.exoplayer2.ui.StyledPlayerView +import java.io.File + +@Composable +fun CIVideoView( + image: String, + duration: Int, + file: CIFile?, + imageProvider: () -> ImageGalleryProvider, + showMenu: MutableState, + receiveFile: (Long) -> Unit +) { + Box( + Modifier.layoutId(CHAT_IMAGE_LAYOUT_ID), + contentAlignment = Alignment.TopEnd + ) { + val context = LocalContext.current + val filePath = remember(file) { getLoadedFilePath(SimplexApp.context, file) } + val preview = remember(image) { base64ToBitmap(image) } + if (file != null && filePath != null) { + val uri = remember(filePath) { FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.provider", File(filePath)) } + val view = LocalView.current + VideoView(uri, file, preview, duration * 1000L, showMenu, onClick = { + hideKeyboard(view) + ModalManager.shared.showCustomModal(animated = false) { close -> + ImageFullScreenView(imageProvider, close) + } + }) + } else { + Box { + ImageView(preview, showMenu, onClick = { + if (file != null) { + when (file.fileStatus) { + CIFileStatus.RcvInvitation -> + receiveFileIfValidSize(file, receiveFile) + CIFileStatus.RcvAccepted -> + when (file.fileProtocol) { + FileProtocol.XFTP -> + AlertManager.shared.showAlertMsg( + generalGetString(R.string.waiting_for_video), + generalGetString(R.string.video_will_be_received_when_contact_completes_uploading) + ) + + FileProtocol.SMP -> + AlertManager.shared.showAlertMsg( + generalGetString(R.string.waiting_for_video), + generalGetString(R.string.video_will_be_received_when_contact_is_online) + ) + } + CIFileStatus.RcvTransfer(rcvProgress = 7, rcvTotal = 10) -> {} // ? + CIFileStatus.RcvComplete -> {} // ? + CIFileStatus.RcvCancelled -> {} // TODO + else -> {} + } + } + }) + if (file != null) { + DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/) + } + if (file?.fileStatus is CIFileStatus.RcvInvitation) { + PlayButton(error = false, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) } + } + } + } + loadingIndicator(file) + } +} + +@Composable +private fun VideoView(uri: Uri, file: CIFile, defaultPreview: Bitmap, defaultDuration: Long, showMenu: MutableState, onClick: () -> Unit) { + val context = LocalContext.current + val player = remember(uri) { VideoPlayer.getOrCreate(uri, false, defaultPreview, defaultDuration, true, context) } + val videoPlaying = remember(uri.path) { player.videoPlaying } + val progress = remember(uri.path) { player.progress } + val duration = remember(uri.path) { player.duration } + val preview by remember { player.preview } +// val soundEnabled by rememberSaveable(uri.path) { player.soundEnabled } + val brokenVideo by rememberSaveable(uri.path) { player.brokenVideo } + val play = { + player.enableSound(true) + player.play(true) + } + val stop = { + player.enableSound(false) + player.stop() + } + val showPreview = remember { derivedStateOf { !videoPlaying.value || progress.value == 0L } } + DisposableEffect(Unit) { + onDispose { + stop() + } + } + Box { + val windowWidth = LocalWindowWidth() + val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp } + AndroidView( + factory = { ctx -> + StyledPlayerView(ctx).apply { + useController = false + resizeMode = RESIZE_MODE_FIXED_WIDTH + this.player = player.player + } + }, + Modifier + .width(width) + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = { if (player.player.playWhenReady) stop() else onClick() } + ) + ) + if (showPreview.value) { + ImageView(preview, showMenu, onClick) + PlayButton(brokenVideo, onLongClick = { showMenu.value = true }, play) + } + DurationProgress(file, videoPlaying, duration, progress/*, soundEnabled*/) + } +} + +@Composable +private fun BoxScope.PlayButton(error: Boolean = false, onLongClick: () -> Unit, onClick: () -> Unit) { + Surface( + Modifier.align(Alignment.Center), + color = Color.White.copy(alpha = 0.8f), + shape = RoundedCornerShape(percent = 50) + ) { + Box( + Modifier + .defaultMinSize(minWidth = 40.dp, minHeight = 40.dp) + .combinedClickable(onClick = onClick, onLongClick = onLongClick), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.PlayArrow, + contentDescription = null, + Modifier.size(25.dp), + tint = if (error) WarningOrange else MaterialTheme.colors.primary + ) + } + } +} + +@Composable +private fun DurationProgress(file: CIFile, playing: MutableState, duration: MutableState, progress: MutableState/*, soundEnabled: MutableState*/) { + if (duration.value > 0L || progress.value > 0) { + Row { + Box( + Modifier + .padding(DEFAULT_PADDING_HALF) + .background(Color.Black.copy(alpha = 0.4f), MaterialTheme.shapes.small) + .padding(vertical = 2.dp, horizontal = 4.dp) + ) { + val time = (if (progress.value > 0) durationText((progress.value / 1000).toInt()) else durationText((duration.value / 1000).toInt())) + val sp30 = with(LocalDensity.current) { 30.sp.toDp() } + val sp45 = with(LocalDensity.current) { 45.sp.toDp() } + Text( + time, + Modifier.widthIn(min = if (time.length <= 5) sp30 else sp45), + fontSize = 13.sp, + color = Color.White + ) + /*if (!soundEnabled.value) { + Icon(Icons.Outlined.VolumeOff, null, + Modifier.padding(start = 5.dp).size(10.dp), + tint = Color.White + ) + }*/ + } + if (!playing.value) { + Box( + Modifier + .padding(top = DEFAULT_PADDING_HALF) + .background(Color.Black.copy(alpha = 0.4f), MaterialTheme.shapes.small) + .padding(vertical = 2.dp, horizontal = 4.dp) + ) { + Text( + formatBytes(file.fileSize), + fontSize = 13.sp, + color = Color.White + ) + } + } + } + } +} + +@Composable +private fun ImageView(preview: Bitmap, showMenu: MutableState, onClick: () -> Unit) { + val windowWidth = LocalWindowWidth() + val width = remember(preview) { if (preview.width * 0.97 <= preview.height) videoViewFullWidth(windowWidth) * 0.75f else 1000.dp } + Image( + preview.asImageBitmap(), + contentDescription = stringResource(R.string.video_descr), + modifier = Modifier + .width(width) + .combinedClickable( + onLongClick = { showMenu.value = true }, + onClick = onClick + ), + contentScale = ContentScale.FillWidth, + ) +} + +@Composable +private fun LocalWindowWidth(): Dp { + val view = LocalView.current + val density = LocalDensity.current.density + return remember { + val rect = Rect() + view.getWindowVisibleDisplayFrame(rect) + (rect.width() / density).dp + } +} + +@Composable +private fun progressIndicator() { + CircularProgressIndicator( + Modifier.size(16.dp), + color = Color.White, + strokeWidth = 2.dp + ) +} + +@Composable +private fun loadingIndicator(file: CIFile?) { + if (file != null) { + Box( + Modifier + .padding(8.dp) + .size(20.dp), + contentAlignment = Alignment.Center + ) { + when (file.fileStatus) { + is CIFileStatus.SndStored -> + when (file.fileProtocol) { + FileProtocol.XFTP -> progressIndicator() + FileProtocol.SMP -> {} + } + is CIFileStatus.SndTransfer -> + progressIndicator() + is CIFileStatus.SndComplete -> + Icon( + Icons.Filled.Check, + stringResource(R.string.icon_descr_video_snd_complete), + Modifier.fillMaxSize(), + tint = Color.White + ) + is CIFileStatus.RcvAccepted -> + Icon( + Icons.Outlined.MoreHoriz, + stringResource(R.string.icon_descr_waiting_for_video), + Modifier.fillMaxSize(), + tint = Color.White + ) + is CIFileStatus.RcvTransfer -> + progressIndicator() + is CIFileStatus.RcvInvitation -> + Icon( + Icons.Outlined.ArrowDownward, + stringResource(R.string.icon_descr_video_asked_to_receive), + Modifier.fillMaxSize(), + tint = Color.White + ) + else -> {} + } + } + } +} + +private fun fileSizeValid(file: CIFile?): Boolean { + if (file != null) { + return file.fileSize <= getMaxFileSize(file.fileProtocol) + } + return false +} + +private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) { + if (fileSizeValid(file)) { + receiveFile(file.fileId) + } else { + AlertManager.shared.showAlertMsg( + generalGetString(R.string.large_file), + String.format(generalGetString(R.string.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol))) + ) + } +} + +private fun videoViewFullWidth(windowWidth: Dp): Dp { + val approximatePadding = 100.dp + return minOf(1000.dp, windowWidth - approximatePadding) +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index 997a88c9a3..c8daf52bd1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -132,7 +132,7 @@ fun ChatItemView( copyText(context, cItem.content.text) showMenu.value = false }) - if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) { + if (cItem.content.msgContent is MsgContent.MCImage || cItem.content.msgContent is MsgContent.MCVideo || cItem.content.msgContent is MsgContent.MCFile || cItem.content.msgContent is MsgContent.MCVoice) { val filePath = getLoadedFilePath(context, cItem.file) if (filePath != null) { val writePermissionState = rememberPermissionState(permission = Manifest.permission.WRITE_EXTERNAL_STORAGE) @@ -145,8 +145,7 @@ fun ChatItemView( writePermissionState.launchPermissionRequest() } } - is MsgContent.MCFile -> saveFileLauncher.launch(cItem.file?.fileName) - is MsgContent.MCVoice -> saveFileLauncher.launch(cItem.file?.fileName) + is MsgContent.MCFile, is MsgContent.MCVoice, is MsgContent.MCVideo -> saveFileLauncher.launch(cItem.file?.fileName) else -> {} } showMenu.value = false diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 9400a1267e..4717ae2c4d 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -125,6 +125,18 @@ fun FramedItemView( modifier = Modifier.size(68.dp).clipToBounds() ) } + is MsgContent.MCVideo -> { + Box(Modifier.fillMaxWidth().weight(1f)) { + ciQuotedMsgView(qi) + } + val imageBitmap = base64ToBitmap(qi.content.image).asImageBitmap() + Image( + imageBitmap, + contentDescription = stringResource(R.string.video_descr), + contentScale = ContentScale.Crop, + modifier = Modifier.size(68.dp).clipToBounds() + ) + } is MsgContent.MCFile, is MsgContent.MCVoice -> { Box(Modifier.fillMaxWidth().weight(1f)) { ciQuotedMsgView(qi) @@ -151,7 +163,8 @@ fun FramedItemView( } } - val transparentBackground = (ci.content.msgContent is MsgContent.MCImage) && !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null + val transparentBackground = (ci.content.msgContent is MsgContent.MCImage || ci.content.msgContent is MsgContent.MCVideo) && + !ci.meta.isLive && ci.content.text.isEmpty() && ci.quotedItem == null Box(Modifier .clip(RoundedCornerShape(18.dp)) @@ -198,6 +211,14 @@ fun FramedItemView( CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler) } } + is MsgContent.MCVideo -> { + CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, receiveFile) + if (mc.text == "" && !ci.meta.isLive) { + metaColor = Color.White + } else { + CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler) + } + } is MsgContent.MCVoice -> { CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") }) if (mc.text != "") { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt index bf64c565f4..77ab795974 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ImageFullScreenView.kt @@ -3,21 +3,28 @@ package chat.simplex.app.views.chat.item import android.graphics.Bitmap import android.net.Uri import android.os.Build +import android.view.View import androidx.activity.compose.BackHandler import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* +import androidx.compose.material.* import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* import androidx.compose.ui.graphics.painter.BitmapPainter import androidx.compose.ui.input.pointer.* import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.* import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.* +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.view.isVisible import chat.simplex.app.R +import chat.simplex.app.views.chat.ProviderMedia import chat.simplex.app.views.helpers.* import coil.ImageLoader import coil.compose.rememberAsyncImagePainter @@ -26,13 +33,16 @@ import coil.decode.ImageDecoderDecoder import coil.request.ImageRequest import coil.size.Size import com.google.accompanist.pager.* +import com.google.android.exoplayer2.ui.AspectRatioFrameLayout +import com.google.android.exoplayer2.ui.StyledPlayerView +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.launch import kotlin.math.absoluteValue interface ImageGalleryProvider { val initialIndex: Int - val totalImagesSize: MutableState - fun getImage(index: Int): Pair? + val totalMediaSize: MutableState + fun getMedia(index: Int): ProviderMedia? fun currentPageChanged(index: Int) fun scrollToStart() fun onDismiss(index: Int) @@ -48,13 +58,17 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> // Pager doesn't ask previous page at initialization step who knows why. By not doing this, prev page is not checked and can be blank, // which makes this blank page visible for a moment. Prevent it by doing the check ourselves LaunchedEffect(Unit) { - if (provider.getImage(provider.initialIndex - 1) == null) { + if (provider.getMedia(provider.initialIndex - 1) == null) { provider.scrollToStart() pagerState.scrollToPage(0) } } val scope = rememberCoroutineScope() - HorizontalPager(count = remember { provider.totalImagesSize }.value, state = pagerState) { index -> + val playersToRelease = rememberSaveable { mutableSetOf() } + DisposableEffectOnGone( + whenGone = { playersToRelease.forEach { VideoPlayer.release(it, true, true) } } + ) + HorizontalPager(count = remember { provider.totalMediaSize }.value, state = pagerState) { index -> Column( Modifier .fillMaxSize() @@ -74,13 +88,13 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> if (settledCurrentPage != provider.initialIndex) provider.currentPageChanged(index) } - val image = provider.getImage(index) - if (image == null) { + val media = provider.getMedia(index) + if (media == null) { // No such image. Let's shrink total pages size or scroll to start of the list of pages to remove blank page automatically SideEffect { scope.launch { when (settledCurrentPage) { - index - 1 -> provider.totalImagesSize.value = settledCurrentPage + 1 + index - 1 -> provider.totalMediaSize.value = settledCurrentPage + 1 index + 1 -> { provider.scrollToStart() pagerState.scrollToPage(0) @@ -89,7 +103,6 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> } } } else { - val (imageBitmap: Bitmap, uri: Uri) = image var scale by remember { mutableStateOf(1f) } var translationX by remember { mutableStateOf(0f) } var translationY by remember { mutableStateOf(0f) } @@ -100,54 +113,106 @@ fun ImageFullScreenView(imageProvider: () -> ImageGalleryProvider, close: () -> translationX = 0f translationY = 0f } - // I'm making a new instance of imageLoader here because if I use one instance in multiple places - // after end of composition here a GIF from the first instance will be paused automatically which isn't what I want - val imageLoader = ImageLoader.Builder(LocalContext.current) - .components { - if (Build.VERSION.SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } + val modifier = Modifier + .onGloballyPositioned { + viewWidth = it.size.width } - .build() - Image( - rememberAsyncImagePainter( - ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(), - placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil - imageLoader = imageLoader - ), - contentDescription = stringResource(R.string.image_descr), - contentScale = ContentScale.Fit, - modifier = Modifier - .onGloballyPositioned { - viewWidth = it.size.width - } - .graphicsLayer( - scaleX = scale, - scaleY = scale, - translationX = translationX, - translationY = translationY, - ) - .pointerInput(Unit) { - detectTransformGestures( - { allowTranslate }, - onGesture = { _, pan, gestureZoom, _ -> - scale = (scale * gestureZoom).coerceIn(1f, 20f) - allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0 - if (scale > 1 && allowTranslate) { - translationX += pan.x * scale - translationY += pan.y * scale - } else if (allowTranslate) { - translationX = 0f - translationY = 0f - } + .graphicsLayer( + scaleX = scale, + scaleY = scale, + translationX = translationX, + translationY = translationY, + ) + .pointerInput(Unit) { + detectTransformGestures( + { allowTranslate }, + onGesture = { _, pan, gestureZoom, _ -> + scale = (scale * gestureZoom).coerceIn(1f, 20f) + allowTranslate = viewWidth * (scale - 1f) - ((translationX + pan.x * scale).absoluteValue * 2) > 0 + if (scale > 1 && allowTranslate) { + translationX += pan.x * scale + translationY += pan.y * scale + } else if (allowTranslate) { + translationX = 0f + translationY = 0f } - ) + } + ) + } + .fillMaxSize() + if (media is ProviderMedia.Image) { + val (uri: Uri, imageBitmap: Bitmap) = media + // I'm making a new instance of imageLoader here because if I use one instance in multiple places + // after end of composition here a GIF from the first instance will be paused automatically which isn't what I want + val imageLoader = ImageLoader.Builder(LocalContext.current) + .components { + if (Build.VERSION.SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } } - .fillMaxSize(), - ) + .build() + Image( + rememberAsyncImagePainter( + ImageRequest.Builder(LocalContext.current).data(data = uri).size(Size.ORIGINAL).build(), + placeholder = BitmapPainter(imageBitmap.asImageBitmap()), // show original image while it's still loading by coil + imageLoader = imageLoader + ), + contentDescription = stringResource(R.string.image_descr), + contentScale = ContentScale.Fit, + modifier = modifier, + ) + } else if (media is ProviderMedia.Video) { + val preview = remember(media.uri.path) { base64ToBitmap(media.preview) } + VideoView(modifier, media.uri, preview, index == settledCurrentPage) + DisposableEffect(Unit) { + onDispose { playersToRelease.add(media.uri) } + } + } } } } } + +@Composable +private fun VideoView(modifier: Modifier, uri: Uri, defaultPreview: Bitmap, currentPage: Boolean) { + val context = LocalContext.current + val player = remember(uri) { VideoPlayer.getOrCreate(uri, true, defaultPreview, 0L, true, context) } + val isCurrentPage = rememberUpdatedState(currentPage) + val play = { + player.play(true) + } + val stop = { + player.stop() + } + LaunchedEffect(Unit) { + player.enableSound(true) + snapshotFlow { isCurrentPage.value } + .distinctUntilChanged() + .collect { if (it) play() else stop() } + } + + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + AndroidView( + factory = { ctx -> + StyledPlayerView(ctx).apply { + resizeMode = if (ctx.resources.configuration.screenWidthDp > ctx.resources.configuration.screenHeightDp) { + AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT + } else { + AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH + } + setShowPreviousButton(false) + setShowNextButton(false) + setShowSubtitleButton(false) + setShowVrButton(false) + controllerAutoShow = false + findViewById(com.google.android.exoplayer2.R.id.exo_controls_background).setBackgroundColor(Color.Black.copy(alpha = 0.4f).toArgb()) + findViewById(com.google.android.exoplayer2.R.id.exo_settings).isVisible = false + this.player = player.player + } + }, + modifier + ) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt index f437e382bd..877e4170d5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/ChooseAttachmentView.kt @@ -15,6 +15,7 @@ import chat.simplex.app.views.newchat.ActionButton sealed class AttachmentOption { object TakePhoto: AttachmentOption() object PickImage: AttachmentOption() + object PickVideo: AttachmentOption() object PickFile: AttachmentOption() } @@ -45,6 +46,10 @@ fun ChooseAttachmentView( attachmentOption.value = AttachmentOption.PickImage hide() } + ActionButton(null, stringResource(R.string.from_gallery_button), icon = Icons.Outlined.Videocam) { + attachmentOption.value = AttachmentOption.PickVideo + hide() + } ActionButton(null, stringResource(R.string.choose_file), icon = Icons.Outlined.InsertDriveFile) { attachmentOption.value = AttachmentOption.PickFile hide() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt index d9a24b70d2..b5d08dad3a 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Enums.kt @@ -48,4 +48,5 @@ object UriSerializer : KSerializer { sealed class UploadContent { @Serializable data class SimpleImage(val uri: Uri): UploadContent() @Serializable data class AnimatedImage(val uri: Uri): UploadContent() + @Serializable data class Video(val uri: Uri, val duration: Int): UploadContent() } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt index b9ce2b5702..7e0e481bc1 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt @@ -39,6 +39,7 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder { } override fun start(onProgressUpdate: (position: Int?, finished: Boolean) -> Unit): String { + VideoPlayer.stopAll() AudioPlayer.stop() val rec: MediaRecorder recorder = initRecorder().also { rec = it } @@ -152,6 +153,7 @@ object AudioPlayer { return null } + VideoPlayer.stopAll() RecorderNative.stopRecording?.invoke() val current = currentlyPlaying.value if (current == null || current.first != filePath) { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index d361daa9af..252c5e4edc 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -10,6 +10,7 @@ import android.content.res.Resources import android.graphics.* import android.graphics.Typeface import android.graphics.drawable.Drawable +import android.media.MediaMetadataRetriever import android.net.Uri import android.os.* import android.provider.OpenableColumns @@ -549,6 +550,19 @@ fun getMaxFileSize(fileProtocol: FileProtocol): Long { } } +fun getBitmapFromVideo(uri: Uri, timestamp: Long? = null, random: Boolean = true): VideoPlayer.PreviewAndDuration { + val mmr = MediaMetadataRetriever() + mmr.setDataSource(SimplexApp.context, uri) + val durationMs = mmr.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() + val image = when { + timestamp != null -> mmr.getFrameAtTime(timestamp * 1000, MediaMetadataRetriever.OPTION_CLOSEST) + random -> mmr.frameAtTime + else -> mmr.getFrameAtIndex(0) + } + mmr.release() + return VideoPlayer.PreviewAndDuration(image, durationMs, timestamp ?: 0) +} + fun Color.darker(factor: Float = 0.1f): Color = Color(max(red * (1 - factor), 0f), max(green * (1 - factor), 0f), max(blue * (1 - factor), 0f), alpha) @@ -606,3 +620,24 @@ fun UriHandler.openUriCatching(uri: String) { Log.e(TAG, e.stackTraceToString()) } } + +fun IntSize.Companion.Saver(): Saver = Saver( + save = { it.width to it.height }, + restore = { IntSize(it.first, it.second) } +) + +@Composable +fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) { + val context = LocalContext.current + DisposableEffect(Unit) { + always() + val activity = context as? Activity ?: return@DisposableEffect onDispose {} + val orientation = activity.resources.configuration.orientation + onDispose { + whenDispose() + if (orientation == activity.resources.configuration.orientation) { + whenGone() + } + } + } +} \ No newline at end of file diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/VideoPlayer.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/VideoPlayer.kt new file mode 100644 index 0000000000..db8f8ccc32 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/VideoPlayer.kt @@ -0,0 +1,246 @@ +package chat.simplex.app.views.helpers + +import android.content.Context +import android.graphics.Bitmap +import android.media.session.PlaybackState +import android.net.Uri +import android.util.Log +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import chat.simplex.app.* +import chat.simplex.app.R +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.C.* +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.upstream.DefaultDataSource +import com.google.android.exoplayer2.upstream.DefaultHttpDataSource +import kotlinx.coroutines.* +import java.io.File + +class VideoPlayer private constructor( + private val uri: Uri, + private val gallery: Boolean, + private val defaultPreview: Bitmap, + defaultDuration: Long, + soundEnabled: Boolean, + context: Context +) { + companion object { + private val players: MutableMap, VideoPlayer> = mutableMapOf() + private val previewsAndDurations: MutableMap = mutableMapOf() + + fun getOrCreate( + uri: Uri, + gallery: Boolean, + defaultPreview: Bitmap, + defaultDuration: Long, + soundEnabled: Boolean, + context: Context + ): VideoPlayer = + players.getOrPut(uri to gallery) { VideoPlayer(uri, gallery, defaultPreview, defaultDuration, soundEnabled, context) } + + fun enableSound(enable: Boolean, fileName: String?, gallery: Boolean): Boolean = + player(fileName, gallery)?.enableSound(enable) == true + + private fun player(fileName: String?, gallery: Boolean): VideoPlayer? { + fileName ?: return null + return players.values.firstOrNull { player -> player.uri.path?.endsWith(fileName) == true && player.gallery == gallery } + } + + fun release(uri: Uri, gallery: Boolean, remove: Boolean) = + player(uri.path, gallery)?.release(remove) + + fun stopAll() { + players.values.forEach { it.stop() } + } + + fun releaseAll() { + players.values.forEach { it.release(false) } + players.clear() + previewsAndDurations.clear() + } + } + + data class PreviewAndDuration(val preview: Bitmap?, val duration: Long?, val timestamp: Long) + + private val currentVolume: Float + val soundEnabled: MutableState = mutableStateOf(soundEnabled) + val brokenVideo: MutableState = mutableStateOf(false) + val videoPlaying: MutableState = mutableStateOf(false) + val progress: MutableState = mutableStateOf(0L) + val duration: MutableState = mutableStateOf(defaultDuration) + val preview: MutableState = mutableStateOf(defaultPreview) + + init { + setPreviewAndDuration() + } + + val player = ExoPlayer.Builder(context, + DefaultRenderersFactory(context)) + /*.setLoadControl(DefaultLoadControl.Builder() + .setPrioritizeTimeOverSizeThresholds(false) // Could probably save some megabytes in memory in case it will be needed + .createDefaultLoadControl())*/ + .setSeekBackIncrementMs(10_000) + .setSeekForwardIncrementMs(10_000) + .build() + .apply { + // Repeat the same track endlessly + repeatMode = 1 + currentVolume = volume + if (!soundEnabled) { + volume = 0f + } + setAudioAttributes( + AudioAttributes.Builder() + .setContentType(CONTENT_TYPE_MUSIC) + .setUsage(USAGE_MEDIA) + .build(), + true // disallow to play multiple instances simultaneously + ) + } + + private val listener: MutableState<((position: Long?, state: TrackState) -> Unit)?> = mutableStateOf(null) + private var progressJob: Job? = null + + enum class TrackState { + PLAYING, PAUSED, STOPPED + } + + private fun start(seek: Long? = null, onProgressUpdate: (position: Long?, state: TrackState) -> Unit): Boolean { + val filepath = getAppFilePath(SimplexApp.context, uri) + if (filepath == null || !File(filepath).exists()) { + Log.e(TAG, "No such file: $uri") + brokenVideo.value = true + return false + } + + if (soundEnabled.value) { + RecorderNative.stopRecording?.invoke() + } + AudioPlayer.stop() + stopAll() + if (listener.value == null) { + runCatching { + val dataSourceFactory = DefaultDataSource.Factory(SimplexApp.context, DefaultHttpDataSource.Factory()) + val source = ProgressiveMediaSource.Factory(dataSourceFactory).createMediaSource(MediaItem.fromUri(uri)) + player.setMediaSource(source, seek ?: 0L) + }.onFailure { + Log.e(TAG, it.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message) + brokenVideo.value = true + return false + } + } + if (player.playbackState == PlaybackState.STATE_NONE || player.playbackState == PlaybackState.STATE_STOPPED) { + runCatching { player.prepare() }.onFailure { + // Can happen when video file is broken + Log.e(TAG, it.stackTraceToString()) + AlertManager.shared.showAlertMsg(generalGetString(R.string.unknown_error), it.message) + brokenVideo.value = true + return false + } + } + if (seek != null) player.seekTo(seek) + player.play() + listener.value = onProgressUpdate + // Player can only be accessed in one specific thread + progressJob = CoroutineScope(Dispatchers.Main).launch { + onProgressUpdate(player.currentPosition, TrackState.PLAYING) + while (isActive && player.playbackState != Player.STATE_IDLE && player.playWhenReady) { + // Even when current position is equal to duration, the player has isPlaying == true for some time, + // so help to make the playback stopped in UI immediately + if (player.currentPosition == player.duration) { + onProgressUpdate(player.currentPosition, TrackState.PLAYING) + break + } + delay(50) + onProgressUpdate(player.currentPosition, TrackState.PLAYING) + } + /* + * Since coroutine is still NOT canceled, means player ended (no stop/no pause). But in some cases + * the player can show position != duration even if they actually equal. + * Let's say to a listener that the position == duration in case of coroutine finished without cancel + * */ + if (isActive) { + onProgressUpdate(player.duration, TrackState.PAUSED) + } + onProgressUpdate(null, TrackState.PAUSED) + } + player.addListener(object: Player.Listener{ + override fun onIsPlayingChanged(isPlaying: Boolean) { + super.onIsPlayingChanged(isPlaying) + // Produce non-ideal transition from stopped to playing state while showing preview image in ChatView +// videoPlaying.value = isPlaying + } + }) + + return true + } + + fun stop() { + player.stop() + stopListener() + } + + private fun stopListener() { + val afterCoroutineCancel: CompletionHandler = { + // Notify prev video listener about stop + listener.value?.invoke(null, TrackState.STOPPED) + } + /** Preventing race by calling a code AFTER coroutine ends, so [TrackState] will be: + * [TrackState.PLAYING] -> [TrackState.PAUSED] -> [TrackState.STOPPED] (in this order) + * */ + if (progressJob != null) { + progressJob?.invokeOnCompletion(afterCoroutineCancel) + } else { + afterCoroutineCancel(null) + } + progressJob?.cancel() + progressJob = null + } + + fun play(resetOnEnd: Boolean) { + if (progress.value == duration.value) { + progress.value = 0 + } + videoPlaying.value = start(progress.value) { pro, _ -> + if (pro != null) { + progress.value = pro + } + if (pro == null || pro == duration.value) { + videoPlaying.value = false + if (pro == duration.value) { + progress.value = if (resetOnEnd) 0 else duration.value + }/* else if (state == TrackState.STOPPED) { + progress.value = 0 // + }*/ + } + } + } + + fun enableSound(enable: Boolean): Boolean { + if (soundEnabled.value == enable) return false + soundEnabled.value = enable + player.volume = if (enable) currentVolume else 0f + return true + } + + fun release(remove: Boolean) { + player.release() + if (remove) { + players.remove(uri to gallery) + } + } + + private fun setPreviewAndDuration() { + // It freezes main thread, doing it in IO thread + CoroutineScope(Dispatchers.IO).launch { + val previewAndDuration = previewsAndDurations.getOrPut(uri) { getBitmapFromVideo(uri) } + withContext(Dispatchers.Main) { + preview.value = previewAndDuration.preview ?: defaultPreview + duration.value = (previewAndDuration.duration ?: 0) + } + } + } +} diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 100389d6cc..a5c8534802 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -227,7 +227,9 @@ Cancel image preview Cancel file preview Too many images! + Too many videos! Only 10 images can be sent at the same time + Only 10 videos can be sent at the same time Decoding error The image cannot be decoded. Please, try a different image or contact developers. you are observer @@ -244,6 +246,15 @@ Image will be received when your contact is online, please wait or check later! Image saved to Gallery + + Video + Waiting for video + Asked to receive the video + Video sent + Waiting for video + Video will be received when your contact completes uploading it. + Video will be received when your contact is online, please wait or check later! + File Large file!