Parcourir la source

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

HapeLee il y a 6 mois
Parent
commit
c7147e6a3d

+ 4 - 12
app/src/main/java/io/legado/app/data/entities/BaseSource.kt

@@ -105,16 +105,13 @@ interface BaseSource : JsExtensions {
         header?.let {
             try {
                 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.substring(4, it.lastIndexOf("<"))
-                    )?.toString().orEmpty()
+                    ).toString()
 
                     else -> it
                 }
-                if (json.isBlank()) {
-                    return@let
-                }
                 GSONStrict.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
                     putAll(map)
                 } ?: GSON.fromJsonObject<Map<String, String>>(json).getOrNull()?.let { map ->
@@ -122,12 +119,7 @@ interface BaseSource : JsExtensions {
                     putAll(map)
                 }
             } catch (e: Exception) {
-                val isEmptyJsonNoise = e.message?.contains("Empty JSON string", ignoreCase = true) == true ||
-                    e.toString().contains("Empty JSON string", ignoreCase = true) ||
-                    e.stackTraceToString().contains("Empty JSON string", ignoreCase = true)
-                if (!isEmptyJsonNoise) {
-                    AppLog.put("执行请求头规则出错\n$e", e)
-                }
+                AppLog.put("执行请求头规则出错\n$e", e)
             }
         }
         if (!has(AppConst.UA_NAME, true)) {
@@ -349,4 +341,4 @@ interface BaseSource : JsExtensions {
         }
         return RhinoScriptEngine.eval(jsStr, scope)
     }
-}
+}

+ 10 - 9
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesCompose.kt

@@ -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.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.cover.buildCoverImageRequest
 import io.legado.app.utils.toastOnUi
@@ -295,21 +296,21 @@ private fun LoadMoreFooter(
         state.errorMessage != null -> "加载失败,点击重试"
         else -> "上拉加载更多"
     }
+    val contentModifier = if (state.errorMessage != null) {
+        Modifier.clickable(onClick = onRetry)
+    } else {
+        Modifier
+    }
     Box(
         modifier = Modifier
             .fillMaxWidth()
             .padding(vertical = 12.dp),
         contentAlignment = Alignment.Center
     ) {
-        Text(
-            text = text,
-            style = LegadoTheme.typography.bodySmall,
-            color = LegadoTheme.colorScheme.onSurfaceVariant,
-            modifier = if (state.errorMessage != null) {
-                Modifier.clickable(onClick = onRetry)
-            } else {
-                Modifier
-            }
+        EmptyMessage(
+            message = text,
+            isLoading = state.isLoadingMore,
+            modifier = contentModifier
         )
     }
 }

+ 4 - 2
app/src/main/java/io/legado/app/ui/rss/article/RssArticlesViewModel.kt

@@ -102,6 +102,7 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
             loadMoreSuccess(it.first)
         }.onError {
             AppLog.put("rss获取内容失败", it)
+            if (page > 1) page--
             _loadState.value = _loadState.value.copy(
                 isLoadingMore = false,
                 errorMessage = it.stackTraceStr
@@ -138,7 +139,8 @@ class RssArticlesViewModel(application: Application) : BaseViewModel(application
 
         _loadState.value = _loadState.value.copy(
             isLoadingMore = false,
-            hasMore = !nextPageUrl.isNullOrBlank()
+            hasMore = !nextPageUrl.isNullOrBlank(),
+            errorMessage = null
         )
     }
 }
@@ -150,5 +152,5 @@ data class RssArticlesLoadState(
     val errorMessage: String? = null
 ) {
     val canLoadMore: Boolean
-        get() = hasMore && !isRefreshing && !isLoadingMore
+        get() = hasMore && !isRefreshing && !isLoadingMore && errorMessage == null
 }

+ 145 - 136
app/src/main/java/io/legado/app/ui/rss/article/RssSortScreen.kt

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
+import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.foundation.pager.HorizontalPager
 import androidx.compose.foundation.pager.rememberPagerState
 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.theme.LegadoTheme
 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.button.SmallIconButton
 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.TopBarNavigationButton
 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.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -119,172 +122,178 @@ fun RssSortScreen(
         }
     }
 
-    Column(modifier = Modifier.fillMaxSize()) {
-        GlassMediumFlexibleTopAppBar(
-            title = title,
-            scrollBehavior = scrollBehavior,
-            navigationIcon = {
-                TopBarNavigationButton(onClick = onBackClick, imageVector = Icons.AutoMirrored.Filled.ArrowBack)
-            },
-            actions = {
-                TopBarActionButton(
-                    onClick = { showMainMenu = true },
-                    imageVector = Icons.Default.MoreVert,
-                    contentDescription = "Menu"
-                )
-                RoundDropdownMenu(
-                    expanded = showMainMenu,
-                    onDismissRequest = { showMainMenu = false }
-                ) { dismiss ->
-                    if (hasLogin) {
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = title,
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick, imageVector = AppIcons.Back)
+                },
+                actions = {
+                    TopBarActionButton(
+                        onClick = { showMainMenu = true },
+                        imageVector = AppIcons.MoreVert,
+                        contentDescription = "Menu"
+                    )
+                    RoundDropdownMenu(
+                        expanded = showMainMenu,
+                        onDismissRequest = { showMainMenu = false }
+                    ) { dismiss ->
+                        if (hasLogin) {
+                            RoundDropdownMenuItem(
+                                text = stringResource(R.string.login),
+                                leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
+                                onClick = {
+                                    dismiss()
+                                    onLogin()
+                                }
+                            )
+                        }
                         RoundDropdownMenuItem(
-                            text = stringResource(R.string.login),
-                            leadingIcon = { MenuItemIcon(Icons.AutoMirrored.Filled.Login) },
+                            text = stringResource(R.string.refresh_sort),
+                            leadingIcon = { MenuItemIcon(Icons.Default.Refresh) },
                             onClick = {
                                 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(
-                            text = policy.title(),
-                            isSelected = policy == redirectPolicy,
+                            text = stringResource(R.string.set_source_variable),
+                            leadingIcon = { MenuItemIcon(Icons.Default.Dataset) },
                             onClick = {
                                 dismiss()
-                                showMainMenu = false
-                                onRedirectPolicyChanged(policy)
+                                onSetSourceVariable()
                             }
                         )
-                    }
-                }
-            },
-            bottomContent = {
-                if (sortList.size > 1) {
-                    Row(
-                        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.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(
+                                text = policy.title(),
+                                isSelected = policy == redirectPolicy,
+                                onClick = {
+                                    dismiss()
+                                    showMainMenu = false
+                                    onRedirectPolicyChanged(policy)
+                                }
+                            )
+                        }
+                    }
+                },
+                bottomContent = {
+                    if (sortList.size > 1) {
+                        Row(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .adaptiveHorizontalPaddingTab(),
+                            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)
-                                                    )
+                            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()) {
             EmptyMessage(
                 message = stringResource(R.string.empty),
                 modifier = Modifier
                     .fillMaxSize()
+                    .padding(paddingValues)
                     .padding(24.dp)
             )
         } else {
             HorizontalPager(
                 state = pagerState,
                 contentPadding = PaddingValues(bottom = 0.dp),
-                modifier = Modifier.fillMaxSize()
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues)
             ) { page ->
                 val item = sortList.getOrNull(page) ?: return@HorizontalPager
                 pagerContent(page, item)

+ 211 - 139
app/src/main/java/io/legado/app/ui/rss/read/RssReadRouteScreen.kt

@@ -6,6 +6,7 @@ import android.annotation.SuppressLint
 import android.net.Uri
 import android.net.http.SslError
 import android.os.SystemClock
+import android.view.MotionEvent
 import android.view.View
 import android.webkit.JavascriptInterface
 import android.webkit.SslErrorHandler
@@ -18,11 +19,18 @@ import android.webkit.WebViewClient
 import androidx.activity.compose.BackHandler
 import androidx.activity.compose.LocalActivity
 import androidx.appcompat.app.AlertDialog
+import androidx.compose.animation.animateContentSize
 import androidx.compose.foundation.layout.Box
 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.fillMaxWidth
 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.automirrored.filled.ArrowBack
 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.rememberCoroutineScope
 import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.core.net.toUri
 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.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppTextField
+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.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.button.TopBarNavigationButton
-import io.legado.app.ui.widget.components.card.GlassCard
+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.RoundDropdownMenu
 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.text.AppText
-import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.isTrue
 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.startActivity
 import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
 import org.apache.commons.text.StringEscapeUtils
 import org.jsoup.Jsoup
@@ -115,6 +129,8 @@ fun RssReadRouteScreen(
     var webProgress by remember { mutableIntStateOf(100) }
     var showMenu 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) }
 
@@ -174,157 +190,208 @@ fun RssReadRouteScreen(
         }
     }
 
-    Column(modifier = Modifier.fillMaxSize()) {
-        GlassMediumFlexibleTopAppBar(
-            title = pageTitle.ifBlank { defaultTopBarTitle },
-            navigationIcon = {
-                TopBarNavigationButton(
-                    onClick = onBackClick,
-                    imageVector = Icons.AutoMirrored.Filled.ArrowBack
-                )
-            },
-            scrollBehavior = null,
-            actions = {
-                TopBarActionButton(
-                    onClick = { viewModel.refresh { webView?.reload() } },
-                    imageVector = Icons.Default.Refresh,
-                    contentDescription = stringResource(R.string.refresh)
-                )
-                TopBarActionButton(
-                    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)
+    Box(modifier = Modifier.fillMaxSize()) {
+        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 }
                         }
                     )
-                    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()
-                                    )
-                                }
+                )
+                createdWebView.setOnTouchListener { _, event ->
+                    when (event.actionMasked) {
+                        MotionEvent.ACTION_DOWN,
+                        MotionEvent.ACTION_MOVE -> {
+                            showFloatingCardJob?.cancel()
+                            showFloatingCard = false
+                        }
+
+                        MotionEvent.ACTION_UP,
+                        MotionEvent.ACTION_CANCEL -> {
+                            createdWebView.performClick()
+                            showFloatingCardJob?.cancel()
+                            showFloatingCardJob = scope.launch {
+                                delay(180L)
+                                showFloatingCard = true
                             }
                         }
+                    }
+                    false
+                }
+            }
+        )
+        if (webProgress in 0..99) {
+            LinearProgressIndicator(
+                progress = { webProgress / 100f },
+                modifier = Modifier.fillMaxWidth()
+            )
+        }
+        TopFloatingStickyItem(
+            item = if (showFloatingCard) {
+                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
                     )
-                    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)
-                                }
-                            }
-                        )
+                    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))
                     }
-                    RoundDropdownMenuItem(
-                        text = stringResource(R.string.redirect_policy),
-                        onClick = { showRedirectMenu = true }
+                    SmallTonalIconButton(
+                        imageVector = Icons.Default.Refresh,
+                        contentDescription = stringResource(R.string.refresh),
+                        onClick = { viewModel.refresh { webView?.reload() } }
                     )
-                    RoundDropdownMenuItem(
-                        text = stringResource(R.string.open_in_browser),
-                        leadingIcon = { MenuItemIcon(Icons.Default.OpenInBrowser) },
+                    Spacer(modifier = Modifier.width(8.dp))
+                    SmallTonalIconButton(
+                        imageVector = Icons.Default.Star,
+                        contentDescription = stringResource(R.string.favorite),
                         onClick = {
-                            dismiss()
-                            webView?.url?.let { context.openUrl(it) } ?: context.toastOnUi("url null")
+                            viewModel.addFavorite()
+                            favoriteTitle = viewModel.rssArticle?.title.orEmpty()
+                            favoriteGroup = viewModel.rssArticle?.group.orEmpty()
+                            showFavoriteSheet = true
                         }
                     )
-                }
-
-                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
+                    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)
                                 }
-                                context.toastOnUi("重定向策略已更新")
-                            },
-                            trailingIcon = {
-                                if (policy == redirectPolicy) {
-                                    Icon(Icons.Default.Check, null)
+                            )
+                            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)
+                                        }
+                                    }
+                                )
                             }
-                        )
-                    }
-                }
-            }
-        )
-
-        Box(modifier = Modifier.fillMaxSize()) {
-            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 }
+                            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)
+                                        }
+                                    }
+                                )
                             }
-                        )
-                    )
+                        }
+                    }
                 }
-            )
-            if (webProgress in 0..99) {
-                LinearProgressIndicator(
-                    progress = { webProgress / 100f },
-                    modifier = Modifier.fillMaxWidth()
-                )
             }
         }
     }
@@ -347,6 +414,11 @@ fun RssReadRouteScreen(
     )
 }
 
+private data class RssReadFloatingSummary(
+    val titleText: String,
+    val progressText: String?,
+)
+
 @Composable
 private fun FavoriteEditSheet(
     show: Boolean,

+ 4 - 0
app/src/main/java/io/legado/app/ui/rss/read/VisibleWebView.kt

@@ -51,6 +51,10 @@ class VisibleWebView(
         super.onWindowVisibilityChanged(VISIBLE)
     }
 
+    override fun performClick(): Boolean {
+        return super.performClick()
+    }
+
     override fun startActionMode(callback: ActionMode.Callback?): ActionMode {
         return super.startActionMode(createWrappedCallback(callback))
     }