Reworked video preview in chat messages to prevent broken layout if thumbnail can't be extracted from video (or if picture is not displayable)

This commit is contained in:
Sylvain Berfini 2024-10-23 10:48:28 +02:00
parent 257352927d
commit cc6ec98846
12 changed files with 193 additions and 25 deletions

View file

@ -95,6 +95,12 @@ class MediaViewerFragment : GenericMainFragment() {
binding.videoPlayer.setAspectRatio(width, height)
}
}
viewModel.changeFullScreenModeEvent.observe(viewLifecycleOwner) {
it.consume { fullScreenMode ->
sharedViewModel.mediaViewerFullScreenMode.value = fullScreenMode
}
}
}
override fun onResume() {

View file

@ -64,6 +64,10 @@ class MediaViewModel @UiThread constructor() : GenericViewModel() {
MutableLiveData<Event<Pair<Int, Int>>>()
}
val changeFullScreenModeEvent: MutableLiveData<Event<Boolean>> by lazy {
MutableLiveData<Event<Boolean>>()
}
lateinit var mediaPlayer: MediaPlayer
private lateinit var filePath: String
@ -172,11 +176,18 @@ class MediaViewModel @UiThread constructor() : GenericViewModel() {
// Leave full screen when playback is done
fullScreenMode.postValue(false)
changeFullScreenModeEvent.postValue(Event(false))
}
setOnVideoSizeChangedListener { mediaPlayer, width, height ->
videoSizeChangedEvent.postValue(Event(Pair(width, height)))
}
try {
prepare()
} catch (e: Exception) {
fullScreenMode.postValue(false)
changeFullScreenModeEvent.postValue(Event(false))
Log.e("$TAG Failed to prepare video file: $e")
}
}
val durationInMillis = mediaPlayer.duration

View file

@ -21,7 +21,9 @@ package org.linphone.ui.main.chat.model
import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.METADATA_KEY_DURATION
import android.media.ThumbnailUtils
import android.net.Uri
import android.provider.MediaStore
import androidx.annotation.AnyThread
import androidx.annotation.UiThread
import androidx.lifecycle.MutableLiveData
@ -52,6 +54,10 @@ class FileModel @AnyThread constructor(
val transferProgress = MutableLiveData<Int>()
val mediaPreview = MutableLiveData<String>()
val mediaPreviewAvailable = MutableLiveData<Boolean>()
val mimeType: FileUtils.MimeType
val mimeTypeString: String
@ -79,6 +85,7 @@ class FileModel @AnyThread constructor(
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
init {
mediaPreviewAvailable.postValue(false)
transferProgress.postValue(-1)
formattedFileSize.postValue(FileUtils.bytesToDisplayableSize(fileSize))
@ -93,6 +100,14 @@ class FileModel @AnyThread constructor(
isImage = mimeType == FileUtils.MimeType.Image
isVideoPreview = mimeType == FileUtils.MimeType.Video
isAudio = mimeType == FileUtils.MimeType.Audio
if (isImage) {
mediaPreview.postValue(path)
mediaPreviewAvailable.postValue(true)
} else if (isVideoPreview) {
loadVideoPreview()
}
if (isVideoPreview || isAudio) {
getDuration()
}
@ -132,6 +147,25 @@ class FileModel @AnyThread constructor(
FileUtils.deleteFile(path)
}
@AnyThread
private fun loadVideoPreview() {
try {
Log.i("$TAG Try to create an image preview of video file [$path]")
val previewBitmap = ThumbnailUtils.createVideoThumbnail(
path,
MediaStore.Images.Thumbnails.MINI_KIND
)
if (previewBitmap != null) {
val previewPath = FileUtils.storeBitmap(previewBitmap, fileName)
Log.i("$TAG Preview of video file [$path] available at [$previewPath]")
mediaPreview.postValue(previewPath)
mediaPreviewAvailable.postValue(true)
}
} catch (e: Exception) {
Log.e("$TAG Failed to get image preview for file [$path]: $e")
}
}
@AnyThread
private fun getDuration() {
try {

View file

@ -309,18 +309,23 @@ fun ImageView.loadFileImage(file: String?) {
}
@UiThread
@BindingAdapter("coilBubble")
fun ImageView.loadImageForChatBubble(file: String?) {
loadImageForChatBubble(this, file, false)
@BindingAdapter("coilBubble", "coilBubbleFallback")
fun ImageView.loadImageForChatBubbleSingle(file: String?, fallback: View?) {
loadImageForChatBubble(this, file, false, fallback)
}
@UiThread
@BindingAdapter("coilBubbleGrid")
fun ImageView.loadImageForChatBubbleGrid(file: String?) {
loadImageForChatBubble(this, file, true)
@BindingAdapter("coilBubbleGrid", "coilBubbleFallback")
fun ImageView.loadImageForChatBubbleGrid(file: String?, fallback: View?) {
loadImageForChatBubble(this, file, true, fallback)
}
private fun loadImageForChatBubble(imageView: ImageView, file: String?, grid: Boolean) {
private fun loadImageForChatBubble(
imageView: ImageView,
file: String?,
grid: Boolean,
fallback: View?
) {
if (file.isNullOrEmpty()) return
val isImage = FileUtils.isExtensionImage((file))
@ -339,22 +344,23 @@ private fun loadImageForChatBubble(imageView: ImageView, file: String?, grid: Bo
if (isVideo) {
imageView.load(file) {
placeholder(R.drawable.image_square)
placeholder(R.drawable.file_video)
videoFrameMillis(0)
transformations(RoundedCornersTransformation(radius))
size(width, height)
listener(
onError = { _, result ->
Log.e(
"$TAG Error getting preview picture from video? [$file]: ${result.throwable}"
"$TAG Error getting preview picture from video (?) [$file]: ${result.throwable}"
)
fallback?.visibility = View.VISIBLE
imageView.visibility = View.GONE
}
)
}
} else {
imageView.load(file) {
placeholder(R.drawable.image_square)
placeholder(R.drawable.file_image)
// Can't have a transformation for gif file, breaks animation
if (FileUtils.getExtensionFromFileName(file) != "gif") {
transformations(RoundedCornersTransformation(radius))
@ -365,6 +371,7 @@ private fun loadImageForChatBubble(imageView: ImageView, file: String?, grid: Bo
Log.e(
"$TAG Error getting picture from file [$file]: ${result.throwable}"
)
fallback?.visibility = View.VISIBLE
imageView.visibility = View.GONE
}
)

View file

@ -22,6 +22,7 @@ package org.linphone.utils
import android.content.ContentValues
import android.content.Context
import android.database.CursorIndexOutOfBoundsException
import android.graphics.Bitmap
import android.net.Uri
import android.os.Environment
import android.os.ParcelFileDescriptor
@ -398,6 +399,19 @@ class FileUtils {
return file.exists()
}
@AnyThread
fun storeBitmap(bitmap: Bitmap, fileName: String): String {
val path = getFileStorageCacheDir("$fileName.jpg", true)
FileOutputStream(path).use { outputStream ->
bitmap.compress(
Bitmap.CompressFormat.JPEG,
100,
outputStream
)
}
return path.absolutePath
}
@AnyThread
suspend fun dumpStringToFile(data: String, to: File): Boolean {
try {

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M110.66,147.56a8,8 0,0 0,-13.32 0L76.49,178.85l-9.76,-15.18a8,8 0,0 0,-13.46 0l-36,56A8,8 0,0 0,24 232L152,232a8,8 0,0 0,6.66 -12.44ZM38.65,216 L60,182.79l9.63,15a8,8 0,0 0,13.39 0.11l21,-31.47L137.05,216ZM213.65,82.34 L157.65,26.34A8,8 0,0 0,152 24L56,24A16,16 0,0 0,40 40v88a8,8 0,0 0,16 0L56,40h88L144,88a8,8 0,0 0,8 8h48L200,216h-8a8,8 0,0 0,0 16h8a16,16 0,0 0,16 -16L216,88A8,8 0,0 0,213.66 82.34ZM160,51.31 L188.69,80L160,80Z"
android:fillColor="#4e6074"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="256"
android:viewportHeight="256">
<path
android:pathData="M213.66,82.34l-56,-56A8,8 0,0 0,152 24L56,24A16,16 0,0 0,40 40v72a8,8 0,0 0,16 0L56,40h88L144,88a8,8 0,0 0,8 8h48L200,216h-8a8,8 0,0 0,0 16h8a16,16 0,0 0,16 -16L216,88A8,8 0,0 0,213.66 82.34ZM160,51.31 L188.69,80L160,80ZM155.88,145a8,8 0,0 0,-8.12 0.22l-19.95,12.46A16,16 0,0 0,112 144L48,144a16,16 0,0 0,-16 16v48a16,16 0,0 0,16 16h64a16,16 0,0 0,15.81 -13.68l19.95,12.46A8,8 0,0 0,160 216L160,152A8,8 0,0 0,155.88 145ZM112,208L48,208L48,160h64v48ZM144,201.57 L128,191.57L128,176.43l16,-10Z"
android:fillColor="#4e6074"/>
</vector>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<corners android:radius="5dp" />
<solid android:color="?attr/color_main2_200" />
</shape>

View file

@ -26,6 +26,21 @@
android:visibility="@{model.isImage || model.isVideoPreview ? View.GONE : View.VISIBLE}"
app:constraint_referenced_ids="file_name, file_background, file_icon" />
<ImageView
android:id="@+id/broken_media_icon"
android:onClick="@{() -> model.onClick()}"
android:layout_width="@dimen/chat_bubble_grid_image_size"
android:layout_height="@dimen/chat_bubble_grid_image_size"
android:adjustViewBounds="true"
android:padding="18dp"
android:background="@drawable/shape_squircle_main2_200_5dp"
android:visibility="@{model.mediaPreviewAvailable ? View.GONE : View.VISIBLE, default=gone}"
android:contentDescription="@{model.isVideoPreview ? @string/content_description_chat_bubble_video : @string/content_description_chat_bubble_image}"
android:src="@{model.isVideoPreview ? @drawable/file_video : @drawable/file_image, default=@drawable/file_video}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:tint="?attr/color_main2_600" />
<ImageView
android:id="@+id/image"
android:onClick="@{() -> model.onClick()}"
@ -36,7 +51,8 @@
android:scaleType="centerCrop"
android:contentDescription="@{model.isVideoPreview ? @string/content_description_chat_bubble_video : @string/content_description_chat_bubble_image}"
android:visibility="@{model.isImage || model.isVideoPreview ? View.VISIBLE : View.GONE}"
coilBubbleGrid="@{model.path}"
coilBubbleGrid="@{model.mediaPreview}"
coilBubbleFallback="@{brokenMediaIcon}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
@ -50,16 +66,17 @@
android:textColor="@{model.isVideoPreview ? @color/white : @color/main2_600}"
android:textSize="12sp"
android:visibility="@{model.isVideoPreview &amp;&amp; model.audioVideoDuration.length() > 0 ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintBottom_toBottomOf="@id/image"
app:layout_constraintStart_toStartOf="@id/image"/>
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<ImageView
android:id="@+id/video_preview"
android:onClick="@{() -> model.onClick()}"
android:layout_width="@dimen/icon_size"
android:layout_height="@dimen/icon_size"
android:src="@drawable/play_fill"
android:contentDescription="@null"
android:visibility="@{model.isVideoPreview ? View.VISIBLE : View.GONE, default=gone}"
android:visibility="@{model.isVideoPreview &amp;&amp; model.mediaPreviewAvailable ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintTop_toTopOf="@id/image"
app:layout_constraintBottom_toBottomOf="@id/image"
app:layout_constraintStart_toStartOf="@id/image"

View file

@ -22,6 +22,22 @@
android:visibility="@{inflatedVisibility == View.VISIBLE ? View.VISIBLE : View.GONE}"
inflatedLifecycleOwner="@{true}">
<ImageView
android:id="@+id/broken_media_icon"
android:onClick="@{() -> model.onClick()}"
android:layout_width="@dimen/chat_bubble_grid_image_size"
android:layout_height="@dimen/chat_bubble_grid_image_size"
android:adjustViewBounds="true"
android:padding="18dp"
android:background="@drawable/shape_squircle_main2_200_5dp"
android:visibility="@{model.mediaPreviewAvailable ? View.GONE : View.VISIBLE, default=gone}"
android:contentDescription="@{model.isVideoPreview ? @string/content_description_chat_bubble_video : @string/content_description_chat_bubble_image}"
android:src="@{model.isVideoPreview ? @drawable/file_video : @drawable/file_image, default=@drawable/file_video}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:tint="?attr/color_main2_600" />
<ImageView
android:id="@+id/image"
android:onClick="@{() -> model.onClick()}"
@ -31,7 +47,8 @@
android:adjustViewBounds="true"
android:scaleType="fitCenter"
android:contentDescription="@{model.isVideoPreview ? @string/content_description_chat_bubble_video : @string/content_description_chat_bubble_image}"
coilBubble="@{model.path}"
coilBubble="@{model.mediaPreview}"
coilBubbleFallback="@{brokenMediaIcon}"
app:layout_constraintHeight_max="@dimen/chat_bubble_big_image_max_size"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
@ -41,6 +58,7 @@
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_600"
android:id="@+id/video_duration"
android:onClick="@{() -> model.onClick()}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
@ -48,16 +66,17 @@
android:textColor="@{model.isVideoPreview ? @color/white : @color/main2_600}"
android:textSize="12sp"
android:visibility="@{model.isVideoPreview &amp;&amp; model.audioVideoDuration.length() > 0 ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintBottom_toBottomOf="@id/image"
app:layout_constraintStart_toStartOf="@id/image"/>
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<ImageView
android:id="@+id/video_preview"
android:onClick="@{() -> model.onClick()}"
android:layout_width="@dimen/icon_size"
android:layout_height="@dimen/icon_size"
android:src="@drawable/play_fill"
android:contentDescription="@null"
android:visibility="@{model.isVideoPreview ? View.VISIBLE : View.GONE, default=gone}"
android:visibility="@{model.isVideoPreview &amp;&amp; model.mediaPreviewAvailable ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintTop_toTopOf="@id/image"
app:layout_constraintBottom_toBottomOf="@id/image"
app:layout_constraintStart_toStartOf="@id/image"

View file

@ -18,6 +18,21 @@
android:layout_height="wrap_content"
android:layout_margin="1dp">
<ImageView
android:id="@+id/broken_media_icon"
android:onClick="@{() -> model.onClick()}"
android:layout_width="@dimen/chat_bubble_grid_image_size"
android:layout_height="@dimen/chat_bubble_grid_image_size"
android:adjustViewBounds="true"
android:padding="18dp"
android:background="@drawable/shape_squircle_main2_200_5dp"
android:visibility="@{model.mediaPreviewAvailable ? View.GONE : View.VISIBLE, default=gone}"
android:contentDescription="@{model.isVideoPreview ? @string/content_description_chat_bubble_video : @string/content_description_chat_bubble_image}"
android:src="@{model.isVideoPreview ? @drawable/file_video : @drawable/file_image, default=@drawable/file_video}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:tint="?attr/color_main2_600" />
<ImageView
android:id="@+id/image"
android:layout_width="@dimen/chat_bubble_grid_image_size"
@ -26,7 +41,8 @@
android:scaleType="centerCrop"
android:contentDescription="@{model.isVideoPreview ? @string/content_description_chat_bubble_video : @string/content_description_chat_bubble_image}"
android:visibility="@{model.isImage || model.isVideoPreview ? View.VISIBLE : View.GONE}"
coilBubbleGrid="@{model.path}"
coilBubbleGrid="@{model.mediaPreview}"
coilBubbleFallback="@{brokenMediaIcon}"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"/>
@ -40,8 +56,8 @@
android:textColor="@{model.isVideoPreview ? @color/white : @color/main2_600}"
android:textSize="12sp"
android:visibility="@{model.isVideoPreview &amp;&amp; model.audioVideoDuration.length() > 0 ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintBottom_toBottomOf="@id/image"
app:layout_constraintStart_toStartOf="@id/image"/>
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"/>
<ImageView
android:id="@+id/video_preview"
@ -49,7 +65,7 @@
android:layout_height="@dimen/icon_size"
android:src="@drawable/play_fill"
android:contentDescription="@null"
android:visibility="@{model.isVideoPreview ? View.VISIBLE : View.GONE, default=gone}"
android:visibility="@{model.isVideoPreview &amp;&amp; model.mediaPreviewAvailable ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintTop_toTopOf="@id/image"
app:layout_constraintBottom_toBottomOf="@id/image"
app:layout_constraintStart_toStartOf="@id/image"

View file

@ -13,6 +13,24 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content">
<ImageView
android:id="@+id/broken_media_icon"
android:onClick="@{() -> model.onClick()}"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_margin="1dp"
android:adjustViewBounds="true"
android:padding="18dp"
android:background="@drawable/shape_squircle_main2_200_5dp"
android:visibility="@{model.mediaPreviewAvailable ? View.GONE : View.VISIBLE, default=gone}"
android:contentDescription="@{model.isVideoPreview ? @string/content_description_chat_bubble_video : @string/content_description_chat_bubble_image}"
android:src="@{model.isVideoPreview ? @drawable/file_video : @drawable/file_image, default=@drawable/file_video}"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintBottom_toBottomOf="parent"
app:tint="?attr/color_main2_600" />
<ImageView
android:id="@+id/image"
android:onClick="@{() -> model.onClick()}"
@ -22,7 +40,8 @@
android:adjustViewBounds="true"
android:scaleType="centerCrop"
android:contentDescription="@{model.isVideoPreview ? @string/content_description_chat_bubble_video : @string/content_description_chat_bubble_image}"
coilBubbleGrid="@{model.path}"
coilBubbleGrid="@{model.mediaPreview}"
coilBubbleFallback="@{brokenMediaIcon}"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
@ -47,6 +66,7 @@
<androidx.appcompat.widget.AppCompatTextView
style="@style/default_text_style_600"
android:id="@+id/audio_video_duration"
android:onClick="@{() -> model.onClick()}"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="5dp"
@ -59,11 +79,12 @@
<ImageView
android:id="@+id/video_preview"
android:onClick="@{() -> model.onClick()}"
android:layout_width="@dimen/icon_size"
android:layout_height="@dimen/icon_size"
android:src="@drawable/play_fill"
android:contentDescription="@null"
android:visibility="@{model.isVideoPreview ? View.VISIBLE : View.GONE, default=gone}"
android:visibility="@{model.isVideoPreview &amp;&amp; model.mediaPreviewAvailable ? View.VISIBLE : View.GONE, default=gone}"
app:layout_constraintTop_toTopOf="@id/image"
app:layout_constraintBottom_toBottomOf="@id/image"
app:layout_constraintStart_toStartOf="@id/image"