refactor(webview): re-enable webview renderer (#568)

* refactor(reading): reenable webview renderer

* refactor(reading): reenable webview renderer

* refactor(reading): reenable webview renderer

* refactor(reading): reenable webview renderer

* refactor(reading): reenable webview renderer

* refactor(reading): re-enable webview renderer

* refactor(webview): support rtl, video, preference styles
This commit is contained in:
Ash 2024-08-09 18:46:40 +08:00 committed by GitHub
parent 2fc28bd4b7
commit b30ff86503
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 1381 additions and 300 deletions

View File

@ -0,0 +1,86 @@
package me.ash.reader.infrastructure.html
import net.dankito.readability4j.extended.processor.ArticleGrabberExtended
import net.dankito.readability4j.extended.util.RegExUtilExtended
import net.dankito.readability4j.model.ArticleGrabberOptions
import net.dankito.readability4j.model.ReadabilityOptions
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.TextNode
open class RYArticleGrabberExtended(options: ReadabilityOptions, regExExtended: RegExUtilExtended) : ArticleGrabberExtended(options, regExExtended) {
override fun prepareNodes(doc: Document, options: ArticleGrabberOptions): List<Element> {
val elementsToScore = ArrayList<Element>()
var node: Element? = doc
while(node != null) {
val matchString = node.className() + " " + node.id()
// Check to see if this node is a byline, and remove it if it is.
if(checkByline(node, matchString)) {
node = removeAndGetNext(node, "byline")
continue
}
// Remove unlikely candidates
if(options.stripUnlikelyCandidates) {
if(regEx.isUnlikelyCandidate(matchString) &&
regEx.okMaybeItsACandidate(matchString) == false &&
node.tagName() != "body" &&
node.tagName() != "a") {
node = this.removeAndGetNext(node, "Removing unlikely candidate")
continue
}
}
// Remove DIV, SECTION, and HEADER nodes without any content(e.g. text, image, video, or iframe).
if((node.tagName() == "div" || node.tagName() == "section" || node.tagName() == "header" ||
node.tagName() == "h1" || node.tagName() == "h2" || node.tagName() == "h3" ||
node.tagName() == "h4" || node.tagName() == "h5" || node.tagName() == "h6") &&
this.isElementWithoutContent(node)) {
node = this.removeAndGetNext(node, "node without content")
continue
}
if(DEFAULT_TAGS_TO_SCORE.contains(node.tagName())) {
elementsToScore.add(node)
}
// Turn all divs that don't have children block level elements into p's
if(node.tagName() == "div") {
// Sites like http://mobile.slate.com encloses each paragraph with a DIV
// element. DIVs with only a P element inside and no text content can be
// safely converted into plain P elements to avoid confusing the scoring
// algorithm with DIVs with are, in practice, paragraphs.
if(this.hasSinglePInsideElement(node)) {
val newNode = node.child(0)
node.replaceWith(newNode)
node = newNode
elementsToScore.add(node)
}
else if(!this.hasChildBlockElement(node)) {
setNodeTag(node, "p")
elementsToScore.add(node)
}
else {
node.childNodes().forEach { childNode ->
if(childNode is TextNode && childNode.text().trim().length > 0) {
val p = doc.createElement("p")
p.text(childNode.text())
// EXPERIMENTAL
// p.attr("style", "display: inline;")
// p.addClass("readability-styled")
childNode.replaceWith(p)
}
}
}
}
node = if(node != null) this.getNextNode(node) else null
}
return elementsToScore
}
}

View File

@ -2,6 +2,11 @@ package me.ash.reader.infrastructure.html
import android.util.Log
import net.dankito.readability4j.extended.Readability4JExtended
import net.dankito.readability4j.extended.processor.PostprocessorExtended
import net.dankito.readability4j.extended.util.RegExUtilExtended
import net.dankito.readability4j.model.ReadabilityOptions
import net.dankito.readability4j.processor.MetadataParser
import net.dankito.readability4j.processor.Preprocessor
import org.jsoup.nodes.Element
object Readability {
@ -9,7 +14,7 @@ object Readability {
fun parseToText(htmlContent: String?, uri: String?): String {
htmlContent ?: return ""
return try {
Readability4JExtended(uri ?: "", htmlContent).parse().textContent?.trim() ?: ""
Readability4JExtended(uri, htmlContent).parse().textContent?.trim() ?: ""
} catch (e: Exception) {
Log.e("RLog", "Readability.parseToText '$uri' is error: ", e)
""
@ -19,10 +24,25 @@ object Readability {
fun parseToElement(htmlContent: String?, uri: String?): Element? {
htmlContent ?: return null
return try {
Readability4JExtended(uri ?: "", htmlContent).parse().articleContent
Readability4JExtended(uri, htmlContent).parse().articleContent
} catch (e: Exception) {
Log.e("RLog", "Readability.parseToElement '$uri' is error: ", e)
null
}
}
private fun Readability4JExtended(uri: String?, html: String): Readability4JExtended {
val options = ReadabilityOptions()
val regExUtil = RegExUtilExtended()
return Readability4JExtended(
uri = uri ?: "",
html = html,
options = options,
regExUtil = regExUtil,
preprocessor = Preprocessor(regExUtil),
metadataParser = MetadataParser(regExUtil),
articleGrabber = RYArticleGrabberExtended(options, regExUtil),
postprocessor = PostprocessorExtended(),
)
}
}

View File

@ -53,6 +53,8 @@ fun Preferences.toSettings(): Settings {
flowArticleListTonalElevation = FlowArticleListTonalElevationPreference.fromPreferences(this),
// Reading page
readingRenderer = ReadingRendererPreference.fromPreferences(this),
readingBionicReading = ReadingBionicReadingPreference.fromPreferences(this),
readingTheme = ReadingThemePreference.fromPreferences(this),
readingDarkTheme = ReadingDarkThemePreference.fromPreferences(this),
readingPageTonalElevation = ReadingPageTonalElevationPreference.fromPreferences(this),

View File

@ -0,0 +1,44 @@
package me.ash.reader.infrastructure.preference
import android.content.Context
import androidx.compose.runtime.compositionLocalOf
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.ash.reader.ui.ext.DataStoreKey
import me.ash.reader.ui.ext.DataStoreKey.Companion.readingBionicReading
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
val LocalReadingBionicReading =
compositionLocalOf<ReadingBionicReadingPreference> { ReadingBionicReadingPreference.default }
sealed class ReadingBionicReadingPreference(val value: Boolean) : Preference() {
object ON : ReadingBionicReadingPreference(true)
object OFF : ReadingBionicReadingPreference(false)
override fun put(context: Context, scope: CoroutineScope) {
scope.launch {
context.dataStore.put(readingBionicReading, value)
}
}
companion object {
val default = OFF
val values = listOf(ON, OFF)
fun fromPreferences(preferences: Preferences) =
when (preferences[DataStoreKey.keys[readingBionicReading]?.key as Preferences.Key<Boolean>]) {
true -> ON
false -> OFF
else -> default
}
}
}
operator fun ReadingBionicReadingPreference.not(): ReadingBionicReadingPreference =
when (value) {
true -> ReadingBionicReadingPreference.OFF
false -> ReadingBionicReadingPreference.ON
}

View File

@ -15,7 +15,7 @@ val LocalReadingImageRoundedCorners =
object ReadingImageRoundedCornersPreference {
const val default = 32
const val default = 24
fun put(context: Context, scope: CoroutineScope, value: Int) {
scope.launch {

View File

@ -0,0 +1,47 @@
package me.ash.reader.infrastructure.preference
import android.content.Context
import androidx.compose.runtime.Stable
import androidx.compose.runtime.compositionLocalOf
import androidx.datastore.preferences.core.Preferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import me.ash.reader.R
import me.ash.reader.ui.ext.DataStoreKey
import me.ash.reader.ui.ext.DataStoreKey.Companion.readingRenderer
import me.ash.reader.ui.ext.dataStore
import me.ash.reader.ui.ext.put
val LocalReadingRenderer =
compositionLocalOf<ReadingRendererPreference> { ReadingRendererPreference.default }
sealed class ReadingRendererPreference(val value: Int) : Preference() {
object WebView : ReadingRendererPreference(0)
object NativeComponent : ReadingRendererPreference(1)
override fun put(context: Context, scope: CoroutineScope) {
scope.launch {
context.dataStore.put(DataStoreKey.readingRenderer, value)
}
}
@Stable
fun toDesc(context: Context): String =
when (this) {
WebView -> context.getString(R.string.webview)
NativeComponent -> context.getString(R.string.native_component)
}
companion object {
val default = WebView
val values = listOf(WebView, NativeComponent)
fun fromPreferences(preferences: Preferences) =
when (preferences[DataStoreKey.keys[readingRenderer]?.key as Preferences.Key<Int>]) {
0 -> WebView
1 -> NativeComponent
else -> default
}
}
}

View File

@ -46,6 +46,14 @@ sealed class ReadingSubheadAlignPreference(val value: Int) : Preference() {
Justify -> TextAlign.Justify
}
fun toTextAlignCSS(): String =
when (this) {
Start -> "left"
End -> "right"
Center -> "center"
Justify -> "justify"
}
companion object {
val default = Start

View File

@ -47,6 +47,14 @@ sealed class ReadingTextAlignPreference(val value: Int) : Preference() {
Justify -> TextAlign.Justify
}
fun toTextAlignCSS(): String =
when (this) {
Start -> "start"
End -> "end"
Center -> "center"
Justify -> "justify"
}
fun toAlignment(): Alignment.Horizontal =
when (this) {
Start -> Alignment.Start

View File

@ -13,8 +13,8 @@ import me.ash.reader.ui.ext.put
val LocalReadingTextLineHeight = compositionLocalOf { ReadingTextLineHeightPreference.default }
data object ReadingTextLineHeightPreference {
const val default = 1f
private val range = 0.8f..2f
const val default = 1.5F
private val range = 0.8F..2F
fun put(context: Context, scope: CoroutineScope, value: Float) {
scope.launch {

View File

@ -50,6 +50,7 @@ sealed class ReadingThemePreference(val value: Int) : Preference() {
ReadingTextHorizontalPaddingPreference.default)
ReadingTextAlignPreference.default.put(context, scope)
ReadingTextLetterSpacingPreference.put(context, scope, ReadingTextLetterSpacingPreference.default)
ReadingTextLineHeightPreference.put(context, scope, ReadingTextLineHeightPreference.default)
ReadingTextFontSizePreference.put(context, scope, ReadingTextFontSizePreference.default)
ReadingImageRoundedCornersPreference.put(context, scope, ReadingImageRoundedCornersPreference.default)
ReadingImageHorizontalPaddingPreference.put(context, scope,
@ -69,7 +70,8 @@ sealed class ReadingThemePreference(val value: Int) : Preference() {
ReadingTextHorizontalPaddingPreference.default)
ReadingTextAlignPreference.default.put(context, scope)
ReadingTextLetterSpacingPreference.put(context, scope, ReadingTextLetterSpacingPreference.default)
ReadingTextFontSizePreference.put(context, scope, 18)
ReadingTextLineHeightPreference.put(context, scope, ReadingTextLineHeightPreference.default)
ReadingTextFontSizePreference.put(context, scope, 22)
ReadingImageRoundedCornersPreference.put(context, scope, 0)
ReadingImageHorizontalPaddingPreference.put(context, scope, 0)
ReadingImageMaximizePreference.default.put(context, scope)
@ -87,6 +89,7 @@ sealed class ReadingThemePreference(val value: Int) : Preference() {
ReadingTextHorizontalPaddingPreference.default)
ReadingTextAlignPreference.Center.put(context, scope)
ReadingTextLetterSpacingPreference.put(context, scope, ReadingTextLetterSpacingPreference.default)
ReadingTextLineHeightPreference.put(context, scope, ReadingTextLineHeightPreference.default)
ReadingTextFontSizePreference.put(context, scope, 20)
ReadingImageRoundedCornersPreference.put(context, scope, 0)
ReadingImageHorizontalPaddingPreference.put(context, scope,
@ -106,6 +109,7 @@ sealed class ReadingThemePreference(val value: Int) : Preference() {
ReadingTextHorizontalPaddingPreference.default)
ReadingTextAlignPreference.default.put(context, scope)
ReadingTextLetterSpacingPreference.put(context, scope, ReadingTextLetterSpacingPreference.default)
ReadingTextLineHeightPreference.put(context, scope, ReadingTextLineHeightPreference.default)
ReadingTextFontSizePreference.put(context, scope, ReadingTextFontSizePreference.default)
ReadingImageRoundedCornersPreference.put(context, scope, ReadingImageRoundedCornersPreference.default)
ReadingImageHorizontalPaddingPreference.put(context, scope,
@ -117,7 +121,7 @@ sealed class ReadingThemePreference(val value: Int) : Preference() {
companion object {
val default = MaterialYou
val default = Reeder
val values = listOf(MaterialYou, Reeder, Paper, Custom)
fun fromPreferences(preferences: Preferences): ReadingThemePreference =

View File

@ -51,6 +51,8 @@ data class Settings(
val flowArticleListReadIndicator: FlowArticleReadIndicatorPreference = FlowArticleReadIndicatorPreference.default,
// Reading page
val readingRenderer: ReadingRendererPreference = ReadingRendererPreference.default,
val readingBionicReading: ReadingBionicReadingPreference = ReadingBionicReadingPreference.default,
val readingTheme: ReadingThemePreference = ReadingThemePreference.default,
val readingDarkTheme: ReadingDarkThemePreference = ReadingDarkThemePreference.default,
val readingPageTonalElevation: ReadingPageTonalElevationPreference = ReadingPageTonalElevationPreference.default,
@ -140,6 +142,8 @@ fun SettingsProvider(
LocalFlowArticleListReadIndicator provides settings.flowArticleListReadIndicator,
// Reading page
LocalReadingRenderer provides settings.readingRenderer,
LocalReadingBionicReading provides settings.readingBionicReading,
LocalReadingTheme provides settings.readingTheme,
LocalReadingDarkTheme provides settings.readingDarkTheme,
LocalReadingPageTonalElevation provides settings.readingPageTonalElevation,

View File

@ -17,7 +17,8 @@ import androidx.compose.ui.unit.dp
fun CanBeDisabledIconButton(
modifier: Modifier = Modifier,
disabled: Boolean,
imageVector: ImageVector,
imageVector: ImageVector? = null,
icon: @Composable () -> Unit = {},
size: Dp = 24.dp,
contentDescription: String?,
tint: Color = LocalContentColor.current,
@ -34,11 +35,15 @@ fun CanBeDisabledIconButton(
enabled = !disabled,
onClick = onClick,
) {
Icon(
modifier = Modifier.size(size),
imageVector = imageVector,
contentDescription = contentDescription,
tint = if (disabled) MaterialTheme.colorScheme.outline else tint,
)
if (imageVector != null) {
Icon(
modifier = Modifier.size(size),
imageVector = imageVector,
contentDescription = contentDescription,
tint = if (disabled) MaterialTheme.colorScheme.outline else tint,
)
} else {
icon()
}
}
}
}

View File

@ -1,215 +0,0 @@
package me.ash.reader.ui.component.base
import android.net.http.SslError
import android.util.Log
import android.webkit.*
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import me.ash.reader.infrastructure.preference.LocalOpenLink
import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser
import me.ash.reader.ui.ext.openURL
const val INJECTION_TOKEN = "/android_asset_font/"
@Composable
fun WebView(
modifier: Modifier = Modifier,
content: String,
onReceivedError: (error: WebResourceError?) -> Unit = {},
) {
val context = LocalContext.current
val openLink = LocalOpenLink.current
val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current
val color = MaterialTheme.colorScheme.onSurfaceVariant.toArgb()
val backgroundColor = MaterialTheme.colorScheme.surface.toArgb()
val webViewClient by remember {
mutableStateOf(object : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
url: String?,
): WebResourceResponse? {
if (url != null && url.contains(INJECTION_TOKEN)) {
try {
val assetPath = url.substring(
url.indexOf(INJECTION_TOKEN) + INJECTION_TOKEN.length,
url.length
)
return WebResourceResponse(
"text/HTML",
"UTF-8",
context.assets.open(assetPath)
)
} catch (e: Exception) {
Log.e("RLog", "WebView shouldInterceptRequest: $e")
}
}
return super.shouldInterceptRequest(view, url);
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
val jsCode = "javascript:(function(){" +
"var imgs=document.getElementsByTagName(\"img\");" +
"for(var i=0;i<imgs.length;i++){" +
"imgs[i].pos = i;" +
"imgs[i].onclick=function(){" +
// "window.jsCallJavaObj.openImage(this.src,this.pos);" +
"alert('asf');" +
"}}})()"
view!!.loadUrl(jsCode)
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?,
): Boolean {
if (null == request?.url) return false
val url = request.url.toString()
if (url.isNotEmpty()) context.openURL(url, openLink, openLinkSpecificBrowser)
return true
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?,
) {
super.onReceivedError(view, request, error)
onReceivedError(error)
}
override fun onReceivedSslError(
view: WebView?,
handler: SslErrorHandler?,
error: SslError?,
) {
handler?.cancel()
}
})
}
val webView by remember {
mutableStateOf(WebView(context).apply {
this.webViewClient = webViewClient
setBackgroundColor(backgroundColor)
isHorizontalScrollBarEnabled = false
isVerticalScrollBarEnabled = false
})
}
// Column(
// modifier = modifier
// .height(if (viewState.isLoading) 100.dp else 0.dp),
// ) {
// Icon(
// modifier = modifier
// .size(50.dp),
// imageVector = Icons.Rounded.HourglassBottom,
// contentDescription = "Loading",
// tint = MaterialTheme.colorScheme.primary,
// )
// Spacer(modifier = modifier.height(50.dp))
// }
AndroidView(
modifier = modifier,//.padding(horizontal = if (content.contains("class=\"page\"")) 0.dp else 24.dp),
factory = { webView },
update = {
it.apply {
Log.i("RLog", "CustomWebView: ${content}")
settings.javaScriptEnabled = true
loadDataWithBaseURL(
null,
getStyle(color) + content,
"text/HTML",
"UTF-8", null
)
}
},
)
}
@Stable
fun argbToCssColor(argb: Int): String = String.format("#%06X", 0xFFFFFF and argb)
// TODO: Google sans is deprecated
@Stable
fun getStyle(argb: Int): String = """
<html><head><style>
*{
padding: 0;
margin: 0;
color: ${argbToCssColor(argb)};
font-family: url('/android_asset_font/font/google_sans_text_regular.TTF'),
url('/android_asset_font/font/google_sans_text_medium_italic.TTF'),
url('/android_asset_font/font/google_sans_text_medium.TTF'),
url('/android_asset_font/font/google_sans_text_italic.TTF'),
url('/android_asset_font/font/google_sans_text_bold_italic.TTF'),
url('/android_asset_font/font/google_sans_text_bold.TTF');
}
html {
padding: 0 24px;
}
img, video, iframe {
margin: 0 -24px 20px;
width: calc(100% + 48px);
border-top: 1px solid ${argbToCssColor(argb)}08;
border-bottom: 1px solid ${argbToCssColor(argb)}08;
}
p,span,a,ol,ul,blockquote,article,section {
text-align: left;
font-size: 16px;
line-height: 24px;
margin-bottom: 20px;
}
ol,ul {
padding-left: 1.5rem;
}
section ul {
}
blockquote {
margin-left: 0.5rem;
padding-left: 0.7rem;
border-left: 1px solid ${argbToCssColor(argb)}33;
color: ${argbToCssColor(argb)}cc;
}
pre {
max-width: 100%;
background: ${argbToCssColor(argb)}11;
padding: 10px;
border-radius: 5px;
margin-bottom: 20px;
}
code {
white-space: pre-wrap;
}
hr {
height: 1px;
border: none;
background: ${argbToCssColor(argb)}33;
margin-bottom: 20px;
}
h1,h2,h3,h4,h5,h6,figure,br {
font-size: large;
margin-bottom: 20px;
}
.element::-webkit-scrollbar { width: 0 !important }
</style></head></html>
"""

View File

@ -0,0 +1,59 @@
package me.ash.reader.ui.component.webview
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun BionicReadingIcon(
modifier: Modifier = Modifier,
size: Dp = 24.dp,
tint: Color = Color.Black,
filled: Boolean = false,
) {
Box(
modifier = modifier.size(size),
contentAlignment = Alignment.Center,
) {
Row {
Text(
text = "B",
fontFamily = FontFamily.SansSerif,
fontSize = (size.value * 0.65F).sp,
fontWeight = if (filled) FontWeight.W900 else FontWeight.W700,
color = if (filled) tint else tint.copy(alpha = 0.6F),
textDecoration = if (filled) TextDecoration.Underline else TextDecoration.None
)
Text(
text = "R",
fontFamily = FontFamily.SansSerif,
fontSize = (size.value * 0.65F).sp,
fontWeight = FontWeight.W300,
color = if (filled) tint else tint.copy(alpha = 0.6F),
textDecoration = if (filled) TextDecoration.Underline else TextDecoration.None
)
}
}
}
@Preview(backgroundColor = 0xFFFFFF)
@Composable
private fun BionicReadingIconPreview() {
Column {
BionicReadingIcon()
BionicReadingIcon(filled = true)
}
}

View File

@ -0,0 +1,14 @@
package me.ash.reader.ui.component.webview
import android.webkit.JavascriptInterface
interface JavaScriptInterface {
@JavascriptInterface
fun onImgTagClick(imgUrl: String?, alt: String?)
companion object {
const val NAME = "JavaScriptInterface"
}
}

View File

@ -0,0 +1,126 @@
package me.ash.reader.ui.component.webview
import android.util.Log
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.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import me.ash.reader.infrastructure.preference.LocalOpenLink
import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser
import me.ash.reader.infrastructure.preference.LocalReadingBionicReading
import me.ash.reader.infrastructure.preference.LocalReadingImageHorizontalPadding
import me.ash.reader.infrastructure.preference.LocalReadingImageRoundedCorners
import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation
import me.ash.reader.infrastructure.preference.LocalReadingSubheadBold
import me.ash.reader.infrastructure.preference.LocalReadingSubheadUpperCase
import me.ash.reader.infrastructure.preference.LocalReadingTextAlign
import me.ash.reader.infrastructure.preference.LocalReadingTextBold
import me.ash.reader.infrastructure.preference.LocalReadingTextFontSize
import me.ash.reader.infrastructure.preference.LocalReadingTextHorizontalPadding
import me.ash.reader.infrastructure.preference.LocalReadingTextLetterSpacing
import me.ash.reader.infrastructure.preference.LocalReadingTextLineHeight
import me.ash.reader.ui.ext.openURL
import me.ash.reader.ui.ext.surfaceColorAtElevation
import me.ash.reader.ui.theme.palette.alwaysLight
@Composable
fun RYWebView(
modifier: Modifier = Modifier,
content: String,
refererDomain: String? = null,
onImageClick: ((imgUrl: String, altText: String) -> Unit)? = null,
) {
val context = LocalContext.current
val maxWidth = LocalConfiguration.current.screenWidthDp.dp.value
val openLink = LocalOpenLink.current
val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current
val tonalElevation = LocalReadingPageTonalElevation.current
val backgroundColor = MaterialTheme.colorScheme
.surfaceColorAtElevation(tonalElevation.value.dp).toArgb()
val selectionTextColor = Color.Black.toArgb()
val selectionBgColor = (MaterialTheme.colorScheme.tertiaryContainer alwaysLight true).toArgb()
val textColor: Int = MaterialTheme.colorScheme.onSurfaceVariant.toArgb()
val textBold: Boolean = LocalReadingTextBold.current.value
val textAlign: String = LocalReadingTextAlign.current.toTextAlignCSS()
val textMargin: Int = LocalReadingTextHorizontalPadding.current
val boldTextColor: Int = MaterialTheme.colorScheme.onSurface.toArgb()
val linkTextColor: Int = MaterialTheme.colorScheme.primary.toArgb()
val subheadBold: Boolean = LocalReadingSubheadBold.current.value
val subheadUpperCase: Boolean = LocalReadingSubheadUpperCase.current.value
val fontSize: Int = LocalReadingTextFontSize.current
val letterSpacing: Float = LocalReadingTextLetterSpacing.current
val lineHeight: Float = LocalReadingTextLineHeight.current
val imgMargin: Int = LocalReadingImageHorizontalPadding.current
val imgBorderRadius: Int = LocalReadingImageRoundedCorners.current
val codeTextColor: Int = MaterialTheme.colorScheme.tertiary.toArgb()
val codeBgColor: Int = MaterialTheme.colorScheme
.surfaceColorAtElevation((tonalElevation.value + 6).dp).toArgb()
val bionicReading = LocalReadingBionicReading.current
val webView by remember(backgroundColor) {
mutableStateOf(
WebViewLayout.get(
context = context,
webViewClient = WebViewClient(
context = context,
refererDomain = refererDomain,
onOpenLink = { url ->
context.openURL(url, openLink, openLinkSpecificBrowser)
}
),
onImageClick = onImageClick
)
)
}
AndroidView(
modifier = modifier,
factory = { webView },
update = {
it.apply {
Log.i("RLog", "maxWidth: ${maxWidth}")
Log.i("RLog", "readingFont: ${context.filesDir.absolutePath}")
Log.i("RLog", "CustomWebView: ${content}")
settings.defaultFontSize = fontSize
loadDataWithBaseURL(
null,
WebViewHtml.HTML.format(
WebViewStyle.get(
fontSize = fontSize,
lineHeight = lineHeight,
letterSpacing = letterSpacing,
textMargin = textMargin,
textColor = textColor,
textBold = textBold,
textAlign = textAlign,
boldTextColor = boldTextColor,
subheadBold = subheadBold,
subheadUpperCase = subheadUpperCase,
imgMargin = imgMargin,
imgBorderRadius = imgBorderRadius,
linkTextColor = linkTextColor,
codeTextColor = codeTextColor,
codeBgColor = codeBgColor,
tableMargin = textMargin,
selectionTextColor = selectionTextColor,
selectionBgColor = selectionBgColor,
),
url,
content,
WebViewScript.get(bionicReading.value),
),
"text/HTML",
"UTF-8", null
)
}
},
)
}

View File

@ -0,0 +1,96 @@
package me.ash.reader.ui.component.webview
import android.content.Context
import android.net.http.SslError
import android.util.Log
import android.webkit.SslErrorHandler
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
import android.webkit.WebView
import android.webkit.WebViewClient
import me.ash.reader.ui.ext.isUrl
import java.io.DataInputStream
import java.net.HttpURLConnection
import java.net.URI
const val INJECTION_TOKEN = "/android_asset_font/"
class WebViewClient(
private val context: Context,
private val refererDomain: String?,
private val onOpenLink: (url: String) -> Unit,
) : WebViewClient() {
override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?,
): WebResourceResponse? {
val url = request?.url?.toString()
if (url != null && url.contains(INJECTION_TOKEN)) {
try {
val assetPath = url.substring(
url.indexOf(INJECTION_TOKEN) + INJECTION_TOKEN.length,
url.length
)
return WebResourceResponse(
"text/HTML",
"UTF-8",
context.assets.open(assetPath)
)
} catch (e: Exception) {
Log.e("RLog", "WebView shouldInterceptRequest: $e")
}
} else if (url != null && url.isUrl()) {
try {
var connection = URI.create(url).toURL().openConnection() as HttpURLConnection
if (connection.responseCode == 403) {
connection.disconnect()
connection = URI.create(url).toURL().openConnection() as HttpURLConnection
connection.setRequestProperty("Referer", refererDomain)
val inputStream = DataInputStream(connection.inputStream)
return WebResourceResponse(connection.contentType, "UTF-8", inputStream)
}
} catch (e: Exception) {
Log.e("RLog", "shouldInterceptRequest url: $e")
}
}
return super.shouldInterceptRequest(view, request)
}
override fun onPageFinished(view: WebView?, url: String?) {
super.onPageFinished(view, url)
val jsCode = """
javascript:(function() {
var imgs = document.getElementsByTagName("img");
for(var i = 0; i < imgs.length; i++){
imgs[i].pos = i;
imgs[i].onclick = function() {
window.${JavaScriptInterface.NAME}.onImgTagClick(this.src, this.alt);
}
}
})()
"""
view!!.loadUrl(jsCode)
}
override fun shouldOverrideUrlLoading(view: WebView?, request: WebResourceRequest?): Boolean {
if (null == request?.url) return false
val url = request.url.toString()
if (url.isNotEmpty()) onOpenLink(url)
return true
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?,
) {
super.onReceivedError(view, request, error)
Log.e("RLog", "RYWebView onReceivedError: $error")
}
override fun onReceivedSslError(view: WebView?, handler: SslErrorHandler?, error: SslError?) {
handler?.cancel()
}
}

View File

@ -0,0 +1,29 @@
package me.ash.reader.ui.component.webview
object WebViewHtml {
const val HTML: String = """
<!DOCTYPE html>
<html dir="auto">
<head>
<meta name="viewport" content="initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no, width=device-width, viewport-fit=cover" />
<meta content="text/html; charset=utf-8" http-equiv="content-type"/>
<style type="text/css">
%s
</style>
<base href="%s" />
</head>
<body>
<main>
<!-- <button id="submit-btn" onclick="bionicRead()">BIONIC</button> -->
<article>
%s
</article>
</main>
<script>
%s
</script>
</body>
</html>
"""
}

View File

@ -0,0 +1,39 @@
package me.ash.reader.ui.component.webview
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Color
import android.webkit.JavascriptInterface
import android.webkit.WebView
object WebViewLayout {
@SuppressLint("SetJavaScriptEnabled")
fun get(
context: Context,
webViewClient: WebViewClient,
onImageClick: ((imgUrl: String, altText: String) -> Unit)? = null,
) = WebView(context).apply {
this.webViewClient = webViewClient
scrollBarSize = 0
isHorizontalScrollBarEnabled = false
isVerticalScrollBarEnabled = true
setBackgroundColor(Color.TRANSPARENT)
with(this.settings) {
domStorageEnabled = true
javaScriptEnabled = true
addJavascriptInterface(object : JavaScriptInterface {
@JavascriptInterface
override fun onImgTagClick(imgUrl: String?, alt: String?) {
if (onImageClick != null && imgUrl != null) {
onImageClick.invoke(imgUrl, alt ?: "")
}
}
}, JavaScriptInterface.NAME)
setSupportZoom(false)
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
isAlgorithmicDarkeningAllowed = true
}
}
}
}

View File

@ -0,0 +1,95 @@
package me.ash.reader.ui.component.webview
object WebViewScript {
fun get(bionicReading: Boolean) = """
function bionicRead() {
let div = document.body;
// Check if the input is empty
if (!div) {
alert("The element with id 'readability-page-1' does not exist.");
return;
}
// Remove all existing <strong> tags
let strongTags = div.querySelectorAll('strong');
strongTags.forEach(tag => {
let parent = tag.parentNode;
while (tag.firstChild) {
parent.insertBefore(tag.firstChild, tag);
}
parent.removeChild(tag);
});
// Get all text nodes within the div, ignoring <code> elements and their children
let walker = document.createTreeWalker(div, NodeFilter.SHOW_TEXT, {
acceptNode: function(node) {
let parent = node.parentNode;
while (parent) {
if (parent.nodeName === 'CODE') {
return NodeFilter.FILTER_REJECT;
}
parent = parent.parentNode;
}
return NodeFilter.FILTER_ACCEPT;
}
});
let textNodes = [];
while (walker.nextNode()) {
textNodes.push(walker.currentNode);
}
// Regex to match emoji characters
const emojiRegex = /[\u{1F600}-\u{1F6FF}|\u{1F300}-\u{1F5FF}|\u{1F680}-\u{1F6FF}|\u{1F700}-\u{1F77F}|\u{1F780}-\u{1F7FF}|\u{1F800}-\u{1F8FF}|\u{1F900}-\u{1F9FF}|\u{1FA00}-\u{1FA6F}|\u{1FA70}-\u{1FAFF}|\u{2600}-\u{26FF}|\u{2700}-\u{27BF}|\u{1F1E0}-\u{1F1FF}]/u;
// Process each text node
textNodes.forEach(node => {
let text = node.textContent;
// Split text into words and process each word
let words = text.split(/(\s+)/); // Keep spaces in the split
let formattedText = "";
words.forEach(word => {
if (word.trim() && !emojiRegex.test(word)) {
let halfIndex = Math.round(word.length / 2);
let half = word.substr(0, halfIndex);
let remHalf = word.substr(halfIndex);
formattedText += "<strong>" + half + "</strong>" + remHalf;
} else {
formattedText += word; // Preserve spaces and skip emoji
}
});
// Create a temporary div to parse HTML
let tempDiv = document.createElement('div');
tempDiv.innerHTML = formattedText;
// Replace original text node with new HTML content
while (tempDiv.firstChild) {
node.parentNode.insertBefore(tempDiv.firstChild, node);
}
node.parentNode.removeChild(node);
});
}
${if (bionicReading) "bionicRead()" else ""}
var images = document.querySelectorAll("img");
images.forEach(function(img) {
img.onload = function() {
img.classList.add("loaded");
console.log("Image width:", img.width, "px");
if (img.width < 412) {
img.classList.add("thin");
}
};
img.onerror = function() {
console.error("Failed to load image:", img.src);
};
});
"""
}

View File

@ -0,0 +1,337 @@
package me.ash.reader.ui.component.webview
object WebViewStyle {
private fun argbToCssColor(argb: Int): String = String.format("#%06X", 0xFFFFFF and argb)
fun get(
fontSize: Int,
lineHeight: Float,
letterSpacing: Float,
textMargin: Int,
textColor: Int,
textBold: Boolean,
textAlign: String,
boldTextColor: Int,
subheadBold: Boolean,
subheadUpperCase: Boolean,
imgMargin: Int,
imgBorderRadius: Int,
linkTextColor: Int,
codeTextColor: Int,
codeBgColor: Int,
tableMargin: Int,
selectionTextColor: Int,
selectionBgColor: Int,
): String = """
:root {
/* --font-family: Inter; */
--font-size: ${fontSize}px;
--line-height: ${lineHeight};
--letter-spacing: ${letterSpacing}px;
--text-margin: ${textMargin}px;
--text-color: ${argbToCssColor(textColor)};
--text-bold: ${if(textBold) "600" else "normal"};
--text-align: ${textAlign};
--bold-text-color: ${argbToCssColor(boldTextColor)};
--link-text-color: ${argbToCssColor(linkTextColor)};
--selection-text-color: ${argbToCssColor(selectionTextColor)};
--selection-bg-color: ${argbToCssColor(selectionBgColor)};
--subhead-bold: ${if(subheadBold) "600" else "normal"};
--subhead-upper-case: ${if(subheadUpperCase) "uppercase" else "none"};
--img-margin: ${imgMargin}px;
--img-border-radius: ${imgBorderRadius}px;
--content-padding;
--bold-text-color;
--image-caption-margin;
--blockquote-margin: 20px;
--blockquote-padding;
--blockquote-bg-color;
--blockquote-border-width: 3px;
--blockquote-border-color: ${argbToCssColor(textColor)}33;
--table-margin: ${tableMargin}px;
--table-border-width;
--table-border-color;
--table-cell-padding: 0.2em;
--table-alt-row-bg-color;
--code-text-color: ${argbToCssColor(codeTextColor)};
--code-bg-color: ${argbToCssColor(codeBgColor)};
--code-scrollbar-color: ${argbToCssColor(codeTextColor)}22;
--code-border-width;
--code-border-color;
--code-padding;
--code-font-family: Menlo, Monospace, 'Courier New';
--code-font-size: 0.9em;
--pre-color;
}
article {
padding: 0;
margin: 0;
margin-left: var(--text-margin) !important;
margin-right: var(--text-margin) !important;
font-family: var(--font-family) !important;
font-size: var(--font-size) !important;
font-weight: var(--text-bold) !important;
color: var(--text-color) !important;
}
/* Page */
body {
margin: 0;
padding 0;
}
::selection {
background-color: var(--selection-bg-color) !important;
color: var(--selection-text-color) !important;
}
/* Heading */
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: var(--subhead-bold) !important;
text-transform: var(--subhead-upper-case) !important;
line-height: calc(min(1.2, var(--line-height))) !important;
letter-spacing: var(--letter-spacing) !important;
color: var(--bold-text-color) !important;
text-align: var(--text-align) !important;
}
/* Paragraph */
p {
max-width: 100% !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
line-height: var(--line-height) !important;
letter-spacing: var(--letter-spacing) !important;
text-align: var(--text-align) !important;
}
span {
line-height: var(--line-height) !important;
letter-spacing: var(--letter-spacing) !important;
text-align: var(--text-align) !important;
}
/* Strong */
strong,
b {
font-weight: 600 !important;
color: var(--bold-text-color) !important;
}
/* Link */
a,
a > strong {
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
font-weight: 600 !important;
color: var(--link-text-color) !important;
}
div > a {
display: block;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
font-weight: 600 !important;
color: var(--link-text-color);
line-height: var(--line-height);
letter-spacing: var(--letter-spacing) !important;
text-align: var(--text-align) !important;
}
/* Image */
iframe,
video,
embed,
object,
img {
margin-top: 0.5em !important;
margin-left: calc(0px - var(--text-margin) + var(--img-margin)) !important;
margin-right: calc(0px - var(--text-margin) + var(--img-margin)) !important;
max-width: calc(100% + 2 * var(--text-margin) - 2 * var(--img-margin)) !important;
border-radius: var(--img-border-radius) !important;
}
img {
height: auto !important;
}
img::after {
width: 100px !important;
}
img.loaded {
opacity: 1; /* 加载完成后设置透明度为1 */
}
img.thin {
margin-top: 0.5em !important;
margin-bottom: 0.5em !important;
margin-left: unset !important;
margin-right: unset !important;
max-width: 100% !important;
}
p > img {
margin-top: 0.5em !important;
margin-bottom: 0.5em !important;
margin-left: calc(0px - var(--text-margin) + var(--img-margin)) !important;
margin-right: calc(0px - var(--text-margin) + var(--img-margin)) !important;
max-width: calc(100% + 2 * var(--text-margin) - 2 * var(--img-margin)) !important;
height: auto !important;
border-radius: var(--img-border-radius) !important;
}
img + small {
display: inline-block;
line-height: calc(min(1.5, var(--line-height))) !important;
letter-spacing: var(--letter-spacing) !important;
margin-top: var(--image-caption-margin) !important;
text-align: var(--text-align) !important;
}
/* List */
ul,
ol {
padding-left: 0 !important;
line-height: var(--line-height) !important;
letter-spacing: var(--letter-spacing) !important;
text-align: var(--text-align) !important;
}
li {
line-height: var(--line-height) !important;
letter-spacing: var(--letter-spacing) !important;
margin-left: 1.5em !important;
text-align: var(--text-align) !important;
}
/* Quote */
blockquote {
margin-left: 0.5em !important;
padding-left: calc(0.9em) !important;
background-color: var(--blockquote-bg-color) !important;
border-left: var(--blockquote-border-width) solid var(--blockquote-border-color) !important;
line-height: var(--line-height) !important;
letter-spacing: var(--letter-spacing) !important;
text-align: var(--text-align) !important;
}
blockquote blockquote {
margin-right: 0 !important;
}
blockquote img {
max-width 100% !important;
left: 0 !important;
}
/* Table */
table {
display: block;
max-width: var(--content-width) !important;
width: 100% !important;
border-collapse: collapse !important;
margin-left: var(--table-margin) !important;
margin-right: var(--table-margin) !important;
}
table th,
table td {
border: var(--table-border-width) solid var(--table-border-color) !important;
padding: var(--table-cell-padding) !important;
line-height: var(--line-height) !important;
letter-spacing: var(--letter-spacing) !important;
text-align: var(--text-align) !important;
}
table tr {
display: block;
}
table tr table tr td {
display: inline-block;
}
table tr:nth-child(even) {
background-color: var(--table-alt-row-bg-color) !important;
}
/* Code */
pre,
code {
color: var(--code-text-color) !important;
background-color: var(--code-bg-color) !important;
border: 1 solid var(--code-text-color) !important;
border-radius: 8px !important;
padding: 2px 5px !important;
margin: 2px !important;
font-family: var(--code-font-family) !important;
font-size: var(--code-font-size) !important;
}
pre {
overflow: auto !important;
}
code {
display: inline-block !important;
}
li code {
white-space: pre-wrap !important;
word-wrap: break-word !important;
overflow-wrap: break-word !important;
max-width: 100% !important;
}
pre::-webkit-scrollbar {
height: 14px;
}
pre::-webkit-scrollbar-track {
background-color: transparent;
}
pre::-webkit-scrollbar-thumb {
background-color: var(--code-scrollbar-color);
border-radius: 7px;
background-clip: content-box;
border: 5px solid transparent;
border-left-width: 10px;
border-right-width: 10px;
}
/* MISC */
figure {
line-height: calc(min(1.5, var(--line-height))) !important;
letter-spacing: var(--letter-spacing) !important;
text-align: var(--text-align) !important;
margin: 0 !important;
opacity: 0.8 !important;
font-size: 0.8em !important;
}
figure * {
font-size: 1em !important;
}
figure p,
caption,
figcaption {
opacity: 0.8 !important;
font-size: 0.8em !important;
}
hr {
border: 0 !important;
height: 2px !important;
background-color: var(--text-color) !important;
opacity: 0.08 !important;
border-radius: 2px;
}
"""
}

View File

@ -134,6 +134,8 @@ data class DataStoreKey<T>(
const val flowArticleListReadIndicator = "flowArticleListReadIndicator"
// Reading page
const val readingRenderer = "readingRender"
const val readingBionicReading = "readingBionicReading"
const val readingDarkTheme = "readingDarkTheme"
const val readingPageTonalElevation = "readingPageTonalElevation"
const val readingTextFontSize = "readingTextFontSize"
@ -207,6 +209,8 @@ data class DataStoreKey<T>(
flowArticleListTonalElevation to DataStoreKey(intPreferencesKey(flowArticleListTonalElevation), Int::class.java),
flowArticleListReadIndicator to DataStoreKey(booleanPreferencesKey(flowArticleListReadIndicator), Boolean::class.java),
// Reading page
readingRenderer to DataStoreKey(intPreferencesKey(readingRenderer), Int::class.java),
readingBionicReading to DataStoreKey(booleanPreferencesKey(readingBionicReading), Boolean::class.java),
readingDarkTheme to DataStoreKey(intPreferencesKey(readingDarkTheme), Int::class.java),
readingPageTonalElevation to DataStoreKey(intPreferencesKey(readingPageTonalElevation), Int::class.java),
readingTextFontSize to DataStoreKey(intPreferencesKey(readingTextFontSize), Int::class.java),

View File

@ -1,7 +1,6 @@
package me.ash.reader.ui.page.common
import android.util.Log
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
@ -22,9 +21,9 @@ import kotlinx.coroutines.flow.collectLatest
import me.ash.reader.domain.model.general.Filter
import me.ash.reader.infrastructure.preference.LocalDarkTheme
import me.ash.reader.infrastructure.preference.LocalReadingDarkTheme
import me.ash.reader.ui.ext.animatedComposable
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.findActivity
import me.ash.reader.ui.ext.animatedComposable
import me.ash.reader.ui.ext.initialFilter
import me.ash.reader.ui.ext.initialPage
import me.ash.reader.ui.ext.isFirstLaunch
@ -41,6 +40,7 @@ import me.ash.reader.ui.page.settings.color.ColorAndStylePage
import me.ash.reader.ui.page.settings.color.DarkThemePage
import me.ash.reader.ui.page.settings.color.feeds.FeedsPageStylePage
import me.ash.reader.ui.page.settings.color.flow.FlowPageStylePage
import me.ash.reader.ui.page.settings.color.reading.BionicReadingPage
import me.ash.reader.ui.page.settings.color.reading.ReadingDarkThemePage
import me.ash.reader.ui.page.settings.color.reading.ReadingImagePage
import me.ash.reader.ui.page.settings.color.reading.ReadingStylePage
@ -207,6 +207,9 @@ fun HomeEntry(
animatedComposable(route = RouteName.READING_PAGE_STYLE) {
ReadingStylePage(navController)
}
animatedComposable(route = RouteName.READING_BIONIC_READING) {
BionicReadingPage(navController)
}
animatedComposable(route = RouteName.READING_DARK_THEME) {
ReadingDarkThemePage(navController)
}

View File

@ -24,6 +24,7 @@ object RouteName {
const val FEEDS_PAGE_STYLE = "feeds_page_style"
const val FLOW_PAGE_STYLE = "flow_page_style"
const val READING_PAGE_STYLE = "reading_page_style"
const val READING_BIONIC_READING = "reading_bionic_reading"
const val READING_DARK_THEME = "reading_dark_theme"
const val READING_PAGE_TITLE = "reading_page_title"
const val READING_PAGE_TEXT = "reading_page_text"

View File

@ -1,15 +1,20 @@
package me.ash.reader.ui.page.home.reading
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.Article
import androidx.compose.material.icons.automirrored.rounded.Article
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.Headphones
import androidx.compose.material.icons.rounded.Article
import androidx.compose.material.icons.rounded.ExpandMore
import androidx.compose.material.icons.rounded.Star
import androidx.compose.material.icons.rounded.StarOutline
@ -24,8 +29,11 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import me.ash.reader.R
import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation
import me.ash.reader.infrastructure.preference.LocalReadingRenderer
import me.ash.reader.infrastructure.preference.ReadingRendererPreference
import me.ash.reader.ui.component.base.CanBeDisabledIconButton
import me.ash.reader.ui.component.base.RYExtensibleVisibility
import me.ash.reader.ui.component.webview.BionicReadingIcon
@Composable
fun BottomBar(
@ -34,12 +42,16 @@ fun BottomBar(
isStarred: Boolean,
isNextArticleAvailable: Boolean,
isFullContent: Boolean,
isBionicReading: Boolean,
onUnread: (isUnread: Boolean) -> Unit = {},
onStarred: (isStarred: Boolean) -> Unit = {},
onNextArticle: () -> Unit = {},
onFullContent: (isFullContent: Boolean) -> Unit = {},
onBionicReading: () -> Unit = {},
onReadAloud: () -> Unit = {},
) {
val tonalElevation = LocalReadingPageTonalElevation.current
val renderer = LocalReadingRenderer.current
Box(
modifier = Modifier
@ -110,12 +122,32 @@ fun BottomBar(
}
CanBeDisabledIconButton(
modifier = Modifier.size(36.dp),
disabled = true,
imageVector = Icons.Outlined.Headphones,
contentDescription = "Add Tag",
disabled = false,
imageVector = if (renderer == ReadingRendererPreference.WebView) null else Icons.Outlined.Headphones,
contentDescription = if (renderer == ReadingRendererPreference.WebView) {
stringResource(R.string.bionic_reading)
} else {
stringResource(R.string.read_aloud)
},
tint = MaterialTheme.colorScheme.outline,
icon = {
BionicReadingIcon(
filled = isBionicReading,
size = 24.dp,
tint = if (renderer == ReadingRendererPreference.WebView) {
MaterialTheme.colorScheme.onSecondaryContainer
} else {
MaterialTheme.colorScheme.outline
}
)
},
) {
view.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP)
if (renderer == ReadingRendererPreference.WebView) {
onBionicReading()
} else {
onReadAloud()
}
}
CanBeDisabledIconButton(
disabled = false,

View File

@ -1,44 +1,35 @@
package me.ash.reader.ui.page.home.reading
import android.util.Log
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.shrinkVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.windowInsetsBottomHeight
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.KeyboardArrowDown
import androidx.compose.material.icons.outlined.KeyboardArrowUp
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import me.ash.reader.infrastructure.preference.LocalOpenLink
import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser
import me.ash.reader.infrastructure.preference.LocalReadingRenderer
import me.ash.reader.infrastructure.preference.LocalReadingSubheadUpperCase
import me.ash.reader.infrastructure.preference.ReadingRendererPreference
import me.ash.reader.ui.component.reader.Reader
import me.ash.reader.ui.component.webview.RYWebView
import me.ash.reader.ui.ext.drawVerticalScrollbar
import me.ash.reader.ui.ext.extractDomain
import me.ash.reader.ui.ext.openURL
import me.ash.reader.ui.ext.pagerAnimate
import java.util.*
import kotlin.math.abs
import java.util.Date
@Composable
fun Content(
@ -57,6 +48,7 @@ fun Content(
val subheadUpperCase = LocalReadingSubheadUpperCase.current
val openLink = LocalOpenLink.current
val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current
val renderer = LocalReadingRenderer.current
if (isLoading) {
Column {
@ -94,17 +86,33 @@ fun Content(
)
}
}
Spacer(modifier = Modifier.height(22.dp))
}
Reader(
context = context,
subheadUpperCase = subheadUpperCase.value,
link = link ?: "",
content = content,
onImageClick = onImageClick,
onLinkClick = {
context.openURL(it, openLink, openLinkSpecificBrowser)
when (renderer) {
ReadingRendererPreference.WebView -> {
item {
RYWebView(
content = content,
refererDomain = link.extractDomain(),
onImageClick = onImageClick,
)
}
}
)
ReadingRendererPreference.NativeComponent -> {
Reader(
context = context,
subheadUpperCase = subheadUpperCase.value,
link = link ?: "",
content = content,
onImageClick = onImageClick,
onLinkClick = {
context.openURL(it, openLink, openLinkSpecificBrowser)
}
)
}
}
item {
Spacer(modifier = Modifier.height(128.dp))

View File

@ -24,25 +24,25 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems
import me.ash.reader.R
import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle
import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar
import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation
import me.ash.reader.infrastructure.preference.LocalReadingBionicReading
import me.ash.reader.infrastructure.preference.LocalReadingTextLineHeight
import me.ash.reader.infrastructure.preference.not
import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.showToast
import me.ash.reader.ui.motion.materialSharedAxisY
import me.ash.reader.ui.page.home.HomeViewModel
import kotlin.math.abs
private const val UPWARD = 1
private const val DOWNWARD = -1
@ -55,12 +55,12 @@ fun ReadingPage(
homeViewModel: HomeViewModel,
readingViewModel: ReadingViewModel = hiltViewModel(),
) {
val tonalElevation = LocalReadingPageTonalElevation.current
val context = LocalContext.current
val isPullToSwitchArticleEnabled = LocalPullToSwitchArticle.current.value
val readingUiState = readingViewModel.readingUiState.collectAsStateValue()
val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue()
val homeUiState = homeViewModel.homeUiState.collectAsStateValue()
val bionicReading = LocalReadingBionicReading.current
var isReaderScrollingDown by remember { mutableStateOf(false) }
var showFullScreenImageViewer by remember { mutableStateOf(false) }
@ -217,6 +217,7 @@ fun ReadingPage(
isStarred = readingUiState.isStarred,
isNextArticleAvailable = isNextArticleAvailable,
isFullContent = readerState.content is ReaderState.FullContent,
isBionicReading = bionicReading.value,
onUnread = {
readingViewModel.updateReadStatus(it)
},
@ -230,6 +231,12 @@ fun ReadingPage(
if (it) readingViewModel.renderFullContent()
else readingViewModel.renderDescriptionContent()
},
onBionicReading = {
(!bionicReading).put(context, homeViewModel.viewModelScope)
},
onReadAloud = {
context.showToast(context.getString(R.string.coming_soon))
}
)
}
}

View File

@ -9,8 +9,17 @@
package me.ash.reader.ui.page.settings
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@ -85,7 +94,7 @@ fun SettingItem(
}
action?.let {
if (separatedActions) {
HorizontalDivider(
VerticalDivider(
modifier = Modifier
.padding(start = 16.dp)
.size(1.dp, 32.dp),

View File

@ -0,0 +1,140 @@
package me.ash.reader.ui.page.settings.color.reading
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import com.ireward.htmlcompose.HtmlText
import me.ash.reader.R
import me.ash.reader.infrastructure.preference.LocalOpenLink
import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser
import me.ash.reader.infrastructure.preference.LocalReadingBionicReading
import me.ash.reader.infrastructure.preference.not
import me.ash.reader.ui.component.base.Banner
import me.ash.reader.ui.component.base.DisplayText
import me.ash.reader.ui.component.base.FeedbackIconButton
import me.ash.reader.ui.component.base.RYScaffold
import me.ash.reader.ui.component.base.RYSwitch
import me.ash.reader.ui.component.base.Subtitle
import me.ash.reader.ui.component.base.Tips
import me.ash.reader.ui.component.webview.RYWebView
import me.ash.reader.ui.ext.openURL
import me.ash.reader.ui.theme.palette.onLight
@Composable
fun BionicReadingPage(
navController: NavHostController,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val openLink = LocalOpenLink.current
val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current
val bionicReading = LocalReadingBionicReading.current
RYScaffold(
containerColor = MaterialTheme.colorScheme.surface onLight MaterialTheme.colorScheme.inverseOnSurface,
navigationIcon = {
FeedbackIconButton(
imageVector = Icons.AutoMirrored.Rounded.ArrowBack,
contentDescription = stringResource(R.string.back),
tint = MaterialTheme.colorScheme.onSurface
) {
navController.popBackStack()
}
},
content = {
LazyColumn {
item {
DisplayText(text = stringResource(R.string.bionic_reading), desc = "")
}
// Preview
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 24.dp)
.clip(RoundedCornerShape(24.dp))
.background(
MaterialTheme.colorScheme.inverseOnSurface
onLight MaterialTheme.colorScheme.surface.copy(0.7f)
)
.clickable { },
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
RYWebView(
content = stringResource(R.string.bionic_reading_preview),
)
}
Spacer(modifier = Modifier.height(24.dp))
}
item {
Banner(
modifier = Modifier.padding(horizontal = 8.dp),
title = stringResource(R.string.use_bionic_reading),
action = {
RYSwitch(activated = bionicReading.value) {
(!bionicReading).put(context, scope)
}
},
) {
(!bionicReading).put(context, scope)
}
Spacer(modifier = Modifier.height(16.dp))
}
item {
Subtitle(
modifier = Modifier.padding(horizontal = 24.dp),
text = stringResource(R.string.about)
)
Tips(
text = stringResource(R.string.bionic_reading_tips),
)
TextButton(
modifier = Modifier.padding(horizontal = 12.dp),
onClick = {
context.openURL(context.getString(R.string.bionic_reading_link), openLink, openLinkSpecificBrowser)
}
) {
HtmlText(
text = stringResource(R.string.browse_bionic_reading_tips),
style = MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.outline,
),
)
}
}
item {
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
}
}
)
}

View File

@ -1,19 +1,43 @@
package me.ash.reader.ui.page.settings.color.reading
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.*
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.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import me.ash.reader.R
import me.ash.reader.infrastructure.preference.*
import me.ash.reader.ui.component.base.*
import me.ash.reader.infrastructure.preference.LocalReadingImageHorizontalPadding
import me.ash.reader.infrastructure.preference.LocalReadingImageMaximize
import me.ash.reader.infrastructure.preference.LocalReadingImageRoundedCorners
import me.ash.reader.infrastructure.preference.LocalReadingRenderer
import me.ash.reader.infrastructure.preference.LocalReadingTheme
import me.ash.reader.infrastructure.preference.ReadingImageHorizontalPaddingPreference
import me.ash.reader.infrastructure.preference.ReadingImageRoundedCornersPreference
import me.ash.reader.infrastructure.preference.ReadingRendererPreference
import me.ash.reader.infrastructure.preference.ReadingThemePreference
import me.ash.reader.infrastructure.preference.not
import me.ash.reader.ui.component.base.DisplayText
import me.ash.reader.ui.component.base.FeedbackIconButton
import me.ash.reader.ui.component.base.RYScaffold
import me.ash.reader.ui.component.base.RYSwitch
import me.ash.reader.ui.component.base.Subtitle
import me.ash.reader.ui.component.base.TextFieldDialog
import me.ash.reader.ui.page.settings.SettingItem
import me.ash.reader.ui.theme.palette.onLight
@ -28,6 +52,7 @@ fun ReadingImagePage(
val roundedCorners = LocalReadingImageRoundedCorners.current
val horizontalPadding = LocalReadingImageHorizontalPadding.current
val maximize = LocalReadingImageMaximize.current
val renderer = LocalReadingRenderer.current
var roundedCornersDialogVisible by remember { mutableStateOf(false) }
var horizontalPaddingDialogVisible by remember { mutableStateOf(false) }
@ -94,11 +119,13 @@ fun ReadingImagePage(
onClick = { roundedCornersDialogVisible = true },
) {}
SettingItem(
enabled = renderer == ReadingRendererPreference.NativeComponent,
title = stringResource(R.string.horizontal_padding),
desc = "${horizontalPadding}dp",
onClick = { horizontalPaddingDialogVisible = true },
) {}
SettingItem(
enabled = renderer == ReadingRendererPreference.NativeComponent,
title = stringResource(R.string.maximize),
onClick = {
(!maximize).put(context, scope)

View File

@ -23,7 +23,6 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material.icons.automirrored.rounded.Segment
import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.rounded.Segment
import androidx.compose.material.icons.rounded.Title
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
@ -43,12 +42,15 @@ import androidx.navigation.NavHostController
import me.ash.reader.R
import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle
import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar
import me.ash.reader.infrastructure.preference.LocalReadingBionicReading
import me.ash.reader.infrastructure.preference.LocalReadingDarkTheme
import me.ash.reader.infrastructure.preference.LocalReadingFonts
import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation
import me.ash.reader.infrastructure.preference.LocalReadingRenderer
import me.ash.reader.infrastructure.preference.LocalReadingTheme
import me.ash.reader.infrastructure.preference.ReadingFontsPreference
import me.ash.reader.infrastructure.preference.ReadingPageTonalElevationPreference
import me.ash.reader.infrastructure.preference.ReadingRendererPreference
import me.ash.reader.infrastructure.preference.ReadingThemePreference
import me.ash.reader.infrastructure.preference.not
import me.ash.reader.ui.component.ReadingThemePrev
@ -80,9 +82,11 @@ fun ReadingStylePage(
val fonts = LocalReadingFonts.current
val autoHideToolbar = LocalReadingAutoHideToolbar.current
val pullToSwitchArticle = LocalPullToSwitchArticle.current
val renderer = LocalReadingRenderer.current
val bionicReading = LocalReadingBionicReading.current
var tonalElevationDialogVisible by remember { mutableStateOf(false) }
var rendererDialogVisible by remember { mutableStateOf(false) }
var fontsDialogVisible by remember { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
@ -152,6 +156,32 @@ fun ReadingStylePage(
modifier = Modifier.padding(horizontal = 24.dp),
text = stringResource(R.string.general)
)
SettingItem(
title = stringResource(R.string.content_renderer),
desc = renderer.toDesc(context),
onClick = { rendererDialogVisible = true },
) {}
SettingItem(
title = stringResource(R.string.bionic_reading),
separatedActions = renderer == ReadingRendererPreference.WebView,
enabled = renderer == ReadingRendererPreference.WebView,
desc = if (renderer == ReadingRendererPreference.WebView) stringResource(R.string.bionic_reading_domain)
else stringResource(R.string.only_available_on_webview),
onClick = {
navController.navigate(RouteName.READING_BIONIC_READING) {
launchSingleTop = true
}
},
) {
if (renderer == ReadingRendererPreference.WebView) {
RYSwitch(
enable = renderer == ReadingRendererPreference.WebView,
activated = bionicReading.value,
) {
(!bionicReading).put(context, scope)
}
}
}
SettingItem(
title = stringResource(R.string.reading_fonts),
desc = fonts.toDesc(context),
@ -173,21 +203,6 @@ fun ReadingStylePage(
darkThemeNot.put(context, scope)
}
}
SettingItem(
title = stringResource(R.string.bionic_reading),
separatedActions = true,
enabled = false,
onClick = {
// (!articleListDesc).put(context, scope)
},
) {
RYSwitch(
activated = false,
enable = false,
) {
// (!articleListDesc).put(context, scope)
}
}
SettingItem(
title = stringResource(R.string.auto_hide_toolbars),
onClick = {
@ -291,6 +306,21 @@ fun ReadingStylePage(
tonalElevationDialogVisible = false
}
RadioDialog(
visible = rendererDialogVisible,
title = stringResource(R.string.content_renderer),
options = ReadingRendererPreference.values.map {
RadioDialogOption(
text = it.toDesc(context),
selected = it == renderer,
) {
it.put(context, scope)
}
}
) {
rendererDialogVisible = false
}
RadioDialog(
visible = fontsDialogVisible,
title = stringResource(R.string.reading_fonts),

View File

@ -317,8 +317,8 @@
<string name="images">Images</string>
<string name="rounded_corners">Rounded corners</string>
<string name="videos">Videos</string>
<string name="align_start">Align left</string>
<string name="align_end">Align right</string>
<string name="align_start">Align start</string>
<string name="align_end">Align end</string>
<string name="center_text">Center text</string>
<string name="justify">Justify</string>
<string name="external_fonts">External fonts</string>
@ -445,4 +445,16 @@
<string name="import_from_json">Import from JSON</string>
<string name="export_as_json">Export as JSON</string>
<string name="invalid_json_file_warning">This file may not be a valid JSON file. Importing it could potentially corrupt the app and result in the loss of current preferences. Are you sure you want to proceed?</string>
<string name="webview" translatable="false">WebView</string>
<string name="native_component">Native Component</string>
<string name="content_renderer">Content renderer</string>
<string name="read_aloud">Read Aloud</string>
<string name="only_available_on_webview">Only available on the WebView</string>
<string name="bionic_reading_preview" translatable="false"><![CDATA[<p>With Bionic Reading you read texts faster, better and more focused.<p>]]></string>
<string name="use_bionic_reading">Use Bionic Reading</string>
<string name="about">About</string>
<string name="bionic_reading_tips">What is Bionic Reading?</string>
<string name="browse_bionic_reading_tips">Learn more at &lt;i&gt;&lt;u&gt;bionic-reading.com&lt;/u&gt;&lt;/i&gt;.</string>
<string name="bionic_reading_domain" translatable="false">bionic-reading.com</string>
<string name="bionic_reading_link" translatable="false">https://bionic-reading.com</string>
</resources>