Improves the rendering of inline metadata

This commit is contained in:
Vitor Pamplona
2023-12-11 16:22:01 -05:00
parent 6fe93fdf33
commit debae02421
6 changed files with 256 additions and 90 deletions
@@ -104,8 +104,8 @@ class RichTextParser() {
url = fullUrl, url = fullUrl,
description = frags["alt"]?.let { URLDecoder.decode(it, "UTF-8") }, description = frags["alt"]?.let { URLDecoder.decode(it, "UTF-8") },
hash = frags["x"]?.let { URLDecoder.decode(it, "UTF-8") }, hash = frags["x"]?.let { URLDecoder.decode(it, "UTF-8") },
dim = frags["blurhash"]?.let { URLDecoder.decode(it, "UTF-8") }, blurhash = frags["blurhash"]?.let { URLDecoder.decode(it, "UTF-8") },
uri = frags["dim"]?.let { URLDecoder.decode(it, "UTF-8") } dim = frags["dim"]?.let { URLDecoder.decode(it, "UTF-8") }
) )
} else { } else {
null null
@@ -2,11 +2,17 @@ package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.ImageDecoder
import android.media.MediaDataSource
import android.media.MediaMetadataRetriever
import android.media.MediaMetadataRetriever.BitmapParams
import android.os.Build
import android.util.Log import android.util.Log
import com.vitorpamplona.amethyst.ui.actions.ImageDownloader import com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.CryptoUtils import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey import com.vitorpamplona.quartz.encoders.toHexKey
import io.trbl.blurhash.BlurHash import io.trbl.blurhash.BlurHash
import java.io.IOException
import kotlin.math.roundToInt import kotlin.math.roundToInt
class FileHeader( class FileHeader(
@@ -76,8 +82,42 @@ class FileHeader(
} else { } else {
Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim) Pair(BlurHash.encode(intArray, mBitmap.width, mBitmap.height, 4, 4), dim)
} }
} else if (mimeType?.startsWith("video/") == true) {
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(ByteArrayMediaDataSource(data))
val newDim = mediaMetadataRetriever.prepareDimFromVideo() ?: dimPrecomputed
val blurhash = mediaMetadataRetriever.getThumbnail()?.let { thumbnail ->
val aspectRatio = (thumbnail.width).toFloat() / (thumbnail.height).toFloat()
val intArray = IntArray(thumbnail.width * thumbnail.height)
thumbnail.getPixels(
intArray,
0,
thumbnail.width,
0,
0,
thumbnail.width,
thumbnail.height
)
if (aspectRatio > 1) {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 9, (9 * (1 / aspectRatio)).roundToInt())
} else if (aspectRatio < 1) {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, (9 * aspectRatio).roundToInt(), 9)
} else {
BlurHash.encode(intArray, thumbnail.width, thumbnail.height, 4, 4)
}
}
if (newDim != "0x0") {
Pair(blurhash, newDim)
} else {
Pair(blurhash, null)
}
} else { } else {
Pair(null, dimPrecomputed) Pair(null, null)
} }
onReady(FileHeader(mimeType, hash, size, dim, blurHash)) onReady(FileHeader(mimeType, hash, size, dim, blurHash))
@@ -88,3 +128,79 @@ class FileHeader(
} }
} }
} }
fun MediaMetadataRetriever.getThumbnail(): Bitmap? {
val raw: ByteArray? = getEmbeddedPicture()
if (raw != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
return ImageDecoder.decodeBitmap(ImageDecoder.createSource(raw))
}
}
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val params = BitmapParams()
params.preferredConfig = Bitmap.Config.ARGB_8888
// Fall back to middle of video
// Note: METADATA_KEY_DURATION unit is in ms, not us.
val thumbnailTimeUs: Long = (extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0) * 1000 / 2
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
getFrameAtTime(thumbnailTimeUs, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, params)
} else {
null
}
} else {
null
}
}
fun MediaMetadataRetriever.prepareDimFromVideo(): String? {
val width = prepareVideoWidth() ?: return null
val height = prepareVideoHeight() ?: return null
return "${width}x$height"
}
fun MediaMetadataRetriever.prepareVideoWidth(): Int? {
val widthData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
return if (widthData.isNullOrEmpty()) {
null
} else {
widthData.toInt()
}
}
fun MediaMetadataRetriever.prepareVideoHeight(): Int? {
val heightData = extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
return if (heightData.isNullOrEmpty()) {
null
} else {
heightData.toInt()
}
}
class ByteArrayMediaDataSource(var imageData: ByteArray) : MediaDataSource() {
override fun getSize(): Long {
return imageData.size.toLong()
}
@Throws(IOException::class)
override fun readAt(position: Long, buffer: ByteArray, offset: Int, size: Int): Int {
if (position >= imageData.size) {
return -1
}
val newSize = if (position + size > imageData.size) {
size - ((position.toInt() + size) - imageData.size)
} else {
size
}
imageData.copyInto(buffer, offset, position.toInt(), position.toInt() + newSize)
return newSize
}
@Throws(IOException::class)
override fun close() {}
}
@@ -17,6 +17,7 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -44,6 +45,8 @@ import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.onGloballyPositioned
@@ -160,6 +163,8 @@ fun VideoView(
waveform: ImmutableList<Int>? = null, waveform: ImmutableList<Int>? = null,
artworkUri: String? = null, artworkUri: String? = null,
authorName: String? = null, authorName: String? = null,
dimensions: String? = null,
blurhash: String? = null,
nostrUriCallback: String? = null, nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null, onDialog: ((Boolean) -> Unit)? = null,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
@@ -168,22 +173,42 @@ fun VideoView(
) { ) {
val defaultToStart by remember(videoUri) { mutableStateOf(DefaultMutedSetting.value) } val defaultToStart by remember(videoUri) { mutableStateOf(DefaultMutedSetting.value) }
VideoViewInner( val automaticallyStartPlayback = remember {
videoUri = videoUri, mutableStateOf<Boolean>(
defaultToStart = defaultToStart, if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value
title = title, )
thumb = thumb, }
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers, if (!automaticallyStartPlayback.value) {
waveform = waveform, ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
artworkUri = artworkUri, } else {
authorName = authorName, val ratio = aspectRatio(dimensions)
nostrUriCallback = nostrUriCallback, val modifier = if (ratio != null && roundedCorner) {
alwaysShowVideo = alwaysShowVideo, Modifier.aspectRatio(ratio)
accountViewModel = accountViewModel, } else {
onControllerVisibilityChanged = onControllerVisibilityChanged, Modifier
onDialog = onDialog }
)
Box(modifier) {
VideoViewInner(
videoUri = videoUri,
defaultToStart = defaultToStart,
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers,
waveform = waveform,
artworkUri = artworkUri,
authorName = authorName,
dimensions = dimensions,
blurhash = blurhash,
nostrUriCallback = nostrUriCallback,
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog
)
}
}
} }
@Composable @Composable
@@ -198,68 +223,61 @@ fun VideoViewInner(
waveform: ImmutableList<Int>? = null, waveform: ImmutableList<Int>? = null,
artworkUri: String? = null, artworkUri: String? = null,
authorName: String? = null, authorName: String? = null,
dimensions: String? = null,
blurhash: String? = null,
nostrUriCallback: String? = null, nostrUriCallback: String? = null,
alwaysShowVideo: Boolean = false, automaticallyStartPlayback: State<Boolean>,
accountViewModel: AccountViewModel,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)? = null onDialog: ((Boolean) -> Unit)? = null
) { ) {
val automaticallyStartPlayback = remember { VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
mutableStateOf<Boolean>( val mediaItem = remember(videoUri) {
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value mutableStateOf(
) MediaItem.Builder()
} .setMediaId(videoUri)
.setUri(videoUri)
if (!automaticallyStartPlayback.value) { .setMediaMetadata(
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback) MediaMetadata.Builder()
} else { .setArtist(authorName?.ifBlank { null })
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen -> .setTitle(title?.ifBlank { null } ?: videoUri)
val mediaItem = remember(videoUri) { .setArtworkUri(
mutableStateOf( try {
MediaItem.Builder() if (artworkUri != null) {
.setMediaId(videoUri) Uri.parse(artworkUri)
.setUri(videoUri) } else {
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(authorName?.ifBlank { null })
.setTitle(title?.ifBlank { null } ?: videoUri)
.setArtworkUri(
try {
if (artworkUri != null) {
Uri.parse(artworkUri)
} else {
null
}
} catch (e: Exception) {
null null
} }
) } catch (e: Exception) {
.build() null
) }
.build() )
) .build()
} )
.build()
)
}
GetVideoController( GetVideoController(
mediaItem = mediaItem, mediaItem = mediaItem,
videoUri = videoUri, videoUri = videoUri,
defaultToStart = defaultToStart, defaultToStart = defaultToStart,
nostrUriCallback = nostrUriCallback nostrUriCallback = nostrUriCallback
) { controller, keepPlaying -> ) { controller, keepPlaying ->
RenderVideoPlayer( RenderVideoPlayer(
controller = controller, controller = controller,
thumbData = thumb, thumbData = thumb,
roundedCorner = roundedCorner, roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers, dimensions = dimensions,
waveform = waveform, blurhash = blurhash,
keepPlaying = keepPlaying, topPaddingForControllers = topPaddingForControllers,
automaticallyStartPlayback = automaticallyStartPlayback, waveform = waveform,
activeOnScreen = activeOnScreen, keepPlaying = keepPlaying,
modifier = modifier, automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged, activeOnScreen = activeOnScreen,
onDialog = onDialog modifier = modifier,
) onControllerVisibilityChanged = onControllerVisibilityChanged,
} onDialog = onDialog
)
} }
} }
} }
@@ -534,6 +552,8 @@ private fun RenderVideoPlayer(
controller: MediaController, controller: MediaController,
thumbData: VideoThumb?, thumbData: VideoThumb?,
roundedCorner: Boolean, roundedCorner: Boolean,
dimensions: String? = null,
blurhash: String? = null,
topPaddingForControllers: Dp = Dp.Unspecified, topPaddingForControllers: Dp = Dp.Unspecified,
waveform: ImmutableList<Int>? = null, waveform: ImmutableList<Int>? = null,
keepPlaying: MutableState<Boolean>, keepPlaying: MutableState<Boolean>,
@@ -586,6 +606,8 @@ private fun RenderVideoPlayer(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT ViewGroup.LayoutParams.WRAP_CONTENT
) )
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false controllerAutoShow = false
thumbData?.thumb?.let { defaultArtwork = it } thumbData?.thumb?.let { defaultArtwork = it }
hideController() hideController()
@@ -609,6 +631,19 @@ private fun RenderVideoPlayer(
} }
} }
val ratio = remember {
aspectRatio(dimensions)
}
if (ratio != null) {
DisplayBlurHash(
blurhash,
null,
ContentScale.Crop,
myModifier.aspectRatio(ratio)
)
}
AndroidView( AndroidView(
modifier = myModifier, modifier = myModifier,
factory = factory factory = factory
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.PagerState
@@ -77,6 +79,7 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.window.DialogProperties
import androidx.compose.ui.window.DialogWindowProvider import androidx.compose.ui.window.DialogWindowProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import coil.annotation.ExperimentalCoilApi import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter import coil.compose.AsyncImagePainter
@@ -145,7 +148,8 @@ class ZoomableUrlVideo(
dim: String? = null, dim: String? = null,
uri: String? = null, uri: String? = null,
val artworkUri: String? = null, val artworkUri: String? = null,
val authorName: String? = null val authorName: String? = null,
val blurhash: String? = null
) : ZoomableUrlContent(url, description, hash, dim, uri) ) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable @Immutable
@@ -246,6 +250,8 @@ fun ZoomableContentView(
title = content.description, title = content.description,
artworkUri = content.artworkUri, artworkUri = content.artworkUri,
authorName = content.authorName, authorName = content.authorName,
dimensions = content.dim,
blurhash = content.blurhash,
roundedCorner = roundedCorner, roundedCorner = roundedCorner,
nostrUriCallback = content.uri, nostrUriCallback = content.uri,
onDialog = { dialogOpen = true }, onDialog = { dialogOpen = true },
@@ -569,8 +575,9 @@ private fun AddedImageFeatures(
} }
} }
private fun aspectRatio(dim: String?): Float? { fun aspectRatio(dim: String?): Float? {
if (dim == null) return null if (dim == null) return null
if (dim == "0x0") return null
val parts = dim.split("x") val parts = dim.split("x")
if (parts.size != 2) return null if (parts.size != 2) return null
@@ -578,7 +585,12 @@ private fun aspectRatio(dim: String?): Float? {
return try { return try {
val width = parts[0].toFloat() val width = parts[0].toFloat()
val height = parts[1].toFloat() val height = parts[1].toFloat()
width / height
if (width < 0.1 || height < 0.1) {
null
} else {
width / height
}
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -664,7 +676,7 @@ private fun InlineLoadingIcon() =
} }
@Composable @Composable
private fun DisplayBlurHash( fun DisplayBlurHash(
blurhash: String?, blurhash: String?,
description: String?, description: String?,
contentScale: ContentScale, contentScale: ContentScale,
@@ -694,7 +706,6 @@ fun ZoomableImageDialog(
accountViewModel: AccountViewModel accountViewModel: AccountViewModel
) { ) {
val orientation = LocalConfiguration.current.orientation val orientation = LocalConfiguration.current.orientation
println("This Log only exists to force orientation listener $orientation")
Dialog( Dialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -704,6 +715,8 @@ fun ZoomableImageDialog(
) )
) { ) {
val view = LocalView.current val view = LocalView.current
val insets = ViewCompat.getRootWindowInsets(view)
val orientation = LocalConfiguration.current.orientation val orientation = LocalConfiguration.current.orientation
println("This Log only exists to force orientation listener $orientation") println("This Log only exists to force orientation listener $orientation")
@@ -810,6 +823,7 @@ private fun DialogContent(
Row( Row(
modifier = Modifier modifier = Modifier
.padding(10.dp) .padding(10.dp)
.statusBarsPadding().systemBarsPadding()
.fillMaxWidth(), .fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
@@ -947,6 +961,10 @@ private fun RenderImageOrVideo(
onToggleControllerVisibility: (() -> Unit)? = null, onToggleControllerVisibility: (() -> Unit)? = null,
accountViewModel: AccountViewModel accountViewModel: AccountViewModel
) { ) {
val automaticallyStartPlayback = remember {
mutableStateOf<Boolean>(true)
}
if (content is ZoomableUrlImage) { if (content is ZoomableUrlImage) {
val mainModifier = Modifier val mainModifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -968,16 +986,15 @@ private fun RenderImageOrVideo(
) )
} else if (content is ZoomableUrlVideo) { } else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView( VideoViewInner(
videoUri = content.url, videoUri = content.url,
title = content.description, title = content.description,
artworkUri = content.artworkUri, artworkUri = content.artworkUri,
authorName = content.authorName, authorName = content.authorName,
roundedCorner = roundedCorner, roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers, topPaddingForControllers = topPaddingForControllers,
onControllerVisibilityChanged = onControllerVisibilityChanged, automaticallyStartPlayback = automaticallyStartPlayback,
accountViewModel = accountViewModel, onControllerVisibilityChanged = onControllerVisibilityChanged
alwaysShowVideo = true
) )
} }
} else if (content is ZoomableLocalImage) { } else if (content is ZoomableLocalImage) {
@@ -1002,16 +1019,15 @@ private fun RenderImageOrVideo(
} else if (content is ZoomableLocalVideo) { } else if (content is ZoomableLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
content.localFile?.let { content.localFile?.let {
VideoView( VideoViewInner(
videoUri = it.toUri().toString(), videoUri = it.toUri().toString(),
title = content.description, title = content.description,
artworkUri = content.artworkUri, artworkUri = content.artworkUri,
authorName = content.authorName, authorName = content.authorName,
roundedCorner = roundedCorner, roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers, topPaddingForControllers = topPaddingForControllers,
onControllerVisibilityChanged = onControllerVisibilityChanged, automaticallyStartPlayback = automaticallyStartPlayback,
accountViewModel = accountViewModel, onControllerVisibilityChanged = onControllerVisibilityChanged
alwaysShowVideo = true
) )
} }
} }
@@ -134,7 +134,7 @@ fun MultiSetCompose(multiSetCard: MultiSetCard, routeForLastRead: String, showHi
} }
Log.d("Rendering Metrics", "All Galeries: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed - ") Log.d("Rendering Metrics", "All Galeries: ${baseNote.event?.content()?.split("\n")?.getOrNull(0)?.take(15)}.. $elapsed - ")
Row(remember { Modifier.fillMaxWidth() }) { Row(Modifier.fillMaxWidth()) {
Spacer(modifier = WidthAuthorPictureModifierWithPadding) Spacer(modifier = WidthAuthorPictureModifierWithPadding)
val (value, elapsed) = measureTimedValue { val (value, elapsed) = measureTimedValue {
@@ -160,8 +160,7 @@ private fun FeedLoaded(
) { ) {
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item -> itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
val defaultModifier = remember { val defaultModifier = remember {
Modifier Modifier.fillMaxWidth().animateItemPlacement()
.fillMaxWidth().animateItemPlacement()
} }
Row(defaultModifier) { Row(defaultModifier) {