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 android.util.Log
import net.dankito.readability4j.extended.Readability4JExtended 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 import org.jsoup.nodes.Element
object Readability { object Readability {
@ -9,7 +14,7 @@ object Readability {
fun parseToText(htmlContent: String?, uri: String?): String { fun parseToText(htmlContent: String?, uri: String?): String {
htmlContent ?: return "" htmlContent ?: return ""
return try { return try {
Readability4JExtended(uri ?: "", htmlContent).parse().textContent?.trim() ?: "" Readability4JExtended(uri, htmlContent).parse().textContent?.trim() ?: ""
} catch (e: Exception) { } catch (e: Exception) {
Log.e("RLog", "Readability.parseToText '$uri' is error: ", e) Log.e("RLog", "Readability.parseToText '$uri' is error: ", e)
"" ""
@ -19,10 +24,25 @@ object Readability {
fun parseToElement(htmlContent: String?, uri: String?): Element? { fun parseToElement(htmlContent: String?, uri: String?): Element? {
htmlContent ?: return null htmlContent ?: return null
return try { return try {
Readability4JExtended(uri ?: "", htmlContent).parse().articleContent Readability4JExtended(uri, htmlContent).parse().articleContent
} catch (e: Exception) { } catch (e: Exception) {
Log.e("RLog", "Readability.parseToElement '$uri' is error: ", e) Log.e("RLog", "Readability.parseToElement '$uri' is error: ", e)
null 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), flowArticleListTonalElevation = FlowArticleListTonalElevationPreference.fromPreferences(this),
// Reading page // Reading page
readingRenderer = ReadingRendererPreference.fromPreferences(this),
readingBionicReading = ReadingBionicReadingPreference.fromPreferences(this),
readingTheme = ReadingThemePreference.fromPreferences(this), readingTheme = ReadingThemePreference.fromPreferences(this),
readingDarkTheme = ReadingDarkThemePreference.fromPreferences(this), readingDarkTheme = ReadingDarkThemePreference.fromPreferences(this),
readingPageTonalElevation = ReadingPageTonalElevationPreference.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 { object ReadingImageRoundedCornersPreference {
const val default = 32 const val default = 24
fun put(context: Context, scope: CoroutineScope, value: Int) { fun put(context: Context, scope: CoroutineScope, value: Int) {
scope.launch { 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 Justify -> TextAlign.Justify
} }
fun toTextAlignCSS(): String =
when (this) {
Start -> "left"
End -> "right"
Center -> "center"
Justify -> "justify"
}
companion object { companion object {
val default = Start val default = Start

View File

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

View File

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

View File

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

View File

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

View File

@ -17,7 +17,8 @@ import androidx.compose.ui.unit.dp
fun CanBeDisabledIconButton( fun CanBeDisabledIconButton(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
disabled: Boolean, disabled: Boolean,
imageVector: ImageVector, imageVector: ImageVector? = null,
icon: @Composable () -> Unit = {},
size: Dp = 24.dp, size: Dp = 24.dp,
contentDescription: String?, contentDescription: String?,
tint: Color = LocalContentColor.current, tint: Color = LocalContentColor.current,
@ -34,11 +35,15 @@ fun CanBeDisabledIconButton(
enabled = !disabled, enabled = !disabled,
onClick = onClick, onClick = onClick,
) { ) {
Icon( if (imageVector != null) {
modifier = Modifier.size(size), Icon(
imageVector = imageVector, modifier = Modifier.size(size),
contentDescription = contentDescription, imageVector = imageVector,
tint = if (disabled) MaterialTheme.colorScheme.outline else tint, 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" const val flowArticleListReadIndicator = "flowArticleListReadIndicator"
// Reading page // Reading page
const val readingRenderer = "readingRender"
const val readingBionicReading = "readingBionicReading"
const val readingDarkTheme = "readingDarkTheme" const val readingDarkTheme = "readingDarkTheme"
const val readingPageTonalElevation = "readingPageTonalElevation" const val readingPageTonalElevation = "readingPageTonalElevation"
const val readingTextFontSize = "readingTextFontSize" const val readingTextFontSize = "readingTextFontSize"
@ -207,6 +209,8 @@ data class DataStoreKey<T>(
flowArticleListTonalElevation to DataStoreKey(intPreferencesKey(flowArticleListTonalElevation), Int::class.java), flowArticleListTonalElevation to DataStoreKey(intPreferencesKey(flowArticleListTonalElevation), Int::class.java),
flowArticleListReadIndicator to DataStoreKey(booleanPreferencesKey(flowArticleListReadIndicator), Boolean::class.java), flowArticleListReadIndicator to DataStoreKey(booleanPreferencesKey(flowArticleListReadIndicator), Boolean::class.java),
// Reading page // 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), readingDarkTheme to DataStoreKey(intPreferencesKey(readingDarkTheme), Int::class.java),
readingPageTonalElevation to DataStoreKey(intPreferencesKey(readingPageTonalElevation), Int::class.java), readingPageTonalElevation to DataStoreKey(intPreferencesKey(readingPageTonalElevation), Int::class.java),
readingTextFontSize to DataStoreKey(intPreferencesKey(readingTextFontSize), Int::class.java), readingTextFontSize to DataStoreKey(intPreferencesKey(readingTextFontSize), Int::class.java),

View File

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

View File

@ -24,6 +24,7 @@ object RouteName {
const val FEEDS_PAGE_STYLE = "feeds_page_style" const val FEEDS_PAGE_STYLE = "feeds_page_style"
const val FLOW_PAGE_STYLE = "flow_page_style" const val FLOW_PAGE_STYLE = "flow_page_style"
const val READING_PAGE_STYLE = "reading_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_DARK_THEME = "reading_dark_theme"
const val READING_PAGE_TITLE = "reading_page_title" const val READING_PAGE_TITLE = "reading_page_title"
const val READING_PAGE_TEXT = "reading_page_text" const val READING_PAGE_TEXT = "reading_page_text"

View File

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

View File

@ -1,44 +1,35 @@
package me.ash.reader.ui.page.home.reading package me.ash.reader.ui.page.home.reading
import android.util.Log import androidx.compose.foundation.layout.Column
import androidx.compose.animation.AnimatedContent import androidx.compose.foundation.layout.Spacer
import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.animation.expandVertically import androidx.compose.foundation.layout.height
import androidx.compose.animation.fadeIn import androidx.compose.foundation.layout.navigationBars
import androidx.compose.animation.fadeOut import androidx.compose.foundation.layout.padding
import androidx.compose.animation.scaleIn import androidx.compose.foundation.layout.size
import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.text.selection.DisableSelection import androidx.compose.foundation.text.selection.DisableSelection
import androidx.compose.foundation.text.selection.SelectionContainer 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.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import me.ash.reader.infrastructure.preference.LocalOpenLink import me.ash.reader.infrastructure.preference.LocalOpenLink
import me.ash.reader.infrastructure.preference.LocalOpenLinkSpecificBrowser 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.LocalReadingSubheadUpperCase
import me.ash.reader.infrastructure.preference.ReadingRendererPreference
import me.ash.reader.ui.component.reader.Reader 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.drawVerticalScrollbar
import me.ash.reader.ui.ext.extractDomain
import me.ash.reader.ui.ext.openURL import me.ash.reader.ui.ext.openURL
import me.ash.reader.ui.ext.pagerAnimate import java.util.Date
import java.util.*
import kotlin.math.abs
@Composable @Composable
fun Content( fun Content(
@ -57,6 +48,7 @@ fun Content(
val subheadUpperCase = LocalReadingSubheadUpperCase.current val subheadUpperCase = LocalReadingSubheadUpperCase.current
val openLink = LocalOpenLink.current val openLink = LocalOpenLink.current
val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current val openLinkSpecificBrowser = LocalOpenLinkSpecificBrowser.current
val renderer = LocalReadingRenderer.current
if (isLoading) { if (isLoading) {
Column { Column {
@ -94,17 +86,33 @@ fun Content(
) )
} }
} }
Spacer(modifier = Modifier.height(22.dp))
} }
Reader(
context = context, when (renderer) {
subheadUpperCase = subheadUpperCase.value, ReadingRendererPreference.WebView -> {
link = link ?: "", item {
content = content, RYWebView(
onImageClick = onImageClick, content = content,
onLinkClick = { refererDomain = link.extractDomain(),
context.openURL(it, openLink, openLinkSpecificBrowser) onImageClick = onImageClick,
)
}
} }
)
ReadingRendererPreference.NativeComponent -> {
Reader(
context = context,
subheadUpperCase = subheadUpperCase.value,
link = link ?: "",
content = content,
onImageClick = onImageClick,
onLinkClick = {
context.openURL(it, openLink, openLinkSpecificBrowser)
}
)
}
}
item { item {
Spacer(modifier = Modifier.height(128.dp)) 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.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.isSpecified import androidx.compose.ui.unit.isSpecified
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import me.ash.reader.R import me.ash.reader.R
import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle
import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar 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.LocalReadingTextLineHeight
import me.ash.reader.infrastructure.preference.not
import me.ash.reader.ui.ext.collectAsStateValue import me.ash.reader.ui.ext.collectAsStateValue
import me.ash.reader.ui.ext.showToast import me.ash.reader.ui.ext.showToast
import me.ash.reader.ui.motion.materialSharedAxisY import me.ash.reader.ui.motion.materialSharedAxisY
import me.ash.reader.ui.page.home.HomeViewModel import me.ash.reader.ui.page.home.HomeViewModel
import kotlin.math.abs import kotlin.math.abs
private const val UPWARD = 1 private const val UPWARD = 1
private const val DOWNWARD = -1 private const val DOWNWARD = -1
@ -55,12 +55,12 @@ fun ReadingPage(
homeViewModel: HomeViewModel, homeViewModel: HomeViewModel,
readingViewModel: ReadingViewModel = hiltViewModel(), readingViewModel: ReadingViewModel = hiltViewModel(),
) { ) {
val tonalElevation = LocalReadingPageTonalElevation.current
val context = LocalContext.current val context = LocalContext.current
val isPullToSwitchArticleEnabled = LocalPullToSwitchArticle.current.value val isPullToSwitchArticleEnabled = LocalPullToSwitchArticle.current.value
val readingUiState = readingViewModel.readingUiState.collectAsStateValue() val readingUiState = readingViewModel.readingUiState.collectAsStateValue()
val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue() val readerState = readingViewModel.readerStateStateFlow.collectAsStateValue()
val homeUiState = homeViewModel.homeUiState.collectAsStateValue() val homeUiState = homeViewModel.homeUiState.collectAsStateValue()
val bionicReading = LocalReadingBionicReading.current
var isReaderScrollingDown by remember { mutableStateOf(false) } var isReaderScrollingDown by remember { mutableStateOf(false) }
var showFullScreenImageViewer by remember { mutableStateOf(false) } var showFullScreenImageViewer by remember { mutableStateOf(false) }
@ -217,6 +217,7 @@ fun ReadingPage(
isStarred = readingUiState.isStarred, isStarred = readingUiState.isStarred,
isNextArticleAvailable = isNextArticleAvailable, isNextArticleAvailable = isNextArticleAvailable,
isFullContent = readerState.content is ReaderState.FullContent, isFullContent = readerState.content is ReaderState.FullContent,
isBionicReading = bionicReading.value,
onUnread = { onUnread = {
readingViewModel.updateReadStatus(it) readingViewModel.updateReadStatus(it)
}, },
@ -230,6 +231,12 @@ fun ReadingPage(
if (it) readingViewModel.renderFullContent() if (it) readingViewModel.renderFullContent()
else readingViewModel.renderDescriptionContent() 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 package me.ash.reader.ui.page.settings
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.Box
import androidx.compose.material3.* 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.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -85,7 +94,7 @@ fun SettingItem(
} }
action?.let { action?.let {
if (separatedActions) { if (separatedActions) {
HorizontalDivider( VerticalDivider(
modifier = Modifier modifier = Modifier
.padding(start = 16.dp) .padding(start = 16.dp)
.size(1.dp, 32.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 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.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowBack
import androidx.compose.material3.MaterialTheme 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.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import me.ash.reader.R import me.ash.reader.R
import me.ash.reader.infrastructure.preference.* import me.ash.reader.infrastructure.preference.LocalReadingImageHorizontalPadding
import me.ash.reader.ui.component.base.* 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.page.settings.SettingItem
import me.ash.reader.ui.theme.palette.onLight import me.ash.reader.ui.theme.palette.onLight
@ -28,6 +52,7 @@ fun ReadingImagePage(
val roundedCorners = LocalReadingImageRoundedCorners.current val roundedCorners = LocalReadingImageRoundedCorners.current
val horizontalPadding = LocalReadingImageHorizontalPadding.current val horizontalPadding = LocalReadingImageHorizontalPadding.current
val maximize = LocalReadingImageMaximize.current val maximize = LocalReadingImageMaximize.current
val renderer = LocalReadingRenderer.current
var roundedCornersDialogVisible by remember { mutableStateOf(false) } var roundedCornersDialogVisible by remember { mutableStateOf(false) }
var horizontalPaddingDialogVisible by remember { mutableStateOf(false) } var horizontalPaddingDialogVisible by remember { mutableStateOf(false) }
@ -94,11 +119,13 @@ fun ReadingImagePage(
onClick = { roundedCornersDialogVisible = true }, onClick = { roundedCornersDialogVisible = true },
) {} ) {}
SettingItem( SettingItem(
enabled = renderer == ReadingRendererPreference.NativeComponent,
title = stringResource(R.string.horizontal_padding), title = stringResource(R.string.horizontal_padding),
desc = "${horizontalPadding}dp", desc = "${horizontalPadding}dp",
onClick = { horizontalPaddingDialogVisible = true }, onClick = { horizontalPaddingDialogVisible = true },
) {} ) {}
SettingItem( SettingItem(
enabled = renderer == ReadingRendererPreference.NativeComponent,
title = stringResource(R.string.maximize), title = stringResource(R.string.maximize),
onClick = { onClick = {
(!maximize).put(context, scope) (!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.automirrored.rounded.Segment
import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.Image
import androidx.compose.material.icons.outlined.Movie import androidx.compose.material.icons.outlined.Movie
import androidx.compose.material.icons.rounded.Segment
import androidx.compose.material.icons.rounded.Title import androidx.compose.material.icons.rounded.Title
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -43,12 +42,15 @@ import androidx.navigation.NavHostController
import me.ash.reader.R import me.ash.reader.R
import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle import me.ash.reader.infrastructure.preference.LocalPullToSwitchArticle
import me.ash.reader.infrastructure.preference.LocalReadingAutoHideToolbar 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.LocalReadingDarkTheme
import me.ash.reader.infrastructure.preference.LocalReadingFonts import me.ash.reader.infrastructure.preference.LocalReadingFonts
import me.ash.reader.infrastructure.preference.LocalReadingPageTonalElevation 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.LocalReadingTheme
import me.ash.reader.infrastructure.preference.ReadingFontsPreference import me.ash.reader.infrastructure.preference.ReadingFontsPreference
import me.ash.reader.infrastructure.preference.ReadingPageTonalElevationPreference 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.ReadingThemePreference
import me.ash.reader.infrastructure.preference.not import me.ash.reader.infrastructure.preference.not
import me.ash.reader.ui.component.ReadingThemePrev import me.ash.reader.ui.component.ReadingThemePrev
@ -80,9 +82,11 @@ fun ReadingStylePage(
val fonts = LocalReadingFonts.current val fonts = LocalReadingFonts.current
val autoHideToolbar = LocalReadingAutoHideToolbar.current val autoHideToolbar = LocalReadingAutoHideToolbar.current
val pullToSwitchArticle = LocalPullToSwitchArticle.current val pullToSwitchArticle = LocalPullToSwitchArticle.current
val renderer = LocalReadingRenderer.current
val bionicReading = LocalReadingBionicReading.current
var tonalElevationDialogVisible by remember { mutableStateOf(false) } var tonalElevationDialogVisible by remember { mutableStateOf(false) }
var rendererDialogVisible by remember { mutableStateOf(false) }
var fontsDialogVisible by remember { mutableStateOf(false) } var fontsDialogVisible by remember { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> val launcher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
@ -152,6 +156,32 @@ fun ReadingStylePage(
modifier = Modifier.padding(horizontal = 24.dp), modifier = Modifier.padding(horizontal = 24.dp),
text = stringResource(R.string.general) 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( SettingItem(
title = stringResource(R.string.reading_fonts), title = stringResource(R.string.reading_fonts),
desc = fonts.toDesc(context), desc = fonts.toDesc(context),
@ -173,21 +203,6 @@ fun ReadingStylePage(
darkThemeNot.put(context, scope) 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( SettingItem(
title = stringResource(R.string.auto_hide_toolbars), title = stringResource(R.string.auto_hide_toolbars),
onClick = { onClick = {
@ -291,6 +306,21 @@ fun ReadingStylePage(
tonalElevationDialogVisible = false 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( RadioDialog(
visible = fontsDialogVisible, visible = fontsDialogVisible,
title = stringResource(R.string.reading_fonts), title = stringResource(R.string.reading_fonts),

View File

@ -317,8 +317,8 @@
<string name="images">Images</string> <string name="images">Images</string>
<string name="rounded_corners">Rounded corners</string> <string name="rounded_corners">Rounded corners</string>
<string name="videos">Videos</string> <string name="videos">Videos</string>
<string name="align_start">Align left</string> <string name="align_start">Align start</string>
<string name="align_end">Align right</string> <string name="align_end">Align end</string>
<string name="center_text">Center text</string> <string name="center_text">Center text</string>
<string name="justify">Justify</string> <string name="justify">Justify</string>
<string name="external_fonts">External fonts</string> <string name="external_fonts">External fonts</string>
@ -445,4 +445,16 @@
<string name="import_from_json">Import from JSON</string> <string name="import_from_json">Import from JSON</string>
<string name="export_as_json">Export as 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="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> </resources>