[优化] 修复订阅源视频播放问题和新增源编辑

This commit is contained in:
HapeLee
2026-04-29 13:27:46 +08:00
parent 914efbb658
commit a191d80e02
13 changed files with 515 additions and 285 deletions
@@ -301,4 +301,15 @@ abstract class BaseRuleViewModel<T : SelectableItem<ID>, Entity, ID, S : ListUiS
_importState.value = currentState.copy(items = newItems)
}
}
fun updateImportItem(index: Int, data: Entity) {
val currentState = _importState.value as? BaseImportUiState.Success<Entity> ?: return
if (index !in currentState.items.indices) return
val newItems = currentState.items.toMutableList()
newItems[index] = newItems[index].copy(data = data)
_importState.value = currentState.copy(
items = newItems,
version = currentState.version + 1
)
}
}
@@ -191,6 +191,7 @@ fun TxtRuleScreen(
onDismissRequest = { viewModel.cancelImport() },
onToggleItem = { viewModel.toggleImportSelection(it) },
onToggleAll = { viewModel.toggleImportAll(it) },
onUpdateItem = { index, rule -> viewModel.updateImportItem(index, rule) },
onConfirm = { viewModel.saveImportedRules() },
itemTitle = { rule -> rule.name },
itemSubtitle = { rule ->
@@ -182,6 +182,7 @@ fun DictRuleScreen(
onDismissRequest = { viewModel.cancelImport() },
onToggleItem = { viewModel.toggleImportSelection(it) },
onToggleAll = { viewModel.toggleImportAll(it) },
onUpdateItem = { index, rule -> viewModel.updateImportItem(index, rule) },
onConfirm = { viewModel.saveImportedRules() },
itemTitle = { rule -> rule.name },
itemSubtitle = { rule ->
@@ -188,6 +188,7 @@ fun ReplaceRuleScreen(
onDismissRequest = { viewModel.cancelImport() },
onToggleItem = { viewModel.toggleImportSelection(it) },
onToggleAll = { viewModel.toggleImportAll(it) },
onUpdateItem = { index, rule -> viewModel.updateImportItem(index, rule) },
onConfirm = { viewModel.saveImportedRules() },
topBarActions = {},
itemTitle = { rule -> rule.name },
@@ -59,6 +59,7 @@ import io.legado.app.data.appDb
import io.legado.app.data.entities.RssArticle
import io.legado.app.data.entities.RssSource
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.EmptyMessage
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.cover.buildCoverImageRequest
@@ -92,6 +93,7 @@ fun RssArticlesPage(
rssSource: RssSource?,
viewModel: RssArticlesViewModel,
onRead: (RssArticle) -> Unit,
paddingValues: PaddingValues = PaddingValues(0.dp),
modifier: Modifier = Modifier
) {
val context = LocalContext.current
@@ -123,6 +125,10 @@ fun RssArticlesPage(
}
val refreshState = rememberPullToRefreshState()
val contentPadding = adaptiveContentPadding(
top = paddingValues.calculateTopPadding(),
bottom = 120.dp
)
PullToRefreshBox(
isRefreshing = loadState.isRefreshing,
@@ -141,7 +147,7 @@ fun RssArticlesPage(
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = 72.dp),
contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
@@ -174,7 +180,7 @@ fun RssArticlesPage(
columns = GridCells.Fixed(2),
state = gridState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = 72.dp),
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -208,7 +214,7 @@ fun RssArticlesPage(
columns = StaggeredGridCells.Fixed(2),
state = staggeredState,
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(start = 8.dp, end = 8.dp, bottom = 72.dp),
contentPadding = contentPadding,
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalItemSpacing = 8.dp
) {
@@ -156,7 +156,7 @@ fun RssSortRouteScreen(
}
context.toastOnUi("重定向策略已更新")
},
pagerContent = { _, sort ->
pagerContent = { _, sort, paddingValues ->
val pageViewModel: RssArticlesViewModel = koinViewModel(
key = "rss_${viewModel.url}_${sort.first}_${sort.second}"
)
@@ -167,6 +167,7 @@ fun RssSortRouteScreen(
rssUrl = viewModel.url,
rssSource = viewModel.rssSource,
viewModel = pageViewModel,
paddingValues = paddingValues,
onRead = { article ->
viewModel.read(article)
onOpenRead(article.title, article.origin, article.link, null)
@@ -89,7 +89,7 @@ fun RssSortScreen(
onOpenReadRecord: (RssReadRecord) -> Unit,
onClearArticles: () -> Unit,
onRedirectPolicyChanged: (RedirectPolicy) -> Unit,
pagerContent: @Composable (index: Int, item: Pair<String, String>) -> Unit
pagerContent: @Composable (index: Int, item: Pair<String, String>, paddingValues: PaddingValues) -> Unit
) {
val scope = rememberCoroutineScope()
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
@@ -291,12 +291,10 @@ fun RssSortScreen(
HorizontalPager(
state = pagerState,
contentPadding = PaddingValues(bottom = 0.dp),
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
modifier = Modifier.fillMaxSize()
) { page ->
val item = sortList.getOrNull(page) ?: return@HorizontalPager
pagerContent(page, item)
pagerContent(page, item, paddingValues)
}
}
}
@@ -370,4 +368,3 @@ private fun RssReadRecordSheet(
}
}
}
@@ -7,6 +7,7 @@ import android.net.Uri
import android.net.http.SslError
import android.os.SystemClock
import android.view.View
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.SslErrorHandler
import android.webkit.URLUtil
@@ -19,10 +20,13 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.clickable
import androidx.compose.material.icons.Icons
@@ -43,17 +47,22 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.zIndex
import androidx.core.net.toUri
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
@@ -61,6 +70,7 @@ import com.script.rhino.runScriptWithContext
import io.legado.app.R
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.help.config.AppConfig
import io.legado.app.help.http.CookieManager
import io.legado.app.lib.dialogs.SelectItem
import io.legado.app.lib.dialogs.selector
@@ -69,6 +79,7 @@ import io.legado.app.ui.association.OnLineImportActivity
import io.legado.app.ui.config.otherConfig.OtherConfig
import io.legado.app.ui.login.SourceLoginActivity
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
import io.legado.app.ui.widget.components.button.MediumIconButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.button.SmallIconButton
@@ -87,6 +98,7 @@ import io.legado.app.utils.setDarkeningAllowed
import io.legado.app.utils.share
import io.legado.app.utils.startActivity
import io.legado.app.utils.toastOnUi
import io.legado.app.utils.toggleSystemBar
import org.apache.commons.text.StringEscapeUtils
import org.jsoup.Jsoup
import java.net.URLDecoder
@@ -115,6 +127,10 @@ fun RssReadRouteScreen(
var showMenu by remember { mutableStateOf(false) }
var showRedirectMenu by remember { mutableStateOf(false) }
var webView by remember { mutableStateOf<WebView?>(null) }
var customView by remember { mutableStateOf<View?>(null) }
var customViewCallback by remember {
mutableStateOf<WebChromeClient.CustomViewCallback?>(null)
}
var redirectPolicy by remember { mutableStateOf(RedirectPolicy.ALLOW_ALL) }
@@ -128,6 +144,15 @@ fun RssReadRouteScreen(
val isSpeaking by viewModel.isSpeakingState.collectAsStateWithLifecycle()
val fallbackUserAgent = OtherConfig.userAgent
fun hideCustomView() {
val currentCustomView = customView ?: return
(currentCustomView.parent as? ViewGroup)?.removeView(currentCustomView)
customView = null
customViewCallback = null
activity?.keepScreenOn(false)
activity?.toggleSystemBar(AppConfig.showStatusBar)
}
LaunchedEffect(origin, link, openUrl, title) {
viewModel.initData(
ReadRssArgs(
@@ -167,169 +192,207 @@ fun RssReadRouteScreen(
BackHandler {
when {
customView != null -> customViewCallback?.onCustomViewHidden() ?: hideCustomView()
webView?.canGoBack() == true && (webView?.copyBackForwardList()?.size ?: 0) > 1 -> webView?.goBack()
else -> onBackClick()
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = pageTitle.ifBlank { defaultTopBarTitle },
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
},
navigationIcon = {
TopBarNavigationButton(onClick = onBackClick)
},
actions = {
TopBarActionButton(
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.refresh),
onClick = { viewModel.refresh { webView?.reload() } }
)
TopBarActionButton(
imageVector = Icons.Default.Star,
contentDescription = stringResource(R.string.favorite),
onClick = {
viewModel.addFavorite()
favoriteTitle = viewModel.rssArticle?.title.orEmpty()
favoriteGroup = viewModel.rssArticle?.group.orEmpty()
showFavoriteSheet = true
}
)
Box {
TopBarActionButton(
imageVector = Icons.Default.MoreVert,
contentDescription = "Menu",
onClick = { showMenu = true }
DisposableEffect(Unit) {
onDispose {
customViewCallback?.onCustomViewHidden()
hideCustomView()
}
}
Box(modifier = Modifier.fillMaxSize()) {
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = pageTitle.ifBlank { defaultTopBarTitle },
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
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)
}
},
navigationIcon = {
TopBarNavigationButton(onClick = onBackClick)
},
actions = {
TopBarActionButton(
imageVector = Icons.Default.Refresh,
contentDescription = stringResource(R.string.refresh),
onClick = { viewModel.refresh { webView?.reload() } }
)
TopBarActionButton(
imageVector = Icons.Default.Star,
contentDescription = stringResource(R.string.favorite),
onClick = {
viewModel.addFavorite()
favoriteTitle = viewModel.rssArticle?.title.orEmpty()
favoriteGroup = viewModel.rssArticle?.group.orEmpty()
showFavoriteSheet = true
}
)
Box {
TopBarActionButton(
imageVector = Icons.Default.MoreVert,
contentDescription = "Menu",
onClick = { showMenu = true }
)
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()) {
RoundDropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) { dismiss ->
RoundDropdownMenuItem(
text = stringResource(R.string.login),
leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
text = stringResource(R.string.share),
leadingIcon = { MenuItemIcon(Icons.Default.Share) },
onClick = {
dismiss()
context.startActivity<SourceLoginActivity> {
putExtra("type", "rssSource")
putExtra("key", viewModel.rssSource?.sourceUrl)
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")
}
)
}
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)
}
}
)
}
)
}
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)
}
}
)
}
}
}
}
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
VisibleWebViewCompose(
modifier = Modifier.fillMaxSize(),
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 }
}
)
}
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
VisibleWebViewCompose(
modifier = Modifier.fillMaxSize(),
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 }
},
onShowCustomView = { view, callback ->
if (view == null) {
callback?.onCustomViewHidden()
} else if (customView != null) {
callback?.onCustomViewHidden()
} else {
customView = view
customViewCallback = callback
activity?.keepScreenOn(true)
activity?.toggleSystemBar(false)
}
},
onHideCustomView = { hideCustomView() }
)
)
}
)
if (webProgress in 0..99) {
LinearProgressIndicator(
progress = { webProgress / 100f },
modifier = Modifier.fillMaxWidth()
)
}
)
if (webProgress in 0..99) {
LinearProgressIndicator(
progress = { webProgress / 100f },
modifier = Modifier.fillMaxWidth()
}
}
customView?.let { view ->
key(view) {
AndroidView(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
.zIndex(1f),
factory = {
(view.parent as? ViewGroup)?.removeView(view)
view
}
)
}
}
@@ -377,7 +440,7 @@ private fun FavoriteEditSheet(
}
) {
Column(modifier = Modifier.padding(12.dp)) {
Column {
AppTextField(
value = titleValue,
onValueChange = onTitleChange,
@@ -396,11 +459,14 @@ private fun FavoriteEditSheet(
)
}
RoundDropdownMenuItem(
text = stringResource(R.string.action_save),
leadingIcon = { MenuItemIcon(Icons.Default.Check) },
onClick = onSave,
modifier = Modifier.padding(top = 8.dp, bottom = 8.dp)
Spacer(modifier = Modifier.height(16.dp))
ConfirmDismissButtonsRow(
modifier = Modifier.fillMaxWidth(),
onDismiss = onDismissRequest,
onConfirm = onSave,
dismissText = stringResource(R.string.cancel),
confirmText = stringResource(R.string.action_save)
)
}
}
@@ -5,6 +5,7 @@ package io.legado.app.ui.rss.read
import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.net.http.SslError
import android.os.SystemClock
@@ -19,10 +20,12 @@ import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.graphics.createBitmap
import androidx.core.net.toUri
import androidx.lifecycle.lifecycleScope
import com.script.rhino.runScriptWithContext
import io.legado.app.R
import io.legado.app.constant.AppConst
import io.legado.app.constant.AppLog
import io.legado.app.help.config.AppConfig
import io.legado.app.lib.dialogs.SelectItem
@@ -39,7 +42,9 @@ import java.net.URLDecoder
internal data class RssReadWebControllerCallbacks(
val onProgressChanged: (Int) -> Unit,
val onPageTitleResolved: (String) -> Unit
val onPageTitleResolved: (String) -> Unit,
val onShowCustomView: (View?, WebChromeClient.CustomViewCallback?) -> Unit,
val onHideCustomView: () -> Unit
)
@SuppressLint("SetJavaScriptEnabled", "JavascriptInterface")
@@ -54,9 +59,21 @@ internal fun configureRssReadWebView(
callbacks: RssReadWebControllerCallbacks
) {
webView.webChromeClient = object : WebChromeClient() {
override fun getDefaultVideoPoster(): Bitmap {
return super.getDefaultVideoPoster() ?: createBitmap(100, 100)
}
override fun onProgressChanged(view: WebView?, newProgress: Int) {
callbacks.onProgressChanged(newProgress)
}
override fun onShowCustomView(view: View?, callback: CustomViewCallback?) {
callbacks.onShowCustomView(view, callback)
}
override fun onHideCustomView() {
callbacks.onHideCustomView()
}
}
webView.webViewClient = object : WebViewClient() {
@@ -218,13 +235,17 @@ internal fun configureRssReadWebView(
webView.settings.apply {
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
domStorageEnabled = true
mediaPlaybackRequiresUserGesture = false
allowContentAccess = true
builtInZoomControls = true
displayZoomControls = false
textZoom = 100
setDarkeningAllowed(AppConfig.isNightTheme)
if (viewModel.rssSource?.enableJs == true) {
javaScriptEnabled = true
userAgentString = viewModel.headerMap[AppConst.UA_NAME] ?: AppConfig.userAgent
viewModel.rssSource?.let { source ->
javaScriptEnabled = source.enableJs
}
cacheMode = WebSettings.LOAD_DEFAULT
}
webView.addJavascriptInterface(object {
@@ -6,8 +6,10 @@ import android.util.AttributeSet
import android.view.ActionMode
import android.view.Menu
import android.view.MenuItem
import android.view.ViewGroup
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -144,19 +146,31 @@ fun VisibleWebViewCompose(
AndroidView(
modifier = modifier,
factory = { context ->
VisibleWebView(context).also {
webViewHolder.webView = it
onCreated(it)
FrameLayout(context).apply {
clipChildren = false
clipToPadding = false
val webView = VisibleWebView(context).apply {
layoutParams = FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
addView(webView)
webViewHolder.webView = webView
onCreated(webView)
}
},
update = {
webViewHolder.webView = it
update = { container ->
webViewHolder.webView = container.getChildAt(0) as? VisibleWebView
}
)
DisposableEffect(Unit) {
onDispose {
onDestroyed?.invoke()
webViewHolder.webView?.destroy()
webViewHolder.webView?.let { webView ->
(webView.parent as? ViewGroup)?.removeView(webView)
webView.destroy()
}
webViewHolder.webView = null
}
}
@@ -40,6 +40,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.base.BaseRuleEvent
import io.legado.app.data.entities.RssSource
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.ActionItem
import io.legado.app.ui.widget.components.DraggableSelectionHandler
import io.legado.app.ui.widget.components.GroupManageBottomSheet
@@ -55,7 +56,6 @@ import io.legado.app.ui.widget.components.lazylist.FastScrollLazyColumn
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.rules.RuleListScaffold
import io.legado.app.ui.widget.components.text.AppText
import org.koin.androidx.compose.koinViewModel
import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -200,6 +200,7 @@ fun RssSourceScreen(
onDismissRequest = { viewModel.cancelImport() },
onToggleItem = { viewModel.toggleImportSelection(it) },
onToggleAll = { viewModel.toggleImportAll(it) },
onUpdateItem = { index, source -> viewModel.updateImportItem(index, source) },
onConfirm = { viewModel.saveImportedRules() },
itemTitle = { rule -> rule.sourceName },
itemSubtitle = { rule ->
@@ -344,9 +345,9 @@ fun RssSourceScreen(
FastScrollLazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding() + 8.dp,
bottom = paddingValues.calculateBottomPadding() + 80.dp
contentPadding = adaptiveContentPadding(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + 120.dp
),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
@@ -2,6 +2,7 @@ package io.legado.app.ui.widget.components.importComponents
import android.annotation.SuppressLint
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
@@ -17,43 +18,38 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.SelectAll
import androidx.compose.material3.AssistChip
import androidx.compose.material3.Button
import androidx.compose.material3.CenterAlignedTopAppBar
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedToggleButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.google.gson.JsonElement
import com.google.gson.JsonNull
import com.google.gson.JsonObject
import com.google.gson.JsonParser
import com.google.gson.JsonPrimitive
import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
import io.legado.app.ui.widget.components.button.SmallIconButton
import io.legado.app.ui.widget.components.card.GlassCard
import io.legado.app.ui.widget.components.card.SelectionItemCard
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.text.AnimatedText
import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
import io.legado.app.ui.widget.components.text.AppText
import kotlinx.coroutines.launch
import io.legado.app.utils.GSON
@Composable
fun SourceInputDialog(
@@ -117,6 +113,7 @@ fun <T> BatchImportDialog(
onToggleItem: (index: Int) -> Unit,
onToggleAll: (isSelected: Boolean) -> Unit,
onItemInfoClick: (index: Int) -> Unit = {},
onUpdateItem: (index: Int, data: T) -> Unit = { _, _ -> },
topBarActions: @Composable RowScope.() -> Unit = {},
itemTitle: (data: T) -> String,
itemSubtitle: (data: T) -> String? = { null }
@@ -130,81 +127,187 @@ fun <T> BatchImportDialog(
if (!show && cachedState == null) return
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val currentState = cachedState!!
var editingIndex by remember(currentState.source) { mutableStateOf<Int?>(null) }
val editingItem = editingIndex?.let { currentState.items.getOrNull(it) }
val isEditing = editingItem != null
val selectedCount = currentState.items.count { it.isSelected }
val totalCount = currentState.items.size
val allSelected = selectedCount == totalCount
val sheetTitle = when {
isEditing -> itemTitle(editingItem.data)
selectedCount > 0 -> {
stringResource(
R.string.select_count,
selectedCount,
totalCount
)
}
else -> title
}
AppModalBottomSheet(
show = show,
onDismissRequest = onDismissRequest
) {
AppScaffold(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f),
topBar = {
CenterAlignedTopAppBar(
title = {
AnimatedText(
if (selectedCount > 0)
stringResource(
R.string.select_count,
selectedCount,
totalCount
)
else
title
)
},
actions = topBarActions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = Color.Transparent
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
bottomBar = {
ImportBottomBar(
selectedCount = selectedCount,
totalCount = totalCount,
onToggleSelectAll = onToggleAll,
onConfirm = {
val selectedData =
currentState.items.filter { it.isSelected }.map { it.data }
onConfirm(selectedData)
},
onCancel = onDismissRequest
onDismissRequest = onDismissRequest,
modifier = Modifier.heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.8f),
title = sheetTitle,
startAction = if (isEditing) {
{
SmallIconButton(
onClick = { editingIndex = null },
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "返回"
)
}
) { padding ->
LazyColumn(
modifier = Modifier.padding(padding),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(
currentState.items,
key = { _, item -> item.data.hashCode() }) { index, itemWrapper ->
ImportItemRow(
title = itemTitle(itemWrapper.data),
subtitle = itemSubtitle(itemWrapper.data),
isSelected = itemWrapper.isSelected,
status = itemWrapper.status,
onClick = { onToggleItem(index) },
onInfoClick = {
scope.launch { snackbarHostState.showSnackbar("其实还没写桀桀桀") }
onItemInfoClick(index)
}
} else {
null
},
endAction = if (!isEditing) {
{
Row {
topBarActions()
SmallIconButton(
onClick = { onToggleAll(!allSelected) },
imageVector = Icons.Default.SelectAll,
contentDescription = if (allSelected) "全不选" else "全选"
)
}
}
} else {
null
}
) {
if (isEditing) {
BatchImportJsonEditContent(
data = editingItem.data,
version = currentState.version
) { data ->
editingIndex?.let { onUpdateItem(it, data) }
}
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.58f)
) {
LazyColumn(
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
itemsIndexed(
currentState.items,
key = { _, item -> item.data.hashCode() }
) { index, itemWrapper ->
ImportItemRow(
title = itemTitle(itemWrapper.data),
subtitle = itemSubtitle(itemWrapper.data),
isSelected = itemWrapper.isSelected,
status = itemWrapper.status,
onClick = { onToggleItem(index) },
onInfoClick = {
onItemInfoClick(index)
editingIndex = index
}
)
}
}
}
}
ConfirmDismissButtonsRow(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(top = 8.dp, bottom = 8.dp),
onDismiss = onDismissRequest,
onConfirm = {
val selectedData = currentState.items.filter { it.isSelected }.map { it.data }
onConfirm(selectedData)
},
dismissText = "取消",
confirmText = "导入",
confirmEnabled = selectedCount > 0
)
}
}
@SuppressLint("ConfigurationScreenWidthHeight")
@Composable
private fun <T> BatchImportJsonEditContent(
data: T,
version: Int,
onDataChange: (T) -> Unit
) {
val jsonObject = remember(version) { data.toImportJsonObject() }
if (jsonObject == null) {
AppText("不支持编辑")
return
}
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = LocalConfiguration.current.screenHeightDp.dp * 0.58f),
contentPadding = PaddingValues(vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(
items = jsonObject.entrySet().toList(),
key = { it.key }
) { entry ->
BatchImportJsonField(
name = entry.key,
value = entry.value,
onValueChange = { value ->
val updatedJsonObject = data.toImportJsonObject() ?: return@BatchImportJsonField
updatedJsonObject.add(entry.key, value)
updatedJsonObject.toImportDataLike(data)?.let(onDataChange)
}
)
}
}
}
@Composable
private fun BatchImportJsonField(
name: String,
value: JsonElement,
onValueChange: (JsonElement) -> Unit
) {
val primitive = value.takeIf { it.isJsonPrimitive }?.asJsonPrimitive
if (primitive?.isBoolean == true) {
GlassCard(
containerColor = LegadoTheme.colorScheme.onSheetContent
) {
SwitchSettingItem(
title = name,
checked = primitive.asBoolean,
onCheckedChange = { onValueChange(JsonPrimitive(it)) }
)
}
return
}
val isJsonText = value.isJsonObject || value.isJsonArray
val initialText = value.toImportEditText()
var text by remember(name, initialText) { mutableStateOf(initialText) }
AppTextField(
value = text,
onValueChange = { newText ->
text = newText
newText.toImportJsonElement(value)?.let(onValueChange)
},
label = name,
modifier = Modifier.fillMaxWidth(),
singleLine = !isJsonText,
maxLines = if (isJsonText) 8 else 1
)
}
@Composable
fun ImportItemRow(
title: String,
@@ -220,7 +323,7 @@ fun ImportItemRow(
isSelected = isSelected,
inSelectionMode = true,
onToggleSelection = onClick,
modifier = Modifier.padding(vertical = 4.dp),
containerColor = LegadoTheme.colorScheme.onSheetContent,
trailingAction = {
AppText(
text = when (status) {
@@ -248,43 +351,49 @@ fun ImportItemRow(
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ImportBottomBar(
selectedCount: Int,
totalCount: Int,
onToggleSelectAll: (Boolean) -> Unit,
onConfirm: () -> Unit,
onCancel: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
val allSelected = selectedCount == totalCount
OutlinedToggleButton(
checked = allSelected,
onCheckedChange = { checked ->
onToggleSelectAll(checked)
},
) {
Icon(
imageVector = Icons.Default.SelectAll,
contentDescription = if (allSelected) "全不选" else "全选"
)
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedButton(onClick = onCancel) { AppText("取消") }
Button(
enabled = selectedCount > 0,
onClick = onConfirm
) {
AppText("导入")
private fun Any?.toImportJsonObject(): JsonObject? {
return GSON.toJsonTree(this).takeIf { it.isJsonObject }?.asJsonObject
}
@Suppress("UNCHECKED_CAST")
private fun <T> JsonObject.toImportDataLike(data: T): T? {
val clazz = data?.let { it::class.java } ?: return null
return runCatching { GSON.fromJson(this, clazz) as T }.getOrNull()
}
private fun JsonElement.toImportEditText(): String {
return when {
this is JsonNull || isJsonNull -> ""
isJsonObject || isJsonArray -> GSON.toJson(this)
isJsonPrimitive -> asJsonPrimitive.asString
else -> toString()
}
}
private fun String.toImportJsonElement(oldValue: JsonElement): JsonElement? {
val text = trim()
if (oldValue.isJsonNull) {
return if (text.isEmpty()) JsonNull.INSTANCE else JsonPrimitive(this)
}
if (oldValue.isJsonObject || oldValue.isJsonArray) {
if (text.isEmpty()) return JsonNull.INSTANCE
return runCatching { JsonParser.parseString(this) }.getOrNull()
}
if (!oldValue.isJsonPrimitive) return JsonPrimitive(this)
val primitive = oldValue.asJsonPrimitive
return when {
primitive.isNumber -> {
if (text.isEmpty()) {
JsonNull.INSTANCE
} else {
text.toLongOrNull()?.let { JsonPrimitive(it) }
?: text.toDoubleOrNull()?.let { JsonPrimitive(it) }
}
}
else -> JsonPrimitive(this)
}
}
@@ -24,9 +24,10 @@ sealed interface BaseImportUiState<out T> {
data class Success<T>(
val source: String,
val items: List<ImportItemWrapper<T>>,
val version: Int = 0,
// 导入配置项
val keepOriginalName: Boolean = false,
val customGroup: String? = null,
val isAddGroup: Boolean = false
) : BaseImportUiState<T>
}
}