diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/BlurHashDecoder.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/BlurHashDecoder.kt new file mode 100644 index 00000000..58c1debd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/BlurHashDecoder.kt @@ -0,0 +1,112 @@ +package ac.cord.auxiliary.compose.ui + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import coil3.Bitmap +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.withSign + +object BlurHashDecoder { + + fun decode(blurHash: String?, width: Int, height: Int, punch: Float = 1f): Bitmap? { + if (blurHash == null || blurHash.length < 6) return null + + val numComponents = decode83(blurHash, 0, 1) + val nx = (numComponents % 9) + 1 + val ny = (numComponents / 9) + 1 + + if (blurHash.length != 4 + 2 * nx * ny) return null + + val maxAc = (decode83(blurHash, 1, 2) + 1) / 166f + val colors = Array(nx * ny) { i -> + if (i == 0) { + val color83 = decode83(blurHash, 2, 6) + decodeDc(color83) + } else { + val color83 = decode83(blurHash, 4 + i * 2, 4 + i * 2 + 2) + decodeAc(color83, maxAc * punch) + } + } + + return null +// val bitmap = Bitmap(width, height, Bitmap.Config.ARGB_8888) +// val pixels = IntArray(width * height) +// +// for (y in 0 until height) { +// for (x in 0 until width) { +// var r = 0f +// var g = 0f +// var b = 0f +// +// for (j in 0 until ny) { +// for (i in 0 until nx) { +// val basis = cos(PI * x * i / width) * cos(PI * y * j / height) +// val color = colors[j * nx + i] +// r += color[0] * basis.toFloat() +// g += color[1] * basis.toFloat() +// b += color[2] * basis.toFloat() +// } +// } +// +// val red = linearToSrgb(r) +// val green = linearToSrgb(g) +// val blue = linearToSrgb(b) +// +// pixels[y * width + x] = Color(red, green, blue).toArgb() +// } +// } +// +// bitmap.setPixels(pixels, 0, width, 0, 0, width, height) +// return bitmap + } + + private fun decode83(str: String, start: Int, end: Int): Int { + var res = 0 + for (i in start until end) { + val c = str[i] + val digit = charMap[c] ?: 0 + res = res * 83 + digit + } + return res + } + + private fun decodeDc(color83: Int): FloatArray { + val r = (color83 shr 16) / 255f + val g = ((color83 shr 8) and 255) / 255f + val b = (color83 and 255) / 255f + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } + + private fun decodeAc(value: Int, maxAc: Float): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9f) * maxAc, + signedPow2((g - 9) / 9f) * maxAc, + signedPow2((b - 9) / 9f) * maxAc + ) + } + + private fun srgbToLinear(v: Float): Float { + val f = v / 255f + return if (f <= 0.04045f) f / 12.92f else ((f + 0.055f) / 1.055f).pow(2.4f) + } + + private fun linearToSrgb(v: Float): Int { + val f = v.coerceIn(0f, 1f) + return if (f <= 0.0031308f) { + (f * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * f.pow(1f / 2.4f) - 0.055f) * 255f + 0.5f).toInt() + } + } + + private fun signedPow2(v: Float) = v.pow(2f).withSign(v) + + private val charMap = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#$%*+,-.:;=?@[]^_{|}~" + .mapIndexed { i, c -> c to i } + .toMap() +} diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/ImageWithContextMenu.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/ImageWithContextMenu.kt new file mode 100644 index 00000000..a6eec812 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/ImageWithContextMenu.kt @@ -0,0 +1,125 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import ac.cord.auxiliary.compose.ui.BlurHashDecoder +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Image +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.unit.dp +import coil3.asImage + +@kotlin.OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun ImageWithContextMenu(meta: MediaMeta, onFullScreen: () -> Unit) { + val url = meta.url + val autoLoad = true + var loaded by remember { mutableStateOf(autoLoad) } + var showMenu by remember { mutableStateOf(false) } +// val context = LocalContext.current + val clipboardManager = LocalClipboardManager.current + + if (!loaded) { + Surface( + modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable { loaded = true }, + color = MaterialTheme.colorScheme.surfaceVariant + ) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = androidx.compose.foundation.layout.Arrangement.Center + ) { + Icon( + imageVector = Icons.Filled.Image, + contentDescription = "Load image", + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(Modifier.height(8.dp)) + Text( + "Tap to load", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + return + } + + val ratio = remember(meta.dimension) { parseAspectRatio(meta.dimension) } + + Box { + LoadingAsyncImage( + model = url, + contentDescription = "Image", + contentScale = ContentScale.FillWidth, + onClick = onFullScreen, + onLongClick = { showMenu = true }, + modifier = Modifier + .fillMaxWidth() + .let { if (ratio != null) it.aspectRatio(ratio) else it } + .clip(RoundedCornerShape(12.dp)), + ) + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + DropdownMenuItem( + text = { Text("Copy URL") }, + onClick = { + clipboardManager.setText(AnnotatedString(url)) + showMenu = false + } + ) + DropdownMenuItem( + text = { Text("Download") }, + onClick = { + showMenu = false +// scope.launch { MediaDownloader.downloadMedia(context, url) } + } + ) + } + } +} + +private fun parseAspectRatio(dim: String?): Float? { + if (dim == null) return null + val parts = dim.split('x') + if (parts.size != 2) return null + val w = parts[0].toFloatOrNull() ?: return null + val h = parts[1].toFloatOrNull() ?: return null + return if (h > 0) w / h else null +} diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LoadingAsyncImage.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LoadingAsyncImage.kt new file mode 100644 index 00000000..3f1d957a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/LoadingAsyncImage.kt @@ -0,0 +1,70 @@ +package ac.cord.auxiliary.compose.ui.composable.widgets.content + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun LoadingAsyncImage( + model: Any?, + contentDescription: String?, + contentScale: ContentScale, + modifier: Modifier = Modifier, + blurPainter: BitmapPainter? = null, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, +) { + var isLoading by remember { mutableStateOf(true) } + + Box(modifier = modifier) { + AsyncImage( + model = model, + contentDescription = contentDescription, + contentScale = contentScale, + placeholder = blurPainter, + onLoading = { isLoading = true }, + onSuccess = { isLoading = false }, + onError = { isLoading = false }, + modifier = Modifier + .fillMaxSize() + .then( + if (onClick != null || onLongClick != null) { + Modifier.combinedClickable( + onClick = onClick ?: {}, + onLongClick = onLongClick + ) + } else Modifier + ) + ) + + if (isLoading) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + color = if (blurPainter != null) Color.White else MaterialTheme.colorScheme.primary + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/QuotedAddressableNote.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/QuotedAddressableNote.kt index f5f9a98e..e19e0f15 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/QuotedAddressableNote.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/QuotedAddressableNote.kt @@ -1,6 +1,7 @@ package ac.cord.auxiliary.compose.ui.composable.widgets.content import ac.cord.auxiliary.compose.database.model.intermdiate.LocalNostrEvent +import ac.cord.auxiliary.compose.database.model.intermdiate.LocalQuotedNostrEvent import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -22,7 +23,7 @@ import androidx.compose.ui.unit.dp @Composable internal fun QuotedAddressableNote( - localNostrEvent: LocalNostrEvent? = null, + localQuotedNostrEvent: LocalQuotedNostrEvent?, kind: Int, dTag: String, author: String, @@ -34,15 +35,10 @@ internal fun QuotedAddressableNote( ) { // TODO: Sync quoted event... -// LaunchedEffect(kind, author, dTag) { -// if (eventRepo.findAddressableEvent(kind, author, dTag) == null) { -// eventRepo.requestAddressableEvent(kind, author, dTag, relayHints) -// } -// } - if (localNostrEvent?.localQuotedNostrEvent != null) { + if (localQuotedNostrEvent != null) { QuotedNote( - localQuotedNostrEvent = localNostrEvent.localQuotedNostrEvent, + localQuotedNostrEvent = localQuotedNostrEvent, onNoteClick = onNoteClick, noteActions = noteActions ) diff --git a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/RichContent.kt b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/RichContent.kt index 6dd3739a..34284c5d 100644 --- a/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/RichContent.kt +++ b/composeApp/src/commonMain/kotlin/ac/cord/auxiliary/compose/ui/composable/widgets/content/RichContent.kt @@ -250,12 +250,12 @@ fun RichContent( Logger.w("Doing the else: $group") val segment = group as ContentSegment when (segment) { -// is ContentSegment.ImageSegment -> { -// ImageWithContextMenu( -// meta = segment.meta, -// onFullScreen = { fullScreenImageUrl = segment.meta.url } -// ) -// } + is ContentSegment.ImageSegment -> { + ImageWithContextMenu( + meta = segment.meta, + onFullScreen = { fullScreenImageUrl = segment.meta.url } + ) + } // is ContentSegment.VideoSegment -> { // InlineVideoPlayerWithFullscreen( // meta = segment.meta, @@ -297,26 +297,27 @@ fun RichContent( is ContentSegment.NostrAddressableSegment -> { val kind = segment.kind when { -// kind == 1 || kind == 0 -> { -// if (localNostrEvent.quotedPost != null && segment.author != null) { -// QuotedAddressableNote( -// kind = kind, -// dTag = segment.dTag, -// author = segment.author, -// relayHints = segment.relays, -// onNoteClick = onNoteClick, -// onProfileClick = onProfileClick, -// noteActions = noteActions, -// style = style -// ) -// } else { -// Text( -// text = "nostr:${segment.dTag.take(12)}...", -// style = style, -// color = MaterialTheme.colorScheme.primary -// ) -// } -// } + kind == 1 || kind == 0 -> { + if (localQuotedNostrEvent != null && segment.author != null) { + QuotedAddressableNote( + localQuotedNostrEvent = localQuotedNostrEvent, + kind = kind, + dTag = segment.dTag, + author = segment.author, + relayHints = segment.relays, + onNoteClick = onNoteClick, + onProfileClick = onProfileClick, + noteActions = noteActions, + style = style + ) + } else { + Text( + text = "nostr:${segment.dTag.take(12)}...", + style = style, + color = MaterialTheme.colorScheme.primary + ) + } + } // kind == 30023 -> { // if (segment.author != null) { // // TODO: Add support for articles in localNostrEvents...