diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt index ebfcfe204..336a3a226 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/CachedRichTextParser.kt @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt index 9dfe2caee..54f60e1d9 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/service/FileHeader.kt @@ -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() {} +} diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt index 9d2e18425..17218010d 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/VideoView.kt @@ -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? = 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( + 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? = null, artworkUri: String? = null, authorName: String? = null, + dimensions: String? = null, + blurhash: String? = null, nostrUriCallback: String? = null, - alwaysShowVideo: Boolean = false, - accountViewModel: AccountViewModel, + automaticallyStartPlayback: State, onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, onDialog: ((Boolean) -> Unit)? = null ) { - val automaticallyStartPlayback = remember { - mutableStateOf( - 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? = null, keepPlaying: MutableState, @@ -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 diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index c1bb638f6..984118e96 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -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(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 ) } } diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index 618191e81..525e3ec35 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -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 { diff --git a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt index eb943717f..e1fdc4fbe 100644 --- a/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt +++ b/app/src/main/java/com/vitorpamplona/amethyst/ui/screen/CardFeedView.kt @@ -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) {