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,
description = frags["alt"]?.let { URLDecoder.decode(it, "UTF-8") },
hash = frags["x"]?.let { URLDecoder.decode(it, "UTF-8") },
dim = frags["blurhash"]?.let { URLDecoder.decode(it, "UTF-8") },
uri = frags["dim"]?.let { URLDecoder.decode(it, "UTF-8") }
blurhash = frags["blurhash"]?.let { URLDecoder.decode(it, "UTF-8") },
dim = frags["dim"]?.let { URLDecoder.decode(it, "UTF-8") }
)
} else {
null
@@ -2,11 +2,17 @@ package com.vitorpamplona.amethyst.service
import android.graphics.Bitmap
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 com.vitorpamplona.amethyst.ui.actions.ImageDownloader
import com.vitorpamplona.quartz.crypto.CryptoUtils
import com.vitorpamplona.quartz.encoders.toHexKey
import io.trbl.blurhash.BlurHash
import java.io.IOException
import kotlin.math.roundToInt
class FileHeader(
@@ -76,8 +82,42 @@ class FileHeader(
} else {
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 {
Pair(null, dimPrecomputed)
Pair(null, null)
}
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.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.defaultMinSize
import androidx.compose.foundation.layout.fillMaxSize
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.graphics.Brush
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.boundsInWindow
import androidx.compose.ui.layout.onGloballyPositioned
@@ -160,6 +163,8 @@ fun VideoView(
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
dimensions: String? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
onDialog: ((Boolean) -> Unit)? = null,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
@@ -168,22 +173,42 @@ fun VideoView(
) {
val defaultToStart by remember(videoUri) { mutableStateOf(DefaultMutedSetting.value) }
VideoViewInner(
videoUri = videoUri,
defaultToStart = defaultToStart,
title = title,
thumb = thumb,
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers,
waveform = waveform,
artworkUri = artworkUri,
authorName = authorName,
nostrUriCallback = nostrUriCallback,
alwaysShowVideo = alwaysShowVideo,
accountViewModel = accountViewModel,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog
)
val automaticallyStartPlayback = remember {
mutableStateOf<Boolean>(
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value
)
}
if (!automaticallyStartPlayback.value) {
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
} else {
val ratio = aspectRatio(dimensions)
val modifier = if (ratio != null && roundedCorner) {
Modifier.aspectRatio(ratio)
} else {
Modifier
}
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
@@ -198,68 +223,61 @@ fun VideoViewInner(
waveform: ImmutableList<Int>? = null,
artworkUri: String? = null,
authorName: String? = null,
dimensions: String? = null,
blurhash: String? = null,
nostrUriCallback: String? = null,
alwaysShowVideo: Boolean = false,
accountViewModel: AccountViewModel,
automaticallyStartPlayback: State<Boolean>,
onControllerVisibilityChanged: ((Boolean) -> Unit)? = null,
onDialog: ((Boolean) -> Unit)? = null
) {
val automaticallyStartPlayback = remember {
mutableStateOf<Boolean>(
if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback.value
)
}
if (!automaticallyStartPlayback.value) {
ImageUrlWithDownloadButton(url = videoUri, showImage = automaticallyStartPlayback)
} else {
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
val mediaItem = remember(videoUri) {
mutableStateOf(
MediaItem.Builder()
.setMediaId(videoUri)
.setUri(videoUri)
.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) {
VideoPlayerActiveMutex(videoUri) { modifier, activeOnScreen ->
val mediaItem = remember(videoUri) {
mutableStateOf(
MediaItem.Builder()
.setMediaId(videoUri)
.setUri(videoUri)
.setMediaMetadata(
MediaMetadata.Builder()
.setArtist(authorName?.ifBlank { null })
.setTitle(title?.ifBlank { null } ?: videoUri)
.setArtworkUri(
try {
if (artworkUri != null) {
Uri.parse(artworkUri)
} else {
null
}
)
.build()
)
.build()
)
}
} catch (e: Exception) {
null
}
)
.build()
)
.build()
)
}
GetVideoController(
mediaItem = mediaItem,
videoUri = videoUri,
defaultToStart = defaultToStart,
nostrUriCallback = nostrUriCallback
) { controller, keepPlaying ->
RenderVideoPlayer(
controller = controller,
thumbData = thumb,
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers,
waveform = waveform,
keepPlaying = keepPlaying,
automaticallyStartPlayback = automaticallyStartPlayback,
activeOnScreen = activeOnScreen,
modifier = modifier,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog
)
}
GetVideoController(
mediaItem = mediaItem,
videoUri = videoUri,
defaultToStart = defaultToStart,
nostrUriCallback = nostrUriCallback
) { controller, keepPlaying ->
RenderVideoPlayer(
controller = controller,
thumbData = thumb,
roundedCorner = roundedCorner,
dimensions = dimensions,
blurhash = blurhash,
topPaddingForControllers = topPaddingForControllers,
waveform = waveform,
keepPlaying = keepPlaying,
automaticallyStartPlayback = automaticallyStartPlayback,
activeOnScreen = activeOnScreen,
modifier = modifier,
onControllerVisibilityChanged = onControllerVisibilityChanged,
onDialog = onDialog
)
}
}
}
@@ -534,6 +552,8 @@ private fun RenderVideoPlayer(
controller: MediaController,
thumbData: VideoThumb?,
roundedCorner: Boolean,
dimensions: String? = null,
blurhash: String? = null,
topPaddingForControllers: Dp = Dp.Unspecified,
waveform: ImmutableList<Int>? = null,
keepPlaying: MutableState<Boolean>,
@@ -586,6 +606,8 @@ private fun RenderVideoPlayer(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
setBackgroundColor(Color.Transparent.toArgb())
setShutterBackgroundColor(Color.Transparent.toArgb())
controllerAutoShow = false
thumbData?.thumb?.let { defaultArtwork = it }
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(
modifier = myModifier,
factory = factory
@@ -28,6 +28,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
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.widthIn
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.DialogWindowProvider
import androidx.core.net.toUri
import androidx.core.view.ViewCompat
import coil.annotation.ExperimentalCoilApi
import coil.compose.AsyncImage
import coil.compose.AsyncImagePainter
@@ -145,7 +148,8 @@ class ZoomableUrlVideo(
dim: String? = null,
uri: String? = null,
val artworkUri: String? = null,
val authorName: String? = null
val authorName: String? = null,
val blurhash: String? = null
) : ZoomableUrlContent(url, description, hash, dim, uri)
@Immutable
@@ -246,6 +250,8 @@ fun ZoomableContentView(
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
dimensions = content.dim,
blurhash = content.blurhash,
roundedCorner = roundedCorner,
nostrUriCallback = content.uri,
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 == "0x0") return null
val parts = dim.split("x")
if (parts.size != 2) return null
@@ -578,7 +585,12 @@ private fun aspectRatio(dim: String?): Float? {
return try {
val width = parts[0].toFloat()
val height = parts[1].toFloat()
width / height
if (width < 0.1 || height < 0.1) {
null
} else {
width / height
}
} catch (e: Exception) {
null
}
@@ -664,7 +676,7 @@ private fun InlineLoadingIcon() =
}
@Composable
private fun DisplayBlurHash(
fun DisplayBlurHash(
blurhash: String?,
description: String?,
contentScale: ContentScale,
@@ -694,7 +706,6 @@ fun ZoomableImageDialog(
accountViewModel: AccountViewModel
) {
val orientation = LocalConfiguration.current.orientation
println("This Log only exists to force orientation listener $orientation")
Dialog(
onDismissRequest = onDismiss,
@@ -704,6 +715,8 @@ fun ZoomableImageDialog(
)
) {
val view = LocalView.current
val insets = ViewCompat.getRootWindowInsets(view)
val orientation = LocalConfiguration.current.orientation
println("This Log only exists to force orientation listener $orientation")
@@ -810,6 +823,7 @@ private fun DialogContent(
Row(
modifier = Modifier
.padding(10.dp)
.statusBarsPadding().systemBarsPadding()
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
@@ -947,6 +961,10 @@ private fun RenderImageOrVideo(
onToggleControllerVisibility: (() -> Unit)? = null,
accountViewModel: AccountViewModel
) {
val automaticallyStartPlayback = remember {
mutableStateOf<Boolean>(true)
}
if (content is ZoomableUrlImage) {
val mainModifier = Modifier
.fillMaxSize()
@@ -968,16 +986,15 @@ private fun RenderImageOrVideo(
)
} else if (content is ZoomableUrlVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
VideoView(
VideoViewInner(
videoUri = content.url,
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers,
onControllerVisibilityChanged = onControllerVisibilityChanged,
accountViewModel = accountViewModel,
alwaysShowVideo = true
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged
)
}
} else if (content is ZoomableLocalImage) {
@@ -1002,16 +1019,15 @@ private fun RenderImageOrVideo(
} else if (content is ZoomableLocalVideo) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxSize(1f)) {
content.localFile?.let {
VideoView(
VideoViewInner(
videoUri = it.toUri().toString(),
title = content.description,
artworkUri = content.artworkUri,
authorName = content.authorName,
roundedCorner = roundedCorner,
topPaddingForControllers = topPaddingForControllers,
onControllerVisibilityChanged = onControllerVisibilityChanged,
accountViewModel = accountViewModel,
alwaysShowVideo = true
automaticallyStartPlayback = automaticallyStartPlayback,
onControllerVisibilityChanged = onControllerVisibilityChanged
)
}
}
@@ -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 - ")
Row(remember { Modifier.fillMaxWidth() }) {
Row(Modifier.fillMaxWidth()) {
Spacer(modifier = WidthAuthorPictureModifierWithPadding)
val (value, elapsed) = measureTimedValue {
@@ -160,8 +160,7 @@ private fun FeedLoaded(
) {
itemsIndexed(state.feed.value, key = { _, item -> item.id() }) { _, item ->
val defaultModifier = remember {
Modifier
.fillMaxWidth().animateItemPlacement()
Modifier.fillMaxWidth().animateItemPlacement()
}
Row(defaultModifier) {