,
+ preFormatted: Boolean = false,
+ lazyListScope: LazyListScope,
+ @DrawableRes imagePlaceholder: Int,
+ onLinkClick: (String) -> Unit,
+ baseUrl: String,
+) {
+ var node = nodes.firstOrNull()
+ while (node != null) {
+ when (node) {
+ is TextNode -> {
+ if (preFormatted) {
+ append(node.wholeText)
+ } else {
+ if (endsWithWhitespace) {
+ node.text().trimStart().let { trimmed ->
+ if (trimmed.isNotEmpty()) {
+ append(trimmed)
+ }
+ }
+ } else {
+ node.text().let { text ->
+ if (text.isNotEmpty()) {
+ append(text)
+ }
+ }
+ }
+ }
+ }
+ is Element -> {
+ val element = node
+ when (element.tagName()) {
+ "p" -> {
+ // Readability4j inserts p-tags in divs for algorithmic purposes.
+ // They screw up formatting.
+ if (node.hasClass("readability-styled")) {
+ appendTextChildren(
+ element.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ } else {
+ withParagraph {
+ appendTextChildren(
+ element.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ }
+ "br" -> append('\n')
+ "h1" -> {
+ withParagraph {
+ withComposableStyle(
+ style = { h5Style().toSpanStyle() }
+ ) {
+ append(element.text())
+ }
+ }
+ }
+ "h2" -> {
+ withParagraph {
+ withComposableStyle(
+ style = { h5Style().toSpanStyle() }
+ ) {
+ append(element.text())
+ }
+ }
+ }
+ "h3" -> {
+ withParagraph {
+ withComposableStyle(
+ style = { h5Style().toSpanStyle() }
+ ) {
+ append(element.text())
+ }
+ }
+ }
+ "h4" -> {
+ withParagraph {
+ withComposableStyle(
+ style = { h5Style().toSpanStyle() }
+ ) {
+ append(element.text())
+ }
+ }
+ }
+ "h5" -> {
+ withParagraph {
+ withComposableStyle(
+ style = { h5Style().toSpanStyle() }
+ ) {
+ append(element.text())
+ }
+ }
+ }
+ "h6" -> {
+ withParagraph {
+ withComposableStyle(
+ style = { h5Style().toSpanStyle() }
+ ) {
+ append(element.text())
+ }
+ }
+ }
+ "strong", "b" -> {
+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
+ appendTextChildren(
+ element.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ "i", "em", "cite", "dfn" -> {
+ withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
+ appendTextChildren(
+ element.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ "tt" -> {
+ withStyle(SpanStyle(fontFamily = FontFamily.Monospace)) {
+ appendTextChildren(
+ element.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ "u" -> {
+ withStyle(SpanStyle(textDecoration = TextDecoration.Underline)) {
+ appendTextChildren(
+ element.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ "sup" -> {
+ withStyle(SpanStyle(baselineShift = BaselineShift.Superscript)) {
+ appendTextChildren(
+ element.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ "sub" -> {
+ withStyle(SpanStyle(baselineShift = BaselineShift.Subscript)) {
+ appendTextChildren(
+ element.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ "font" -> {
+ val fontFamily: FontFamily? = element.attr("face")?.asFontFamily()
+ withStyle(SpanStyle(fontFamily = fontFamily)) {
+ appendTextChildren(
+ element.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ "pre" -> {
+ appendTextChildren(
+ element.childNodes(),
+ preFormatted = true,
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ "code" -> {
+ if (element.parent()?.tagName() == "pre") {
+ terminateCurrentText()
+ lazyListScope.formatCodeBlock(
+ element = element,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ } else {
+ // inline code
+ withComposableStyle(
+ style = { codeInlineStyle() }
+ ) {
+ appendTextChildren(
+ element.childNodes(),
+ preFormatted = preFormatted,
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ }
+ "blockquote" -> {
+ withParagraph {
+ withComposableStyle(
+ style = { blockQuoteStyle() }
+ ) {
+ appendTextChildren(
+ element.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ }
+ "a" -> {
+ withComposableStyle(
+ style = { linkTextStyle().toSpanStyle() }
+ ) {
+ withAnnotation("URL", element.attr("abs:href") ?: "") {
+ appendTextChildren(
+ element.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ }
+ "img" -> {
+ val imageCandidates = getImageSource(baseUrl, element)
+ if (imageCandidates.hasImage) {
+ val alt = element.attr("alt") ?: ""
+ appendImage(onLinkClick = onLinkClick) { onClick ->
+ lazyListScope.item {
+// val scale = remember { mutableStateOf(1f) }
+ Column(
+ modifier = Modifier
+// .padding(horizontal = PADDING_HORIZONTAL.dp)
+ .width(MAX_CONTENT_WIDTH.dp)
+ ) {
+ DisableSelection {
+ BoxWithConstraints(
+ modifier = Modifier
+ .clip(RectangleShape)
+ .clickable(
+ enabled = onClick != null
+ ) {
+ onClick?.invoke()
+ }
+ .fillMaxWidth()
+ // This makes scrolling a pain, find a way to solve that
+// .pointerInput("imgzoom") {
+// detectTransformGestures { centroid, pan, zoom, rotation ->
+// val z = zoom * scale.value
+// scale.value = when {
+// z < 1f -> 1f
+// z > 3f -> 3f
+// else -> z
+// }
+// }
+// }
+ ) {
+ val imageSize = maxImageSize()
+ AsyncImage(
+ modifier = Modifier.fillMaxWidth(),
+ data = imageCandidates.getBestImageForMaxSize(
+ pixelDensity = pixelDensity(),
+ maxSize = imageSize,
+ ),
+ contentDescription = alt,
+ size = imageSize,
+ precision = Precision.INEXACT,
+ contentScale = ContentScale.FillWidth,
+ )
+ }
+ }
+
+ if (alt.isNotBlank()) {
+ Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp / 2))
+
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = PADDING_HORIZONTAL.dp),
+ text = alt,
+ style = captionStyle(),
+ )
+ }
+
+ Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp))
+ }
+ }
+ }
+ }
+ }
+ "ul" -> {
+ element.children()
+ .filter { it.tagName() == "li" }
+ .forEach { listItem ->
+ withParagraph {
+ // no break space
+ append("• ")
+ appendTextChildren(
+ listItem.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ }
+ "ol" -> {
+ element.children()
+ .filter { it.tagName() == "li" }
+ .forEachIndexed { i, listItem ->
+ withParagraph {
+ // no break space
+ append("${i + 1}. ")
+ appendTextChildren(
+ listItem.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ }
+ "table" -> {
+ appendTable {
+ /*
+ In this order:
+ optionally a caption element (containing text children for instance),
+ followed by zero or more colgroup elements,
+ followed optionally by a thead element,
+ followed by either zero or more tbody elements
+ or one or more tr elements,
+ followed optionally by a tfoot element
+ */
+ element.children()
+ .filter { it.tagName() == "caption" }
+ .forEach {
+ appendTextChildren(
+ it.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ ensureDoubleNewline()
+ terminateCurrentText()
+ }
+
+ element.children()
+ .filter { it.tagName() == "thead" || it.tagName() == "tbody" || it.tagName() == "tfoot" }
+ .flatMap {
+ it.children()
+ .filter { it.tagName() == "tr" }
+ }
+ .forEach { row ->
+ appendTextChildren(
+ row.childNodes(),
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ terminateCurrentText()
+ }
+
+ append("\n\n")
+ }
+ }
+ "iframe" -> {
+ val video: Video? = getVideo(element.attr("abs:src"))
+
+ if (video != null) {
+ appendImage(onLinkClick = onLinkClick) {
+ lazyListScope.item {
+ Column(
+ modifier = Modifier
+ .padding(horizontal = PADDING_HORIZONTAL.dp)
+ .width(MAX_CONTENT_WIDTH.dp)
+ ) {
+ DisableSelection {
+ BoxWithConstraints(
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ AsyncImage(
+ modifier = Modifier
+ .clickable {
+ onLinkClick(video.link)
+ }
+ .fillMaxWidth(),
+ data = video.imageUrl,
+ size = maxImageSize(),
+ contentDescription = "点击播放视频",
+ precision = Precision.INEXACT,
+ contentScale = ContentScale.FillWidth,
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp / 2))
+
+ Text(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = PADDING_HORIZONTAL.dp),
+ text = "点击播放视频",
+ style = captionStyle(),
+ )
+
+ Spacer(modifier = Modifier.height(PADDING_HORIZONTAL.dp))
+ }
+ }
+ }
+ }
+ }
+ "video" -> {
+ // not implemented yet. remember to disable selection
+ }
+ else -> {
+ appendTextChildren(
+ nodes = element.childNodes(),
+ preFormatted = preFormatted,
+ lazyListScope = lazyListScope,
+ imagePlaceholder = imagePlaceholder,
+ onLinkClick = onLinkClick,
+ baseUrl = baseUrl,
+ )
+ }
+ }
+ }
+ }
+
+ node = node.nextSibling()
+ }
+}
+
+@OptIn(ExperimentalStdlibApi::class)
+private fun String.asFontFamily(): FontFamily? = when (this.lowercase()) {
+ "monospace" -> FontFamily.Monospace
+ "serif" -> FontFamily.Serif
+ "sans-serif" -> FontFamily.SansSerif
+ else -> null
+}
+
+@Preview
+@Composable
+private fun testIt() {
+ val html = """
+ In Gimp you go to Image in the top menu bar and select Mode followed by Indexed. Now you see a popup where you can select the number of colors for a generated optimum palette.
You’ll have to experiment a little because it will depend on your image.
I used this approach to shrink the size of the cover image in the_zopfli post from a 37KB (JPG) to just 15KB (PNG, all PNG sizes listed include Zopfli compression btw).
Straight JPG to PNG conversion: 124KB
First off, I exported the JPG file as a PNG file. This PNG file had a whopping 124KB! Clearly there was some bloat being stored.
256 colors: 40KB
Reducing from RGB to only 256 colors has no visible effect to my eyes.
128 colors: 34KB
Still no difference.
64 colors: 25KB
You can start to see some artifacting in the shadow behind the text.
32 colors: 15KB
In my opinion this is the sweet spot. The shadow artifacting is barely noticable but the size is significantly reduced.
16 colors: 11KB
Clear artifacting in the text shadow and the yellow (fire?) in the background has developed an outline.
8 colors: 7.3KB
The broom has shifted in color from a clear brown to almost grey. Text shadow is just a grey blob at this point. Even clearer outline developed on the yellow background.
4 colors: 4.3KB
Interestingly enough, I think 4 colors looks better than 8 colors. The outline in the background has disappeared because there’s not enough color spectrum to render it. The broom is now black and filled areas tend to get a white separator to the outlines.
2 colors: 2.4KB
Well, at least the silhouette is well defined at this point I guess.
Other posts in the Migrating from Ghost to Hugo series:
+ """.trimIndent()
+
+ html.byteInputStream().use { stream ->
+ LazyColumn {
+ htmlFormattedText(
+ inputStream = stream,
+ baseUrl = "https://cowboyprogrammer.org",
+ imagePlaceholder = R.drawable.ic_telegram,
+ onLinkClick = {}
+ )
+ }
+ }
+}
+
+@Composable
+private fun pixelDensity() = with(LocalDensity.current) {
+ density
+}
+
+@Composable
+private fun BoxWithConstraintsScope.maxImageSize() = with(LocalDensity.current) {
+ val maxWidthPx = maxWidth.toPx().roundToInt()
+
+ Size(
+ width = maxWidth.toPx().roundToInt().coerceAtLeast(1),
+ height = maxHeight
+ .toPx()
+ .roundToInt()
+ .coerceAtLeast(1)
+ .coerceAtMost(10 * maxWidthPx),
+ )
+}
+
+/**
+ * Gets the url to the image in the tag - could be from srcset or from src
+ */
+internal fun getImageSource(baseUrl: String, element: Element) = ImageCandidates(
+ baseUrl = baseUrl,
+ srcSet = element.attr("srcset") ?: "",
+ absSrc = element.attr("abs:src") ?: "",
+)
+
+internal class ImageCandidates(
+ val baseUrl: String,
+ val srcSet: String,
+ val absSrc: String
+) {
+ val hasImage: Boolean = srcSet.isNotBlank() || absSrc.isNotBlank()
+
+ /**
+ * Might throw if hasImage returns false
+ */
+ fun getBestImageForMaxSize(maxSize: Size, pixelDensity: Float): String {
+ val setCandidate = srcSet.splitToSequence(",")
+ .map { it.trim() }
+ .map { it.split(SpaceRegex).take(2).map { x -> x.trim() } }
+ .fold(100f to "") { acc, candidate ->
+ val candidateSize = if (candidate.size == 1) {
+ // Assume it corresponds to 1x pixel density
+ 1.0f / pixelDensity
+ } else {
+ val descriptor = candidate.last()
+ when {
+ descriptor.endsWith("w", ignoreCase = true) -> {
+ descriptor.substringBefore("w").toFloat() / maxSize.width.pxOrElse { 1 }
+ }
+ descriptor.endsWith("x", ignoreCase = true) -> {
+ descriptor.substringBefore("x").toFloat() / pixelDensity
+ }
+ else -> {
+ return@fold acc
+ }
+ }
+ }
+
+ if (abs(candidateSize - 1.0f) < abs(acc.first - 1.0f)) {
+ candidateSize to candidate.first()
+ } else {
+ acc
+ }
+ }
+ .second
+
+ if (setCandidate.isNotBlank()) {
+ return StringUtil.resolve(baseUrl, setCandidate)
+ }
+ return StringUtil.resolve(baseUrl, absSrc)
+ }
+}
+
+private val SpaceRegex = Regex("\\s+")
diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/Reader.kt b/app/src/main/java/me/ash/reader/ui/component/reader/Reader.kt
new file mode 100644
index 0000000..0a47d3a
--- /dev/null
+++ b/app/src/main/java/me/ash/reader/ui/component/reader/Reader.kt
@@ -0,0 +1,49 @@
+/*
+ * Feeder: Android RSS reader app
+ * https://gitlab.com/spacecowboy/Feeder
+ *
+ * Copyright (C) 2022 Jonas Kalderstam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package me.ash.reader.ui.component.reader
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.util.Log
+import androidx.compose.foundation.lazy.LazyListScope
+import me.ash.reader.R
+
+fun LazyListScope.reader(
+ context: Context,
+ link: String,
+ content: String,
+) {
+ Log.i("RLog", "Reader: ")
+ htmlFormattedText(
+ inputStream = content.byteInputStream(),
+ baseUrl = link,
+ imagePlaceholder = R.drawable.ic_launcher_foreground,
+ onLinkClick = {
+ context.startActivity(
+ Intent(
+ Intent.ACTION_VIEW,
+ Uri.parse(it)
+ )
+ )
+ }
+ )
+}
\ No newline at end of file
diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/Styles.kt b/app/src/main/java/me/ash/reader/ui/component/reader/Styles.kt
new file mode 100644
index 0000000..7ed82e9
--- /dev/null
+++ b/app/src/main/java/me/ash/reader/ui/component/reader/Styles.kt
@@ -0,0 +1,122 @@
+/*
+ * Feeder: Android RSS reader app
+ * https://gitlab.com/spacecowboy/Feeder
+ *
+ * Copyright (C) 2022 Jonas Kalderstam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package me.ash.reader.ui.component.reader
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.unit.dp
+import me.ash.reader.ui.ext.alphaLN
+
+const val PADDING_HORIZONTAL = 24.0
+const val MAX_CONTENT_WIDTH = 840.0
+
+@Composable
+fun bodyForeground(): Color =
+ MaterialTheme.colorScheme.onSurfaceVariant
+
+@Composable
+fun bodyStyle(): TextStyle =
+ MaterialTheme.typography.bodyLarge.copy(
+ color = bodyForeground()
+ )
+
+@Composable
+fun h1Style(): TextStyle =
+ MaterialTheme.typography.displayMedium.copy(
+ color = bodyForeground()
+ )
+
+@Composable
+fun h2Style(): TextStyle =
+ MaterialTheme.typography.displaySmall.copy(
+ color = bodyForeground()
+ )
+
+@Composable
+fun h3Style(): TextStyle =
+ MaterialTheme.typography.headlineLarge.copy(
+ color = bodyForeground()
+ )
+
+@Composable
+fun h4Style(): TextStyle =
+ MaterialTheme.typography.headlineMedium.copy(
+ color = bodyForeground()
+ )
+
+@Composable
+fun h5Style(): TextStyle =
+ MaterialTheme.typography.headlineSmall.copy(
+ color = bodyForeground()
+ )
+
+@Composable
+fun h6Style(): TextStyle =
+ MaterialTheme.typography.titleLarge.copy(
+ color = bodyForeground()
+ )
+
+@Composable
+fun captionStyle(): TextStyle =
+ MaterialTheme.typography.bodySmall.copy(
+ color = bodyForeground().copy(alpha = 0.6f)
+ )
+
+@Composable
+fun linkTextStyle(): TextStyle =
+ TextStyle(
+ color = MaterialTheme.colorScheme.secondary,
+ textDecoration = TextDecoration.Underline
+ )
+
+@Composable
+fun codeBlockStyle(): TextStyle =
+ MaterialTheme.typography.titleSmall.merge(
+ SpanStyle(
+ color = bodyForeground(),
+ fontFamily = FontFamily.Monospace
+ )
+ )
+
+@Composable
+fun codeBlockBackground(): Color =
+ MaterialTheme.colorScheme.secondary.copy(alpha = (0.dp).alphaLN(weight = 3.2f))
+
+@Composable
+fun blockQuoteStyle(): SpanStyle =
+ MaterialTheme.typography.titleSmall.toSpanStyle().merge(
+ SpanStyle(
+ fontWeight = FontWeight.Light
+ )
+ )
+
+@Composable
+fun codeInlineStyle(): SpanStyle =
+ MaterialTheme.typography.titleSmall.toSpanStyle().copy(
+ color = bodyForeground(),
+ fontFamily = FontFamily.Monospace,
+ )
diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/TextComposer.kt b/app/src/main/java/me/ash/reader/ui/component/reader/TextComposer.kt
new file mode 100644
index 0000000..e38d0e6
--- /dev/null
+++ b/app/src/main/java/me/ash/reader/ui/component/reader/TextComposer.kt
@@ -0,0 +1,182 @@
+/*
+ * Feeder: Android RSS reader app
+ * https://gitlab.com/spacecowboy/Feeder
+ *
+ * Copyright (C) 2022 Jonas Kalderstam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package me.ash.reader.ui.component.reader
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.SpanStyle
+
+class TextComposer(
+ val paragraphEmitter: (AnnotatedParagraphStringBuilder) -> Unit
+) {
+ val spanStack: MutableList = mutableListOf()
+
+ // The identity of this will change - do not reference it in blocks
+ private var builder: AnnotatedParagraphStringBuilder = AnnotatedParagraphStringBuilder()
+
+ fun terminateCurrentText() {
+ if (builder.isEmpty()) {
+ // Nothing to emit, and nothing to reset
+ return
+ }
+
+ paragraphEmitter(builder)
+
+ builder = AnnotatedParagraphStringBuilder()
+
+ for (span in spanStack) {
+ when (span) {
+ is SpanWithStyle -> builder.pushStyle(span.spanStyle)
+ is SpanWithAnnotation -> builder.pushStringAnnotation(
+ tag = span.tag,
+ annotation = span.annotation
+ )
+ is SpanWithComposableStyle -> builder.pushComposableStyle(span.spanStyle)
+ }
+ }
+ }
+
+ val endsWithWhitespace: Boolean
+ get() = builder.endsWithWhitespace
+
+ fun ensureDoubleNewline() =
+ builder.ensureDoubleNewline()
+
+ fun append(text: String) =
+ builder.append(text)
+
+ fun append(char: Char) =
+ builder.append(char)
+
+ fun appendTable(block: () -> R): R {
+ builder.ensureDoubleNewline()
+ terminateCurrentText()
+ return block()
+ }
+
+ fun appendImage(
+ link: String? = null,
+ onLinkClick: (String) -> Unit,
+ block: (
+ onClick: (() -> Unit)?
+ ) -> R
+ ): R {
+ val url = link ?: findClosestLink()
+ builder.ensureDoubleNewline()
+ terminateCurrentText()
+ val onClick: (() -> Unit)? = if (url?.isNotBlank() == true) {
+ {
+ onLinkClick(url)
+ }
+ } else {
+ null
+ }
+ return block(onClick)
+ }
+
+ fun pop(index: Int) =
+ builder.pop(index)
+
+ fun pushStyle(style: SpanStyle): Int =
+ builder.pushStyle(style)
+
+ fun pushStringAnnotation(tag: String, annotation: String): Int =
+ builder.pushStringAnnotation(tag = tag, annotation = annotation)
+
+ fun pushComposableStyle(style: @Composable () -> SpanStyle): Int =
+ builder.pushComposableStyle(style)
+
+ fun popComposableStyle(index: Int) =
+ builder.popComposableStyle(index)
+
+ private fun findClosestLink(): String? {
+ for (span in spanStack.reversed()) {
+ if (span is SpanWithAnnotation && span.tag == "URL") {
+ return span.annotation
+ }
+ }
+ return null
+ }
+}
+
+inline fun TextComposer.withParagraph(
+ crossinline block: TextComposer.() -> R
+): R {
+ ensureDoubleNewline()
+ return block(this)
+}
+
+inline fun TextComposer.withStyle(
+ style: SpanStyle,
+ crossinline block: TextComposer.() -> R
+): R {
+ spanStack.add(SpanWithStyle(style))
+ val index = pushStyle(style)
+ return try {
+ block()
+ } finally {
+ pop(index)
+ spanStack.removeLast()
+ }
+}
+
+inline fun TextComposer.withComposableStyle(
+ noinline style: @Composable () -> SpanStyle,
+ crossinline block: TextComposer.() -> R
+): R {
+ spanStack.add(SpanWithComposableStyle(style))
+ val index = pushComposableStyle(style)
+ return try {
+ block()
+ } finally {
+ popComposableStyle(index)
+ spanStack.removeLast()
+ }
+}
+
+inline fun TextComposer.withAnnotation(
+ tag: String,
+ annotation: String,
+ crossinline block: TextComposer.() -> R
+): R {
+ spanStack.add(SpanWithAnnotation(tag = tag, annotation = annotation))
+ val index = pushStringAnnotation(tag = tag, annotation = annotation)
+ return try {
+ block()
+ } finally {
+ pop(index)
+ spanStack.removeLast()
+ }
+}
+
+sealed class Span
+
+data class SpanWithStyle(
+ val spanStyle: SpanStyle
+) : Span()
+
+data class SpanWithAnnotation(
+ val tag: String,
+ val annotation: String
+) : Span()
+
+data class SpanWithComposableStyle(
+ val spanStyle: @Composable () -> SpanStyle
+) : Span()
diff --git a/app/src/main/java/me/ash/reader/ui/component/reader/VideoTagHunter.kt b/app/src/main/java/me/ash/reader/ui/component/reader/VideoTagHunter.kt
new file mode 100644
index 0000000..084ce6a
--- /dev/null
+++ b/app/src/main/java/me/ash/reader/ui/component/reader/VideoTagHunter.kt
@@ -0,0 +1,54 @@
+/*
+ * Feeder: Android RSS reader app
+ * https://gitlab.com/spacecowboy/Feeder
+ *
+ * Copyright (C) 2022 Jonas Kalderstam
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package me.ash.reader.ui.component.reader
+
+// Example strings
+// www.youtube.com/embed/cjxnVO9RpaQ
+// www.youtube.com/embed/cjxnVO9RpaQ?feature=oembed
+// www.youtube.com/embed/cjxnVO9RpaQ/theoretical_crap
+// www.youtube.com/embed/cjxnVO9RpaQ/crap?feature=oembed
+internal val YoutubeIdPattern = "youtube.com/embed/([^?/]*)".toRegex()
+
+fun getVideo(src: String?): Video? {
+ return src?.let {
+ YoutubeIdPattern.find(src)?.let { match ->
+ val videoId = match.groupValues[1]
+ Video(
+ src = src,
+ imageUrl = "http://img.youtube.com/vi/$videoId/hqdefault.jpg",
+ link = "https://www.youtube.com/watch?v=$videoId"
+ )
+ }
+ }
+}
+
+data class Video(
+ val src: String,
+ val imageUrl: String,
+ // Youtube needs a different link than embed links
+ val link: String
+) {
+ val width: Int
+ get() = 480
+
+ val height: Int
+ get() = 360
+}
diff --git a/app/src/main/java/me/ash/reader/ui/ext/ScrollbarsExt.kt b/app/src/main/java/me/ash/reader/ui/ext/ScrollbarsExt.kt
index ce85a11..1f24737 100644
--- a/app/src/main/java/me/ash/reader/ui/ext/ScrollbarsExt.kt
+++ b/app/src/main/java/me/ash/reader/ui/ext/ScrollbarsExt.kt
@@ -27,6 +27,7 @@ package me.ash.reader.ui.ext
*/
import android.view.ViewConfiguration
+import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
@@ -88,7 +89,7 @@ fun Modifier.drawHorizontalScrollbar(
fun Modifier.drawVerticalScrollbar(
state: LazyListState,
reverseScrolling: Boolean = false
-): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling)
+): Modifier = drawScrollbar(state, Orientation.Vertical, reverseScrolling).animateContentSize()
private fun Modifier.drawScrollbar(
state: ScrollState,
diff --git a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadBar.kt b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadBar.kt
index 8e51c6e..ec642b1 100644
--- a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadBar.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadBar.kt
@@ -7,7 +7,7 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.FiberManualRecord
import androidx.compose.material.icons.outlined.Article
import androidx.compose.material.icons.outlined.FiberManualRecord
-import androidx.compose.material.icons.outlined.TextFormat
+import androidx.compose.material.icons.outlined.Headphones
import androidx.compose.material.icons.rounded.Article
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Star
@@ -108,9 +108,9 @@ fun ReadBar(
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
}
CanBeDisabledIconButton(
- modifier = Modifier.size(40.dp),
+ modifier = Modifier.size(36.dp),
disabled = true,
- imageVector = Icons.Outlined.TextFormat,
+ imageVector = Icons.Outlined.Headphones,
contentDescription = "Add Tag",
tint = MaterialTheme.colorScheme.outline,
) {
diff --git a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt
index d9e0fd8..c2a5e7b 100644
--- a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadPage.kt
@@ -1,20 +1,20 @@
package me.ash.reader.ui.page.home.read
-import android.util.Log
+import android.content.Intent
import androidx.compose.animation.*
-import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.layout.*
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyListState
+import androidx.compose.foundation.text.selection.DisableSelection
+import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.outlined.Headphones
-import androidx.compose.material.icons.outlined.MoreVert
+import androidx.compose.material.icons.outlined.Share
import androidx.compose.material.icons.rounded.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.draw.alpha
+import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
@@ -23,7 +23,7 @@ import androidx.navigation.NavHostController
import me.ash.reader.R
import me.ash.reader.data.entity.ArticleWithFeed
import me.ash.reader.ui.component.FeedbackIconButton
-import me.ash.reader.ui.component.WebView
+import me.ash.reader.ui.component.reader.reader
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.drawVerticalScrollbar
@@ -34,7 +34,8 @@ fun ReadPage(
readViewModel: ReadViewModel = hiltViewModel(),
) {
val viewState = readViewModel.viewState.collectAsStateValue()
- var isScrollDown by remember { mutableStateOf(false) }
+ val isScrollDown = viewState.listState.isScrollDown()
+// val isScrollDown by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
navController.currentBackStackEntryFlow.collect {
@@ -44,24 +45,7 @@ fun ReadPage(
}
}
- if (viewState.scrollState.isScrollInProgress) {
- LaunchedEffect(Unit) {
- Log.i("RLog", "scroll: start")
- }
-
- val preScrollOffset by remember { mutableStateOf(viewState.scrollState.value) }
- val currentOffset = viewState.scrollState.value
- isScrollDown = currentOffset > preScrollOffset
-
- DisposableEffect(Unit) {
- onDispose {
- Log.i("RLog", "scroll: end")
- }
- }
- }
-
LaunchedEffect(viewState.articleWithFeed?.article?.id) {
- isScrollDown = false
viewState.articleWithFeed?.let {
if (it.article.isUnread) {
readViewModel.dispatch(ReadViewAction.MarkUnread(false))
@@ -82,7 +66,8 @@ fun ReadPage(
) {
TopBar(
isShow = viewState.articleWithFeed == null || !isScrollDown,
- isShowActions = viewState.articleWithFeed != null,
+ title = viewState.articleWithFeed?.article?.title,
+ link = viewState.articleWithFeed?.article?.link,
onClose = {
navController.popBackStack()
},
@@ -91,8 +76,8 @@ fun ReadPage(
Content(
content = viewState.content ?: "",
articleWithFeed = viewState.articleWithFeed,
- viewState = viewState,
- scrollState = viewState.scrollState,
+ isLoading = viewState.isLoading,
+ listState = viewState.listState,
)
Box(
modifier = Modifier
@@ -121,12 +106,39 @@ fun ReadPage(
)
}
+@Composable
+fun LazyListState.isScrollDown(): Boolean {
+ var isScrollDown by remember { mutableStateOf(false) }
+ var preItemIndex by remember { mutableStateOf(0) }
+ var preScrollStartOffset by remember { mutableStateOf(0) }
+
+ LaunchedEffect(this) {
+ snapshotFlow { isScrollInProgress }.collect {
+ if (isScrollInProgress) {
+ isScrollDown = when {
+ firstVisibleItemIndex > preItemIndex -> true
+ firstVisibleItemScrollOffset < preItemIndex -> false
+ else -> firstVisibleItemScrollOffset > preScrollStartOffset
+ }
+ } else {
+ preItemIndex = firstVisibleItemIndex
+ preScrollStartOffset = firstVisibleItemScrollOffset
+ }
+ }
+ }
+
+ return isScrollDown
+}
+
@Composable
private fun TopBar(
isShow: Boolean,
- isShowActions: Boolean = false,
+ title: String? = "",
+ link: String? = "",
onClose: () -> Unit = {},
) {
+ val context = LocalContext.current
+
AnimatedVisibility(
visible = isShow,
enter = fadeIn() + expandVertically(),
@@ -148,23 +160,19 @@ private fun TopBar(
}
},
actions = {
- if (isShowActions) {
- FeedbackIconButton(
- modifier = Modifier
- .size(22.dp)
- .alpha(0.5f),
- imageVector = Icons.Outlined.Headphones,
- contentDescription = stringResource(R.string.mark_all_as_read),
- tint = MaterialTheme.colorScheme.onSurface,
- ) {
- }
- FeedbackIconButton(
- modifier = Modifier.alpha(0.5f),
- imageVector = Icons.Outlined.MoreVert,
- contentDescription = stringResource(R.string.search),
- tint = MaterialTheme.colorScheme.onSurface,
- ) {
- }
+ FeedbackIconButton(
+ modifier = Modifier.size(20.dp),
+ imageVector = Icons.Outlined.Share,
+ contentDescription = stringResource(R.string.search),
+ tint = MaterialTheme.colorScheme.onSurface,
+ ) {
+ context.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
+ putExtra(
+ Intent.EXTRA_TEXT,
+ title?.takeIf { it.isNotBlank() }?.let { it + "\n" } + link
+ )
+ type = "text/plain"
+ }, "Share"))
}
}
)
@@ -175,37 +183,37 @@ private fun TopBar(
private fun Content(
content: String,
articleWithFeed: ArticleWithFeed?,
- viewState: ReadViewState,
- scrollState: ScrollState = rememberScrollState(),
+ listState: LazyListState,
+ isLoading: Boolean,
) {
- Column(
- modifier = Modifier
- .statusBarsPadding()
- .navigationBarsPadding()
- .drawVerticalScrollbar(scrollState)
- .verticalScroll(scrollState),
- ) {
- if (articleWithFeed == null) {
- Spacer(modifier = Modifier.height(64.dp))
-// LottieAnimation(
-// modifier = Modifier
-// .alpha(0.7f)
-// .padding(80.dp),
-// url = "https://assets8.lottiefiles.com/packages/lf20_jm7mv1ib.json",
-// )
- } else {
- Column {
+ if (articleWithFeed == null) return
+ val context = LocalContext.current
+
+ SelectionContainer {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxSize()
+ .statusBarsPadding()
+ .navigationBarsPadding()
+ .drawVerticalScrollbar(listState),
+ state = listState,
+ ) {
+ item {
Spacer(modifier = Modifier.height(64.dp))
- Spacer(modifier = Modifier.height(2.dp))
+ Spacer(modifier = Modifier.height(22.dp))
Column(
modifier = Modifier
.padding(horizontal = 12.dp)
) {
- Header(articleWithFeed)
+ DisableSelection {
+ Header(articleWithFeed)
+ }
}
+ }
+ item {
Spacer(modifier = Modifier.height(22.dp))
AnimatedVisibility(
- visible = viewState.isLoading,
+ visible = isLoading,
enter = fadeIn() + expandVertically(),
exit = fadeOut() + shrinkVertically(),
) {
@@ -224,12 +232,15 @@ private fun Content(
}
}
}
- if (!viewState.isLoading) {
- WebView(
- content = content
- )
- Spacer(modifier = Modifier.height(50.dp))
- }
+ }
+ if (!isLoading) {
+ reader(
+ context = context,
+ link = articleWithFeed.article.link,
+ content = content
+ )
+ }
+ item {
Spacer(modifier = Modifier.height(64.dp))
Spacer(modifier = Modifier.height(64.dp))
}
diff --git a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadViewModel.kt b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadViewModel.kt
index a947583..1aeadf2 100644
--- a/app/src/main/java/me/ash/reader/ui/page/home/read/ReadViewModel.kt
+++ b/app/src/main/java/me/ash/reader/ui/page/home/read/ReadViewModel.kt
@@ -1,7 +1,7 @@
package me.ash.reader.ui.page.home.read
import android.util.Log
-import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.lazy.LazyListState
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -146,7 +146,8 @@ data class ReadViewState(
val articleWithFeed: ArticleWithFeed? = null,
val content: String? = null,
val isLoading: Boolean = true,
- val scrollState: ScrollState = ScrollState(0),
+// val scrollState: ScrollState = ScrollState(0),
+ val listState: LazyListState = LazyListState(),
)
sealed class ReadViewAction {
diff --git a/app/src/main/res/drawable/ic_broken_image_black_24dp.xml b/app/src/main/res/drawable/ic_broken_image_black_24dp.xml
new file mode 100644
index 0000000..6872d2e
--- /dev/null
+++ b/app/src/main/res/drawable/ic_broken_image_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/app/src/main/res/drawable/ic_hourglass_empty_black_24dp.xml b/app/src/main/res/drawable/ic_hourglass_empty_black_24dp.xml
new file mode 100644
index 0000000..ca58686
--- /dev/null
+++ b/app/src/main/res/drawable/ic_hourglass_empty_black_24dp.xml
@@ -0,0 +1,9 @@
+
+
+