[优化] 重写订阅阅读页面,可能有bug喵

This commit is contained in:
HapeLee committed 2026-04-20 13:13:21 +08:00
1 parent ca70dcaec9
commit c7147e6a3d
6 files changed
+382 -302

No files matched your search

@@ -105,16 +105,13 @@ interface BaseSource : JsExtensions {
header?.let { header?.let {
try { try {
val json = when { val json = when {
it.startsWith("@js:", true) -> evalJS(it.substring(4))?.toString().orEmpty() it.startsWith("@js:", true) -> evalJS(it.substring(4)).toString()
it.startsWith("<js>", true) -> evalJS( it.startsWith("<js>", true) -> evalJS(
it.substring(4, it.lastIndexOf("<")) it.substring(4, it.lastIndexOf("<"))
)?.toString().orEmpty() ).toString()
else -> it else -> it
} }
if (json.isBlank()) {
return@let
}
GSONStrict.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map -> GSONStrict.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
putAll(map) putAll(map)
} ?: GSON.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map -> } ?: GSON.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
@@ -122,12 +119,7 @@ interface BaseSource : JsExtensions {
putAll(map) putAll(map)
} }
} catch (e: Exception) { } catch (e: Exception) {
val isEmptyJsonNoise = e.message?.contains("Empty JSON string", ignoreCase = true) == true || AppLog.put("执行请求头规则出错\n$e", e)
e.toString().contains("Empty JSON string", ignoreCase = true) ||
e.stackTraceToString().contains("Empty JSON string", ignoreCase = true)
if (!isEmptyJsonNoise) {
AppLog.put("执行请求头规则出错\n$e", e)
}
} }
} }
if (!has(AppConst.UA_NAME, true)) { if (!has(AppConst.UA_NAME, true)) {
@@ -59,6 +59,7 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.RssArticle import io.legado.app.data.entities.RssArticle
import io.legado.app.data.entities.RssSource import io.legado.app.data.entities.RssSource
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.cover.buildCoverImageRequest import io.legado.app.ui.widget.components.cover.buildCoverImageRequest
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
@@ -295,21 +296,21 @@ private fun LoadMoreFooter(
state.errorMessage != null -> "加载失败,点击重试" state.errorMessage != null -> "加载失败,点击重试"
else -> "上拉加载更多" else -> "上拉加载更多"
} }
val contentModifier = if (state.errorMessage != null) {
Modifier.clickable(onClick = onRetry)
} else {
Modifier
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 12.dp), .padding(vertical = 12.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( EmptyMessage(
text = text, message = text,
style = LegadoTheme.typography.bodySmall, isLoading = state.isLoadingMore,
color = LegadoTheme.colorScheme.onSurfaceVariant, modifier = contentModifier
modifier = if (state.errorMessage != null) {
Modifier.clickable(onClick = onRetry)
} else {
Modifier
}
) )
} }
} }
@@ -102,6 +102,7 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
loadMoreSuccess(it.first) loadMoreSuccess(it.first)
}.onError { }.onError {
AppLog.put("rss获取内容失败", it) AppLog.put("rss获取内容失败", it)
if (page > 1) page--
_loadState.value = _loadState.value.copy( _loadState.value = _loadState.value.copy(
isLoadingMore = false, isLoadingMore = false,
errorMessage = it.stackTraceStr errorMessage = it.stackTraceStr
@@ -138,7 +139,8 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
_loadState.value = _loadState.value.copy( _loadState.value = _loadState.value.copy(
isLoadingMore = false, isLoadingMore = false,
hasMore = !nextPageUrl.isNullOrBlank() hasMore = !nextPageUrl.isNullOrBlank(),
errorMessage = null
) )
} }
} }
@@ -150,5 +152,5 @@ data class RssArticlesLoadState(
val errorMessage: String? = null val errorMessage: String? = null
) { ) {
val canLoadMore: Boolean val canLoadMore: Boolean
get() = hasMore && !isRefreshing && !isLoadingMore get() = hasMore && !isRefreshing && !isLoadingMore && errorMessage == null
} }
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -48,12 +49,14 @@ import io.legado.app.ui.rss.read.RedirectPolicy
import io.legado.app.ui.rss.read.title import io.legado.app.ui.rss.read.title
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab import io.legado.app.ui.theme.adaptiveHorizontalPaddingTab
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.EmptyMessage import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.button.SmallIconButton import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton import io.legado.app.ui.widget.components.button.SmallOutlinedIconToggleButton
import io.legado.app.ui.widget.components.button.TopBarActionButton import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.button.TopBarNavigationButton import io.legado.app.ui.widget.components.button.TopBarNavigationButton
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.icon.AppIcons
import io.legado.app.ui.widget.components.menuItem.MenuItemIcon import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -119,172 +122,178 @@ fun RssSortScreen(
} }
} }
Column(modifier = Modifier.fillMaxSize()) { AppScaffold(
GlassMediumFlexibleTopAppBar( modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
title = title, topBar = {
scrollBehavior = scrollBehavior, GlassMediumFlexibleTopAppBar(
navigationIcon = { title = title,
TopBarNavigationButton(onClick = onBackClick, imageVector = Icons.AutoMirrored.Filled.ArrowBack) scrollBehavior = scrollBehavior,
}, navigationIcon = {
actions = { TopBarNavigationButton(onClick = onBackClick, imageVector = AppIcons.Back)
TopBarActionButton( },
onClick = { showMainMenu = true }, actions = {
imageVector = Icons.Default.MoreVert, TopBarActionButton(
contentDescription = "Menu" onClick = { showMainMenu = true },
) imageVector = AppIcons.MoreVert,
RoundDropdownMenu( contentDescription = "Menu"
expanded = showMainMenu, )
onDismissRequest = { showMainMenu = false } RoundDropdownMenu(
) { dismiss -> expanded = showMainMenu,
if (hasLogin) { onDismissRequest = { showMainMenu = false }
) { dismiss ->
if (hasLogin) {
RoundDropdownMenuItem(
text = stringResource(R.string.login),
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
onClick = {
dismiss()
onLogin()
}
)
}
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = stringResource(R.string.login), text = stringResource(R.string.refresh_sort),
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) }, leadingIcon = { MenuItemIcon(Icons.Default.Refresh) },
onClick = { onClick = {
dismiss() dismiss()
onLogin() onRefreshSort()
} }
) )
}
RoundDropdownMenuItem(
text = stringResource(R.string.refresh_sort),
leadingIcon = { MenuItemIcon(Icons.Default.Refresh) },
onClick = {
dismiss()
onRefreshSort()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.set_source_variable),
leadingIcon = { MenuItemIcon(Icons.Default.Dataset) },
onClick = {
dismiss()
onSetSourceVariable()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.edit_source),
leadingIcon = { MenuItemIcon(Icons.Default.Edit) },
onClick = {
dismiss()
onEditSource()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.switchLayout),
leadingIcon = { MenuItemIcon(Icons.Default.Style) },
onClick = {
dismiss()
onSwitchLayout()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.read_record),
leadingIcon = { MenuItemIcon(Icons.Default.History) },
onClick = {
dismiss()
onReadRecord()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.clear),
leadingIcon = { MenuItemIcon(Icons.Default.CleaningServices) },
onClick = {
dismiss()
onClearArticles()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.redirect_policy),
onClick = { showRedirectMenu = true }
)
}
RoundDropdownMenu(
expanded = showRedirectMenu,
onDismissRequest = { showRedirectMenu = false },
modifier = Modifier.width(280.dp)
) { dismiss ->
RedirectPolicy.entries.forEach { policy ->
RoundDropdownMenuItem( RoundDropdownMenuItem(
text = policy.title(), text = stringResource(R.string.set_source_variable),
isSelected = policy == redirectPolicy, leadingIcon = { MenuItemIcon(Icons.Default.Dataset) },
onClick = { onClick = {
dismiss() dismiss()
showMainMenu = false onSetSourceVariable()
onRedirectPolicyChanged(policy)
} }
) )
} RoundDropdownMenuItem(
} text = stringResource(R.string.edit_source),
}, leadingIcon = { MenuItemIcon(Icons.Default.Edit) },
bottomContent = { onClick = {
if (sortList.size > 1) { dismiss()
Row( onEditSource()
modifier = Modifier }
.fillMaxWidth()
.adaptiveHorizontalPaddingTab(),
verticalAlignment = Alignment.CenterVertically
) {
AppTabRow(
tabTitles = tabTitles,
selectedTabIndex = selectedTabIndex,
onTabSelected = { index ->
scope.launch { pagerState.animateScrollToPage(index) }
},
modifier = Modifier.weight(1f)
) )
RoundDropdownMenuItem(
text = stringResource(R.string.switchLayout),
leadingIcon = { MenuItemIcon(Icons.Default.Style) },
onClick = {
dismiss()
onSwitchLayout()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.read_record),
leadingIcon = { MenuItemIcon(Icons.Default.History) },
onClick = {
dismiss()
onReadRecord()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.clear),
leadingIcon = { MenuItemIcon(Icons.Default.CleaningServices) },
onClick = {
dismiss()
onClearArticles()
}
)
RoundDropdownMenuItem(
text = stringResource(R.string.redirect_policy),
onClick = { showRedirectMenu = true }
)
}
if (BookshelfConfig.shouldShowExpandButton) { RoundDropdownMenu(
Box { expanded = showRedirectMenu,
SmallOutlinedIconToggleButton( onDismissRequest = { showRedirectMenu = false },
checked = showGroupMenu, modifier = Modifier.width(280.dp)
onCheckedChange = { showGroupMenu = it }, ) { dismiss ->
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted, RedirectPolicy.entries.forEach { policy ->
contentDescription = stringResource(R.string.group_manage) RoundDropdownMenuItem(
) text = policy.title(),
RoundDropdownMenu( isSelected = policy == redirectPolicy,
expanded = showGroupMenu, onClick = {
onDismissRequest = { showGroupMenu = false } dismiss()
) { dismiss -> showMainMenu = false
sortList.forEachIndexed { index, group -> onRedirectPolicyChanged(policy)
RoundDropdownMenuItem( }
text = group.first, )
onClick = { }
scope.launch { pagerState.animateScrollToPage(index) } }
dismiss() },
}, bottomContent = {
trailingIcon = { if (sortList.size > 1) {
if (selectedTabIndex == index) { Row(
Icon( modifier = Modifier
Icons.Default.Check, .fillMaxWidth()
null, .adaptiveHorizontalPaddingTab(),
modifier = Modifier.size(18.dp) verticalAlignment = Alignment.CenterVertically
) ) {
AppTabRow(
tabTitles = tabTitles,
selectedTabIndex = selectedTabIndex,
onTabSelected = { index ->
scope.launch { pagerState.animateScrollToPage(index) }
},
modifier = Modifier.weight(1f)
)
if (BookshelfConfig.shouldShowExpandButton) {
Box {
SmallOutlinedIconToggleButton(
checked = showGroupMenu,
onCheckedChange = { showGroupMenu = it },
imageVector = Icons.AutoMirrored.Filled.FormatListBulleted,
contentDescription = stringResource(R.string.group_manage)
)
RoundDropdownMenu(
expanded = showGroupMenu,
onDismissRequest = { showGroupMenu = false }
) { dismiss ->
sortList.forEachIndexed { index, group ->
RoundDropdownMenuItem(
text = group.first,
onClick = {
scope.launch { pagerState.animateScrollToPage(index) }
dismiss()
},
trailingIcon = {
if (selectedTabIndex == index) {
Icon(
Icons.Default.Check,
null,
modifier = Modifier.size(18.dp)
)
}
} }
} )
) }
} }
} }
} }
} }
} }
} }
} )
) }
) { paddingValues ->
if (sortList.isEmpty()) { if (sortList.isEmpty()) {
EmptyMessage( EmptyMessage(
message = stringResource(R.string.empty), message = stringResource(R.string.empty),
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues)
.padding(24.dp) .padding(24.dp)
) )
} else { } else {
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
contentPadding = PaddingValues(bottom = 0.dp), contentPadding = PaddingValues(bottom = 0.dp),
modifier = Modifier.fillMaxSize() modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) { page -> ) { page ->
val item = sortList.getOrNull(page) ?: return@HorizontalPager val item = sortList.getOrNull(page) ?: return@HorizontalPager
pagerContent(page, item) pagerContent(page, item)
@@ -6,6 +6,7 @@ import android.annotation.SuppressLint
import android.net.Uri import android.net.Uri
import android.net.http.SslError import android.net.http.SslError
import android.os.SystemClock import android.os.SystemClock
import android.view.MotionEvent
import android.view.View import android.view.View
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler import android.webkit.SslErrorHandler
@@ -18,11 +19,18 @@ import android.webkit.WebViewClient
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.Login import androidx.compose.material.icons.automirrored.filled.Login
@@ -46,9 +54,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
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.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
@@ -67,16 +77,18 @@ import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.login.SourceLoginActivity import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppTextField import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.button.SmallIconButton import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.button.TopBarNavigationButton
import io.legado.app.ui.widget.components.card.GlassCard import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.button.SmallTonalIconButton
import io.legado.app.ui.widget.components.button.TopBarActionButton
import io.legado.app.ui.widget.components.list.TopFloatingStickyItem
import io.legado.app.ui.widget.components.menuItem.MenuItemIcon import io.legado.app.ui.widget.components.menuItem.MenuItemIcon
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AppText import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.utils.NetworkUtils import io.legado.app.utils.NetworkUtils
import io.legado.app.utils.isTrue import io.legado.app.utils.isTrue
import io.legado.app.utils.keepScreenOn import io.legado.app.utils.keepScreenOn
@@ -86,6 +98,8 @@ import io.legado.app.utils.setDarkeningAllowed
import io.legado.app.utils.share import io.legado.app.utils.share
import io.legado.app.utils.startActivity import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi import io.legado.app.utils.toastOnUi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.apache.commons.text.StringEscapeUtils import org.apache.commons.text.StringEscapeUtils
import org.jsoup.Jsoup import org.jsoup.Jsoup
@@ -115,6 +129,8 @@ fun RssReadRouteScreen(
var webProgress by remember { mutableIntStateOf(100) } var webProgress by remember { mutableIntStateOf(100) }
var showMenu by remember { mutableStateOf(false) } var showMenu by remember { mutableStateOf(false) }
var showRedirectMenu by remember { mutableStateOf(false) } var showRedirectMenu by remember { mutableStateOf(false) }
var showFloatingCard by remember { mutableStateOf(true) }
var showFloatingCardJob by remember { mutableStateOf<Job?>(null) }
var webView by remember { mutableStateOf<WebView?>(null) } var webView by remember { mutableStateOf<WebView?>(null) }
@@ -174,157 +190,208 @@ fun RssReadRouteScreen(
} }
} }
Column(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
GlassMediumFlexibleTopAppBar( VisibleWebViewCompose(
title = pageTitle.ifBlank { defaultTopBarTitle }, modifier = Modifier.fillMaxSize(),
navigationIcon = { onCreated = { createdWebView ->
TopBarNavigationButton( webView = createdWebView
onClick = onBackClick, configureRssReadWebView(
imageVector = Icons.AutoMirrored.Filled.ArrowBack webView = createdWebView,
) context = context,
}, activity = activity,
scrollBehavior = null, appCompatActivity = appCompatActivity,
actions = { viewModel = viewModel,
TopBarActionButton( initialTitle = title,
onClick = { viewModel.refresh { webView?.reload() } }, redirectPolicyProvider = { redirectPolicy },
imageVector = Icons.Default.Refresh, callbacks = RssReadWebControllerCallbacks(
contentDescription = stringResource(R.string.refresh) onProgressChanged = { webProgress = it },
) onPageTitleResolved = { resolved ->
TopBarActionButton( pageTitle = resolved.ifBlank { defaultTopBarTitle }
onClick = {
viewModel.addFavorite()
favoriteTitle = viewModel.rssArticle?.title.orEmpty()
favoriteGroup = viewModel.rssArticle?.group.orEmpty()
showFavoriteSheet = true
},
imageVector = Icons.Default.Star,
contentDescription = stringResource(R.string.favorite)
)
TopBarActionButton(
onClick = { showMenu = true },
imageVector = Icons.Default.MoreVert,
contentDescription = "Menu"
)
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) { dismiss ->
RoundDropdownMenuItem(
text = stringResource(R.string.share),
leadingIcon = { MenuItemIcon(Icons.Default.Share) },
onClick = {
dismiss()
webView?.url?.let {
context.share(it)
} ?: viewModel.rssArticle?.let {
context.share(it.link)
} ?: context.toastOnUi(R.string.null_url)
} }
) )
RoundDropdownMenuItem( )
text = if (isSpeaking) stringResource(R.string.aloud_stop) else stringResource( createdWebView.setOnTouchListener { _, event ->
R.string.read_aloud when (event.actionMasked) {
), MotionEvent.ACTION_DOWN,
leadingIcon = { MotionEvent.ACTION_MOVE -> {
MenuItemIcon(if (isSpeaking) Icons.Default.Stop else Icons.Default.VolumeUp) showFloatingCardJob?.cancel()
}, showFloatingCard = false
onClick = {
dismiss()
if (isSpeaking) {
viewModel.stopReadAloud()
} else {
webView?.evaluateJavascript("document.documentElement.outerHTML") {
val html = StringEscapeUtils.unescapeJson(it)
.replace("^\"|\"$".toRegex(), "")
viewModel.readAloud(
Jsoup.parse(html)
.text()
)
}
}
} }
)
if (!viewModel.rssSource?.loginUrl.isNullOrBlank()) {
RoundDropdownMenuItem(
text = stringResource(R.string.login),
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
onClick = {
dismiss()
context.startActivity<SourceLoginActivity> {
putExtra("type", "rssSource")
putExtra("key", viewModel.rssSource?.sourceUrl)
}
}
)
}
RoundDropdownMenuItem(
text = stringResource(R.string.redirect_policy),
onClick = { showRedirectMenu = true }
)
RoundDropdownMenuItem(
text = stringResource(R.string.open_in_browser),
leadingIcon = { MenuItemIcon(Icons.Default.OpenInBrowser) },
onClick = {
dismiss()
webView?.url?.let { context.openUrl(it) } ?: context.toastOnUi("url null")
}
)
}
RoundDropdownMenu( MotionEvent.ACTION_UP,
expanded = showRedirectMenu, MotionEvent.ACTION_CANCEL -> {
onDismissRequest = { showRedirectMenu = false } createdWebView.performClick()
) { dismiss -> showFloatingCardJob?.cancel()
RedirectPolicy.entries.forEach { policy -> showFloatingCardJob = scope.launch {
RoundDropdownMenuItem( delay(180L)
text = policy.title(), showFloatingCard = true
onClick = {
dismiss()
showMenu = false
viewModel.rssSource?.let { source ->
viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name)
redirectPolicy = policy
}
context.toastOnUi("重定向策略已更新")
},
trailingIcon = {
if (policy == redirectPolicy) {
Icon(Icons.Default.Check, null)
}
} }
) }
} }
false
} }
} }
) )
if (webProgress in 0..99) {
Box(modifier = Modifier.fillMaxSize()) { LinearProgressIndicator(
VisibleWebViewCompose( progress = { webProgress / 100f },
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxWidth()
onCreated = { createdWebView ->
webView = createdWebView
configureRssReadWebView(
webView = createdWebView,
context = context,
activity = activity,
appCompatActivity = appCompatActivity,
viewModel = viewModel,
initialTitle = title,
redirectPolicyProvider = { redirectPolicy },
callbacks = RssReadWebControllerCallbacks(
onProgressChanged = { webProgress = it },
onPageTitleResolved = { resolved ->
pageTitle = resolved.ifBlank { defaultTopBarTitle }
}
)
)
}
) )
if (webProgress in 0..99) { }
LinearProgressIndicator( TopFloatingStickyItem(
progress = { webProgress / 100f }, item = if (showFloatingCard) {
modifier = Modifier.fillMaxWidth() RssReadFloatingSummary(
titleText = pageTitle.ifBlank { defaultTopBarTitle },
progressText = if (webProgress in 0..99) " · ${webProgress}%" else null
) )
} else {
null
},
modifier = Modifier
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 6.dp),
) { summary ->
NormalCard(
modifier = Modifier.animateContentSize(),
cornerRadius = 32.dp,
containerColor = LegadoTheme.colorScheme.surfaceContainer,
contentColor = LegadoTheme.colorScheme.onCardContainer
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
SmallTonalIconButton(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.back),
onClick = onBackClick
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = summary.titleText,
style = LegadoTheme.typography.labelSmallEmphasized,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 12.dp)
)
summary.progressText?.let { text ->
AppText(text = text, style = LegadoTheme.typography.labelMediumEmphasized)
Spacer(modifier = Modifier.width(6.dp))
}
SmallTonalIconButton(
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.refresh),
onClick = { viewModel.refresh { webView?.reload() } }
)
Spacer(modifier = Modifier.width(8.dp))
SmallTonalIconButton(
imageVector = Icons.Default.Star,
contentDescription = stringResource(R.string.favorite),
onClick = {
viewModel.addFavorite()
favoriteTitle = viewModel.rssArticle?.title.orEmpty()
favoriteGroup = viewModel.rssArticle?.group.orEmpty()
showFavoriteSheet = true
}
)
Spacer(modifier = Modifier.width(8.dp))
Box {
SmallTonalIconButton(
imageVector = Icons.Default.MoreVert,
contentDescription = "Menu",
onClick = { showMenu = true }
)
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) { dismiss ->
RoundDropdownMenuItem(
text = stringResource(R.string.share),
leadingIcon = { MenuItemIcon(Icons.Default.Share) },
onClick = {
dismiss()
webView?.url?.let {
context.share(it)
} ?: viewModel.rssArticle?.let {
context.share(it.link)
} ?: context.toastOnUi(R.string.null_url)
}
)
RoundDropdownMenuItem(
text = if (isSpeaking) stringResource(R.string.aloud_stop) else stringResource(
R.string.read_aloud
),
leadingIcon = {
MenuItemIcon(if (isSpeaking) Icons.Default.Stop else Icons.Default.VolumeUp)
},
onClick = {
dismiss()
if (isSpeaking) {
viewModel.stopReadAloud()
} else {
webView?.evaluateJavascript("document.documentElement.outerHTML") {
val html = StringEscapeUtils.unescapeJson(it)
.replace("^\"|\"$".toRegex(), "")
viewModel.readAloud(
Jsoup.parse(html).text()
)
}
}
}
)
if (!viewModel.rssSource?.loginUrl.isNullOrBlank()) {
RoundDropdownMenuItem(
text = stringResource(R.string.login),
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
onClick = {
dismiss()
context.startActivity<SourceLoginActivity> {
putExtra("type", "rssSource")
putExtra("key", viewModel.rssSource?.sourceUrl)
}
}
)
}
RoundDropdownMenuItem(
text = stringResource(R.string.redirect_policy),
onClick = { showRedirectMenu = true }
)
RoundDropdownMenuItem(
text = stringResource(R.string.open_in_browser),
leadingIcon = { MenuItemIcon(Icons.Default.OpenInBrowser) },
onClick = {
dismiss()
webView?.url?.let { context.openUrl(it) } ?: context.toastOnUi("url null")
}
)
}
RoundDropdownMenu(
expanded = showRedirectMenu,
onDismissRequest = { showRedirectMenu = false }
) { dismiss ->
RedirectPolicy.entries.forEach { policy ->
RoundDropdownMenuItem(
text = policy.title(),
onClick = {
dismiss()
showMenu = false
viewModel.rssSource?.let { source ->
viewModel.updateRssSourceRedirectPolicy(source.sourceUrl, policy.name)
redirectPolicy = policy
}
context.toastOnUi("重定向策略已更新")
},
trailingIcon = {
if (policy == redirectPolicy) {
Icon(Icons.Default.Check, null)
}
}
)
}
}
}
}
} }
} }
} }
@@ -347,6 +414,11 @@ fun RssReadRouteScreen(
) )
} }
private data class RssReadFloatingSummary(
val titleText: String,
val progressText: String?,
)
@Composable @Composable
private fun FavoriteEditSheet( private fun FavoriteEditSheet(
show: Boolean, show: Boolean,
@@ -51,6 +51,10 @@ class VisibleWebView(
super.onWindowVisibilityChanged(VISIBLE) super.onWindowVisibilityChanged(VISIBLE)
} }
override fun performClick(): Boolean {
return super.performClick()
}
override fun startActionMode(callback: ActionMode.Callback?): ActionMode { override fun startActionMode(callback: ActionMode.Callback?): ActionMode {
return super.startActionMode(createWrappedCallback(callback)) return super.startActionMode(createWrappedCallback(callback))
} }