From 7968718ecba5c810262d594c533955da3eebb4e5 Mon Sep 17 00:00:00 2001 From: Evgenii Kozlov Date: Thu, 5 Jun 2025 19:59:30 +0200 Subject: [PATCH] DROID-2966 Chats | Enhancement | Displaying video thumbnail + Request playing video by OS media player (MVP) (#2505) --- .../anytype/ui/chats/ChatFragment.kt | 7 +- .../anytypeio/anytype/core_utils/ext/IOExt.kt | 15 ++++ feature-chats/build.gradle | 1 + .../feature_chats/presentation/ChatView.kt | 15 +++- .../presentation/ChatViewModel.kt | 60 ++++++++++++++-- .../anytype/feature_chats/ui/Attachments.kt | 69 +++++++++++++++++++ .../anytype/feature_chats/ui/ChatBox.kt | 2 +- .../feature_chats/ui/ChatBoxAttachments.kt | 29 ++++++++ .../anytype/feature_chats/ui/ChatScreen.kt | 13 +++- .../res/drawable/ic_chat_attachment_play.xml | 9 +++ gradle/libs.versions.toml | 2 + 11 files changed, 207 insertions(+), 15 deletions(-) create mode 100644 feature-chats/src/main/res/drawable/ic_chat_attachment_play.xml diff --git a/app/src/main/java/com/anytypeio/anytype/ui/chats/ChatFragment.kt b/app/src/main/java/com/anytypeio/anytype/ui/chats/ChatFragment.kt index 7870862764..83813a701c 100644 --- a/app/src/main/java/com/anytypeio/anytype/ui/chats/ChatFragment.kt +++ b/app/src/main/java/com/anytypeio/anytype/ui/chats/ChatFragment.kt @@ -1,7 +1,6 @@ package com.anytypeio.anytype.ui.chats import android.Manifest -import android.content.pm.PackageManager import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -14,7 +13,6 @@ import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.MaterialTheme @@ -41,8 +39,7 @@ import com.anytypeio.anytype.core_models.primitives.Space import com.anytypeio.anytype.core_models.primitives.SpaceId import com.anytypeio.anytype.core_utils.ext.arg import com.anytypeio.anytype.core_utils.ext.toast -import com.anytypeio.anytype.core_utils.intents.SystemAction -import com.anytypeio.anytype.core_utils.intents.SystemAction.* +import com.anytypeio.anytype.core_utils.intents.SystemAction.OpenUrl import com.anytypeio.anytype.core_utils.intents.proceedWithAction import com.anytypeio.anytype.core_utils.ui.BaseComposeFragment import com.anytypeio.anytype.di.common.componentManager @@ -113,7 +110,7 @@ class ChatFragment : BaseComposeFragment() { modifier = Modifier.weight(1f), vm = vm, onAttachObjectClicked = { showGlobalSearchBottomSheet = true }, - onMarkupLinkClicked = { proceedWithAction(SystemAction.OpenUrl(it)) }, + onMarkupLinkClicked = { proceedWithAction(OpenUrl(it)) }, onRequestOpenFullScreenImage = { url -> vm.onMediaPreview(url) }, onSelectChatReaction = vm::onSelectChatReaction, onViewChatReaction = { msg, emoji -> diff --git a/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/IOExt.kt b/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/IOExt.kt index 25b0ae82ed..af29a0e70e 100644 --- a/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/IOExt.kt +++ b/core-utils/src/main/java/com/anytypeio/anytype/core_utils/ext/IOExt.kt @@ -1,6 +1,7 @@ package com.anytypeio.anytype.core_utils.ext import android.content.Context +import android.net.Uri import java.io.IOException fun Context.getJsonDataFromAsset(fileName: String): String? = try { @@ -11,4 +12,18 @@ fun Context.getJsonDataFromAsset(fileName: String): String? = try { } catch (e: IOException) { e.printStackTrace() null +} + +fun getMimeType(context: Context, uri: Uri): String? { + return context.contentResolver.getType(uri) +} + +fun isImage(uri: Uri, context: Context): Boolean { + val mimeType = getMimeType(context, uri) + return mimeType?.startsWith("image/") == true +} + +fun isVideo(uri: Uri, context: Context): Boolean { + val mimeType = getMimeType(context, uri) + return mimeType?.startsWith("video/") == true } \ No newline at end of file diff --git a/feature-chats/build.gradle b/feature-chats/build.gradle index 31a760c343..9b00cb1f9b 100644 --- a/feature-chats/build.gradle +++ b/feature-chats/build.gradle @@ -40,6 +40,7 @@ dependencies { implementation libs.composeMaterial implementation libs.coilCompose + implementation libs.coilVideo annotationProcessor libs.glideCompiler implementation libs.glideCompose diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatView.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatView.kt index b087dad0ec..b0544d205d 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatView.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatView.kt @@ -91,6 +91,13 @@ sealed interface ChatView { val ext: String ): Attachment() + data class Video( + val target: Id, + val url: String, + val name: String, + val ext: String + ): Attachment() + data class Link( val target: Id, val wrapper: ObjectWrapper.Basic?, @@ -111,7 +118,8 @@ sealed interface ChatView { data class Media( val uri: String, - val state: State = State.Idle + val state: State = State.Idle, + val isVideo: Boolean = false ): ChatBoxAttachment() data class File( @@ -127,6 +135,11 @@ sealed interface ChatView { val url: Url ) : Existing() + data class Video( + val target: Id, + val url: Url + ) : Existing() + data class Link( val target: Id, val name: String, diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt index 893c97da5e..875c2e71a9 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/presentation/ChatViewModel.kt @@ -301,7 +301,7 @@ class ChatViewModel @Inject constructor( val wrapper = dependencies[attachment.target] ChatView.Message.Attachment.Image( target = attachment.target, - url = urlBuilder.medium(path = attachment.target), + url = urlBuilder.large(path = attachment.target), name = wrapper?.name.orEmpty(), ext = wrapper?.fileExt.orEmpty() ) @@ -317,6 +317,14 @@ class ChatViewModel @Inject constructor( ext = wrapper.fileExt.orEmpty() ) } + ObjectType.Layout.VIDEO -> { + ChatView.Message.Attachment.Video( + target = attachment.target, + url = urlBuilder.large(path = attachment.target), + name = wrapper.name.orEmpty(), + ext = wrapper.fileExt.orEmpty() + ) + } ObjectType.Layout.BOOKMARK -> { ChatView.Message.Attachment.Bookmark( id = wrapper.id, @@ -525,6 +533,14 @@ class ChatViewModel @Inject constructor( ) ) } + is ChatView.Message.ChatBoxAttachment.Existing.Video -> { + add( + Chat.Message.Attachment( + target = attachment.target, + type = Chat.Message.Attachment.Type.File + ) + ) + } is ChatView.Message.ChatBoxAttachment.Media -> { chatBoxAttachments.value = currAttachments.toMutableList().apply { set( @@ -538,13 +554,19 @@ class ChatViewModel @Inject constructor( UploadFile.Params( space = vmParams.space, path = attachment.uri, - type = Block.Content.File.Type.IMAGE + type = if (attachment.isVideo) + Block.Content.File.Type.VIDEO + else + Block.Content.File.Type.IMAGE ) ).onSuccess { file -> add( Chat.Message.Attachment( target = file.id, - type = Chat.Message.Attachment.Type.Image + type = if (attachment.isVideo) + Chat.Message.Attachment.Type.File + else + Chat.Message.Attachment.Type.Image ) ) chatBoxAttachments.value = currAttachments.toMutableList().apply { @@ -736,6 +758,14 @@ class ChatViewModel @Inject constructor( ) ) } + is ChatView.Message.Attachment.Video -> { + add( + ChatView.Message.ChatBoxAttachment.Existing.Video( + target = a.target, + url = a.url + ) + ) + } is ChatView.Message.Attachment.Bookmark -> { add( ChatView.Message.ChatBoxAttachment.Existing.Link( @@ -839,6 +869,13 @@ class ChatViewModel @Inject constructor( attachment.name } } + is ChatView.Message.Attachment.Video -> { + if (attachment.ext.isNotEmpty()) { + "${attachment.name}.${attachment.ext}" + } else { + attachment.name + } + } is ChatView.Message.Attachment.Gallery -> { val first = attachment.images.firstOrNull() if (first != null) { @@ -893,9 +930,12 @@ class ChatViewModel @Inject constructor( ) ) } - is ChatView.Message.Attachment.Gallery -> { + is ChatView.Message.Attachment.Video -> { // TODO } + is ChatView.Message.Attachment.Gallery -> { + // Do nothing. + } is ChatView.Message.Attachment.Bookmark -> { commands.emit(ViewModelCommand.Browse(attachment.url)) } @@ -922,11 +962,12 @@ class ChatViewModel @Inject constructor( } } - fun onChatBoxMediaPicked(uris: List) { + fun onChatBoxMediaPicked(uris: List) { Timber.d("onChatBoxMediaPicked: $uris") - chatBoxAttachments.value += uris.map { + chatBoxAttachments.value += uris.map { uri -> ChatView.Message.ChatBoxAttachment.Media( - uri = it + uri = uri.uri, + isVideo = uri.isVideo ) } } @@ -1193,6 +1234,11 @@ class ChatViewModel @Inject constructor( } } + data class ChatBoxMediaUri( + val uri: String, + val isVideo: Boolean = false + ) + sealed class ViewModelCommand { data object Exit : ViewModelCommand() data object OpenWidgets : ViewModelCommand() diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/Attachments.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/Attachments.kt index fedd908cff..189dcff1cf 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/Attachments.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/Attachments.kt @@ -1,5 +1,8 @@ package com.anytypeio.anytype.feature_chats.ui +import android.content.Context +import android.content.Intent +import android.net.Uri import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.Image import androidx.compose.foundation.background @@ -22,11 +25,17 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage import coil3.compose.rememberAsyncImagePainter +import coil3.request.ImageRequest +import coil3.request.crossfade +import coil3.video.videoFrameMillis import com.anytypeio.anytype.core_ui.common.DefaultPreviews import com.anytypeio.anytype.core_ui.views.PreviewTitle2Medium import com.anytypeio.anytype.core_ui.views.Relations3 @@ -39,6 +48,7 @@ import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi import com.bumptech.glide.integration.compose.GlideImage import com.bumptech.glide.load.DecodeFormat import com.bumptech.glide.load.engine.DiskCacheStrategy +import timber.log.Timber @Composable @OptIn(ExperimentalGlideComposeApi::class, ExperimentalFoundationApi::class) @@ -48,6 +58,8 @@ fun BubbleAttachments( onAttachmentLongClicked: (ChatView.Message.Attachment) -> Unit, isUserAuthor: Boolean ) { + Timber.d("Binding attachments: $attachments") + val context = LocalContext.current attachments.forEachIndexed { idx, attachment -> when (attachment) { is ChatView.Message.Attachment.Gallery -> { @@ -64,6 +76,52 @@ fun BubbleAttachments( index += rowSize } } + is ChatView.Message.Attachment.Video -> { + Box( + modifier = Modifier + .padding(horizontal = 4.dp) + .size(292.dp) + .background( + color = colorResource(R.color.shape_tertiary), + shape = RoundedCornerShape(12.dp) + ) + .combinedClickable( + onClick = { + requestPlayingVideoByOS(attachment, context) + }, + onLongClick = { + onAttachmentLongClicked(attachment) + } + ) + ) { + CircularProgressIndicator( + modifier = Modifier + .align(alignment = Alignment.Center) + .size(48.dp), + color = colorResource(R.color.glyph_active), + trackColor = colorResource(R.color.glyph_active).copy(alpha = 0.5f), + strokeWidth = 4.dp + ) + AsyncImage( + modifier = Modifier + .size(292.dp) + .clip(RoundedCornerShape(12.dp)), + model = ImageRequest.Builder(context) + .data(attachment.url) + .videoFrameMillis(0) + .crossfade(true) + .build(), + contentDescription = null, + contentScale = ContentScale.Crop + ) + + Image( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(id = R.drawable.ic_chat_attachment_play), + contentDescription = "Play button" + ) + } + } is ChatView.Message.Attachment.Image -> { Box( modifier = Modifier @@ -146,6 +204,17 @@ fun BubbleAttachments( } } +private fun requestPlayingVideoByOS( + attachment: ChatView.Message.Attachment.Video, + context: Context +) { + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(Uri.parse(attachment.url), "video/*") + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK + } + context.startActivity(intent) +} + @OptIn(ExperimentalFoundationApi::class) @Composable fun AttachedObject( diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBox.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBox.kt index 0d95c86e63..06a8e713ac 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBox.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBox.kt @@ -270,7 +270,7 @@ fun ChatBox( onClick = { showDropdownMenu = false uploadMediaLauncher.launch( - PickVisualMediaRequest(mediaType = ActivityResultContracts.PickVisualMedia.ImageOnly) + PickVisualMediaRequest(mediaType = ActivityResultContracts.PickVisualMedia.ImageAndVideo) ) } ) diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBoxAttachments.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBoxAttachments.kt index 39cb206115..e4b6377f60 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBoxAttachments.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatBoxAttachments.kt @@ -171,6 +171,35 @@ internal fun ChatBoxAttachments( } } } + is ChatView.Message.ChatBoxAttachment.Existing.Video -> { + item { + Box(modifier = Modifier.padding()) { + Image( + painter = rememberAsyncImagePainter(attachment.url), + contentDescription = null, + modifier = Modifier + .padding( + top = 12.dp, + end = 4.dp + ) + .size(72.dp) + .clip(RoundedCornerShape(8.dp)) + , + contentScale = ContentScale.Crop + ) + Image( + painter = painterResource(R.drawable.ic_clear_chatbox_attachment), + contentDescription = "Clear attachment icon", + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 6.dp) + .noRippleClickable { + onClearAttachmentClicked(attachment) + } + ) + } + } + } is ChatView.Message.ChatBoxAttachment.File -> { item { Box { diff --git a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt index 0976271c05..53cc9b673d 100644 --- a/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt +++ b/feature-chats/src/main/java/com/anytypeio/anytype/feature_chats/ui/ChatScreen.kt @@ -83,6 +83,7 @@ import com.anytypeio.anytype.core_ui.views.Caption1Medium import com.anytypeio.anytype.core_ui.views.Caption1Regular import com.anytypeio.anytype.core_ui.views.PreviewTitle2Regular import com.anytypeio.anytype.core_utils.common.DefaultFileInfo +import com.anytypeio.anytype.core_utils.ext.isVideo import com.anytypeio.anytype.core_utils.ext.parseImagePath import com.anytypeio.anytype.domain.chats.ChatContainer import com.anytypeio.anytype.feature_chats.R @@ -191,7 +192,17 @@ fun ChatScreenWrapper( onReplyMessage = vm::onReplyMessage, onClearReplyClicked = vm::onClearReplyClicked, onChatBoxMediaPicked = { uris -> - vm.onChatBoxMediaPicked(uris.map { it.parseImagePath(context = context) }) + vm.onChatBoxMediaPicked( + uris.map { + ChatViewModel.ChatBoxMediaUri( + uri = it.parseImagePath(context = context), + isVideo = isVideo( + uri = it, + context = context + ) + ) + } + ) }, onChatBoxFilePicked = { uris -> val infos = uris.mapNotNull { uri -> diff --git a/feature-chats/src/main/res/drawable/ic_chat_attachment_play.xml b/feature-chats/src/main/res/drawable/ic_chat_attachment_play.xml new file mode 100644 index 0000000000..f79365d15a --- /dev/null +++ b/feature-chats/src/main/res/drawable/ic_chat_attachment_play.xml @@ -0,0 +1,9 @@ + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9a44157bb2..859e594e7c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -61,6 +61,7 @@ roomVersion = '2.7.0' dataStoreVersion = '1.1.4' amplitudeVersion = '3.35.1' coilComposeVersion = '3.1.0' +coilVideoVersion = '3.2.0' sentryVersion = '7.13.0' firebaseBomVersion = "33.13.0" @@ -98,6 +99,7 @@ glide = { module = "com.github.bumptech.glide:glide", version.ref = "glideVersio glideCompiler = { module = "com.github.bumptech.glide:compiler", version.ref = "glideVersion" } glideCompose = { module = "com.github.bumptech.glide:compose", version.ref = "glideComposeVersion" } coilCompose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coilComposeVersion" } +coilVideo = { module = "io.coil-kt.coil3:coil-video", version.ref = "coilVideoVersion" } coilNetwork = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coilComposeVersion" } mockitoKotlin = { module = "org.mockito.kotlin:mockito-kotlin", version.ref = "mockitoKotlinVersion" } junit = { module = "junit:junit", version.ref = "junitVersion" }