From 63331eb13180939eab5021266ed4f95ee52a969b Mon Sep 17 00:00:00 2001 From: QxTeak <351827445+QxTeak@users.noreply.github.com> Date: Fri, 13 Nov 2026 19:59:39 +0800 Subject: [PATCH 1/8] =?UTF-8?q?feat(home):=20=E6=8E=A5=E5=85=A5=E7=9B=B4?= =?UTF-8?q?=E6=92=AD=E9=A1=B5=E4=B8=8E=E7=95=AA=E5=89=A7=E9=A1=B5=EF=BC=9B?= =?UTF-8?q?=E8=AF=84=E8=AE=BA=E6=8A=98=E5=8F=A0=E5=AD=97=E5=8F=B7=E4=B8=8E?= =?UTF-8?q?=E5=B1=95=E5=BC=80=E9=A1=B5=E7=BB=93=E6=9E=84=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=EF=BC=9B=E6=94=B6=E8=B5=B7=E6=92=AD=E6=94=BE=E5=99=A8=E5=8A=A0?= =?UTF-8?q?=E8=BF=94=E5=9B=9E=E9=94=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/aritxonly/bdfun/AppGraph.kt | 2 + .../com/aritxonly/bdfun/di/AppContainer.kt | 3 + .../application/DiscoverStateHolders.kt | 213 +++++++++++ .../comment/row/CommentPreviewText.kt | 5 +- .../comment/row/CommentTypography.kt | 5 + .../comment/thread/CommentThreadParentCard.kt | 87 ++--- .../comment/thread/CommentThreadReplyCard.kt | 15 +- .../discover/BangumiPageScreen.kt | 308 +++++++++++++++ .../presentation/discover/DiscoverPull.kt | 93 +++++ .../presentation/discover/LivePageScreen.kt | 360 ++++++++++++++++++ .../presentation/library/LibraryFolders.kt | 5 - .../bdfun/presentation/shell/MainTabPager.kt | 11 +- .../presentation/text/EmoteTextContracts.kt | 4 +- .../presentation/text/RichTextDecorations.kt | 9 +- .../bdfun/ui/video/VideoScreenLayout.kt | 1 + .../bdfun/core/model/live/LiveCard.kt | 20 + .../bdfun/core/model/pgc/BangumiCard.kt | 14 + .../presentation/VideoCollapseOverlay.kt | 63 ++- .../presentation/detail/VideoDetailLayout.kt | 18 +- .../detail/VideoDetailLayoutSlots.kt | 1 + .../aritxonly/bdfun/infra/bili/BiliBackend.kt | 7 + .../aritxonly/bdfun/infra/bili/LiveApi2.kt | 107 ++++++ .../com/aritxonly/bdfun/infra/bili/PgcApi.kt | 82 ++++ 23 files changed, 1332 insertions(+), 101 deletions(-) create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/feature/discover/application/DiscoverStateHolders.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageScreen.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/DiscoverPull.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageScreen.kt create mode 100644 core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt create mode 100644 core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/pgc/BangumiCard.kt create mode 100644 infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt create mode 100644 infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcApi.kt diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/AppGraph.kt b/app/src/main/kotlin/com/aritxonly/bdfun/AppGraph.kt index afbf9551..00d2c91d 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/AppGraph.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/AppGraph.kt @@ -70,6 +70,8 @@ object AppGraph { it.videoReadCache.scope(), it.automaticVideoQuality, it.settings, it.videoPageDependencies) } val homeFeedState get() = checkNotNull(container) { "AppGraph 尚未初始化" }.homeFeedState + internal val livePageState get() = checkNotNull(container) { "AppGraph 尚未初始化" }.livePageState + internal val bangumiPageState get() = checkNotNull(container) { "AppGraph 尚未初始化" }.bangumiPageState /** 登录态:MainActivity 与 LoginActivity 共享,登录成功直接写这里。 */ val session get() = checkNotNull(container) { "AppGraph 尚未初始化" }.session diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt b/app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt index 2564bb3b..3bb06c39 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt @@ -139,6 +139,9 @@ internal class AppContainer( HomeSlot(job, view) }, { it.view.close(); it.job.cancel() }) val homeFeedState: HomeFeedState get() = homeSlot.get().view + // 首页「直播」「番剧」两段的状态:进程内单例,切 tab 不丢列表与滚动位置。 + val livePageState by lazy { com.aritxonly.bdfun.feature.discover.application.LivePageStateHolder(api) } + val bangumiPageState by lazy { com.aritxonly.bdfun.feature.discover.application.BangumiPageStateHolder(api) } private val dynamicRepository by lazy { BiliFeedPageRepository({ type, offset, mid -> api.dynamicPage(type, offset, mid) }, api::dynamicPortal, api::dynamicUpList, api::likeFeed) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/discover/application/DiscoverStateHolders.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/discover/application/DiscoverStateHolders.kt new file mode 100644 index 00000000..f8695cbe --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/discover/application/DiscoverStateHolders.kt @@ -0,0 +1,213 @@ +package com.aritxonly.bdfun.feature.discover.application + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import com.aritxonly.bdfun.core.model.live.LiveArea +import com.aritxonly.bdfun.core.model.live.LiveCard +import com.aritxonly.bdfun.core.model.pgc.BangumiCard +import com.aritxonly.bdfun.data.BiliApi + +/** + * 首页「直播」段的状态:关注直播 + 分区 + 分区直播流。 + * 刷新/加载失败只写到 error,不改动已有列表,界面按「空态 + 重试」处理。 + */ +internal class LivePageStateHolder(private val api: BiliApi) { + var following by mutableStateOf>(emptyList()) + private set + var areas by mutableStateOf>(emptyList()) + private set + var cards by mutableStateOf>(emptyList()) + private set + var selectedKey by mutableStateOf(0L) + private set + private var selectedParent = 0L + private var selectedSub = 0L + var loading by mutableStateOf(false) + private set + var refreshing by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + var pullFraction by mutableFloatStateOf(0f) + var pullOffsetDp by mutableFloatStateOf(0f) + private var page = 1 + private var hasMore = true + var loadMoreFailed by mutableStateOf(false) + private set + private var token = 0 + + suspend fun ensureLoaded() { + if (cards.isNotEmpty() || loading) return + load(reset = true) + } + + suspend fun refresh() { + if (refreshing) return + refreshing = true + try { load(reset = true) } finally { refreshing = false } + } + + suspend fun selectArea(area: LiveArea) { + val key = area.id + if (key == selectedKey) return + selectedKey = key + selectedParent = if (area.parentId > 0L) area.parentId else area.id + selectedSub = if (area.parentId > 0L) area.id else 0L + cards = emptyList() + page = 1 + hasMore = true + load(reset = true) + } + + suspend fun loadMore() { + if (loading || refreshing || !hasMore) return + val next = page + 1 + val current = token + loading = true + loadMoreFailed = false + try { + val more = api.liveRecommend(next, selectedParent, selectedSub) + if (current != token) return + if (more.isEmpty()) { hasMore = false } else { + cards = cards + more + page = next + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + loadMoreFailed = true + } finally { + if (current == token) loading = false + } + } + + fun retry() { error = null } + + /** 一级分区 + 「娱乐」的子分区(聊天室 / 颜值等),顺序与 B 站直播页一致。 */ + private suspend fun loadAreas(): List { + val parents = runCatching { api.liveAreaList(0L) }.getOrDefault(emptyList()) + if (parents.isEmpty()) return emptyList() + val sub = parents.firstOrNull { it.name.contains("娱乐") }?.let { parent -> + runCatching { api.liveAreaList(parent.id) }.getOrDefault(emptyList()) + }.orEmpty() + return parents + sub + } + + private suspend fun load(reset: Boolean) { + token++ + val current = token + if (cards.isEmpty()) loading = true + loadMoreFailed = false + try { + if (areas.isEmpty()) { + areas = loadAreas() + } + val followingNow = runCatching { api.liveFollowing(1) }.getOrDefault(emptyList()) + val list = api.liveRecommend(1, selectedParent, selectedSub) + if (current != token) return + following = followingNow + cards = list + page = 1 + hasMore = list.isNotEmpty() + error = null + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + if (current != token) return + if (cards.isEmpty()) error = e.message.orEmpty().ifBlank { "加载失败" } + } finally { + if (current == token) loading = false + } + } +} + +/** 首页「番剧」段的状态:最近追番 + 推荐。 */ +internal class BangumiPageStateHolder(private val api: BiliApi) { + var following by mutableStateOf>(emptyList()) + private set + var cards by mutableStateOf>(emptyList()) + private set + var loading by mutableStateOf(false) + private set + var refreshing by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + var pullFraction by mutableFloatStateOf(0f) + var pullOffsetDp by mutableFloatStateOf(0f) + private var page = 1 + private var hasMore = true + var loadMoreFailed by mutableStateOf(false) + private set + private var token = 0 + + suspend fun ensureLoaded() { + if (cards.isNotEmpty() || loading) return + load() + } + + suspend fun refresh() { + if (refreshing) return + refreshing = true + try { load() } finally { refreshing = false } + } + + suspend fun loadMore() { + if (loading || refreshing || !hasMore) return + val next = page + 1 + val current = token + loading = true + loadMoreFailed = false + try { + val more = api.pgcIndex(next) + if (current != token) return + if (more.isEmpty()) { hasMore = false } else { + cards = cards + more + page = next + } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + loadMoreFailed = true + } finally { + if (current == token) loading = false + } + } + + fun retry() { error = null } + + private suspend fun load() { + token++ + val current = token + if (cards.isEmpty()) loading = true + loadMoreFailed = false + try { + val followed = runCatching { api.pgcFollowing(1) }.getOrDefault(emptyList()) + val list = api.pgcIndex(1) + if (current != token) return + following = followed + cards = list + page = 1 + hasMore = list.isNotEmpty() + error = null + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + if (current != token) return + if (cards.isEmpty()) error = e.message.orEmpty().ifBlank { "加载失败" } + } finally { + if (current == token) loading = false + } + } +} + +/** 两个页面共用的网格滚动状态缓存,避免切段/返回时丢位置。 */ +internal object DiscoverGridStates { + var live: LazyGridState? = null + var bangumi: LazyGridState? = null +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentPreviewText.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentPreviewText.kt index 1678ecf7..42d41713 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentPreviewText.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentPreviewText.kt @@ -33,8 +33,9 @@ internal fun androidx.compose.foundation.layout.RowScope.CommentPreviewText( } else { colors.primary.copy(alpha = 0.72f) }, - fontSize = CommentTypography.bodySize, - lineHeight = CommentTypography.bodyLineHeight, + fontSize = CommentTypography.previewBodySize, + lineHeight = CommentTypography.previewLineHeight, + prefixFontSize = CommentTypography.previewNameSize, fontWeight = FontWeight.Normal, mentionColor = if (reply.isVip) { colors.primary diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt index e7e35a44..e472df06 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt @@ -6,4 +6,9 @@ import androidx.compose.ui.unit.sp internal object CommentTypography { val bodySize = 14.85.sp val bodyLineHeight = 21.8.sp + + /** 折叠回复:正文比主评论小 1sp,用户名比折叠正文大 1sp(即回到主评论字号)。 */ + val previewBodySize = bodySize - 1.sp + val previewLineHeight = bodyLineHeight - 1.sp + val previewNameSize = previewBodySize + 1.sp } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadParentCard.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadParentCard.kt index 84451df2..4616373c 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadParentCard.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadParentCard.kt @@ -1,60 +1,45 @@ package com.aritxonly.bdfun.ui -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp import com.aritxonly.bdfun.core.model.comment.CommentItem -import top.yukonga.miuix.kmp.theme.MiuixTheme @Composable internal fun CommentThreadParentCard(comment: CommentItem, transient: CommentThreadPresentationStateHolder, callbacks: ThreadSheetCallbacks) { val likes = LocalVideoPageState.current.dependencies.comments.likes - val colors = MiuixTheme.colorScheme - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(colors.surfaceContainer), - ) { - CommentRow( - item = comment.copy( - replyCount = 0, - extras = comment.extras.copy(previewReplies = emptyList()), - liked = likes.isLiked(comment), - like = likes.likeCount(comment), - ), - disliked = transient.dislikedIds[comment.rpid] == true, - actions = CommentRowActions( - onLike = { callbacks.onLike(comment) }, - onDislike = { - transient.dislikedIds[comment.rpid] = - transient.dislikedIds[comment.rpid] != true - }, - onReply = { transient.replyTarget = comment }, - onLongPress = { bounds -> - transient.menuAnchor = bounds - transient.menuReply = comment - transient.menuVisible = true - }, - onMore = { bounds -> - transient.menuAnchor = bounds - transient.menuReply = comment - transient.menuVisible = true - }, - onOpenReplies = {}, - onReplyToReply = { transient.replyTarget = it }, - onLongPressReply = { reply, bounds -> - transient.menuAnchor = bounds - transient.menuReply = reply - transient.menuVisible = true - }, - onPreviewLike = { callbacks.onLike(it) }, - ), - ) - } + // 与主评论区结构一致:直接平铺一行评论,不再套圆角矩形背景。 + CommentRow( + item = comment.copy( + replyCount = 0, + extras = comment.extras.copy(previewReplies = emptyList()), + liked = likes.isLiked(comment), + like = likes.likeCount(comment), + ), + disliked = transient.dislikedIds[comment.rpid] == true, + actions = CommentRowActions( + onLike = { callbacks.onLike(comment) }, + onDislike = { + transient.dislikedIds[comment.rpid] = + transient.dislikedIds[comment.rpid] != true + }, + onReply = { transient.replyTarget = comment }, + onLongPress = { bounds -> + transient.menuAnchor = bounds + transient.menuReply = comment + transient.menuVisible = true + }, + onMore = { bounds -> + transient.menuAnchor = bounds + transient.menuReply = comment + transient.menuVisible = true + }, + onOpenReplies = {}, + onReplyToReply = { transient.replyTarget = it }, + onLongPressReply = { reply, bounds -> + transient.menuAnchor = bounds + transient.menuReply = reply + transient.menuVisible = true + }, + onPreviewLike = { callbacks.onLike(it) }, + ), + ) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadReplyCard.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadReplyCard.kt index d54e3162..137e2ea0 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadReplyCard.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadReplyCard.kt @@ -3,11 +3,8 @@ package com.aritxonly.bdfun.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.unit.dp import com.aritxonly.bdfun.core.model.comment.CommentItem import top.yukonga.miuix.kmp.theme.MiuixTheme import androidx.compose.runtime.getValue @@ -19,27 +16,19 @@ internal fun CommentThreadReplyCard( ) { val likes = LocalVideoPageState.current.dependencies.comments.likes val colors = MiuixTheme.colorScheme - val isFirst = index == 0 - val isLast = index == lastIndex && end - val shape = RoundedCornerShape( - topStart = if (isFirst) 16.dp else 0.dp, - topEnd = if (isFirst) 16.dp else 0.dp, - bottomStart = if (isLast) 16.dp else 0.dp, - bottomEnd = if (isLast) 16.dp else 0.dp, - ) val rowBackground by androidx.compose.animation.animateColorAsState( targetValue = if (reply.rpid == transient.flashRpid) { colors.onBackground.copy(alpha = 0.10f) } else { - colors.surfaceContainer + androidx.compose.ui.graphics.Color.Transparent }, animationSpec = androidx.compose.animation.core.tween(120), label = "thread-reply-flash", ) + // 与主评论区结构一致:平铺评论行,没有圆角卡片;只保留「刚发出的回复」那一下高亮。 Box( modifier = Modifier .fillMaxWidth() - .clip(shape) .background(rowBackground), ) { CommentRow( diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageScreen.kt new file mode 100644 index 00000000..b05c5194 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageScreen.kt @@ -0,0 +1,308 @@ +package com.aritxonly.bdfun.presentation.discover + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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 +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.aritxonly.bdfun.BrandPink +import com.aritxonly.bdfun.core.model.pgc.BangumiCard +import com.aritxonly.bdfun.feature.discover.application.BangumiPageStateHolder +import com.aritxonly.bdfun.feature.discover.application.DiscoverGridStates +import com.aritxonly.bdfun.ui.FeedCardShape +import com.aritxonly.bdfun.ui.HomePullIndicator +import com.aritxonly.bdfun.ui.HomeTopBarHeight +import com.aritxonly.bdfun.ui.TeakLoading +import com.aritxonly.bdfun.ui.rememberImageRequest +import com.aritxonly.teak.common.chrome.TeakGlass +import com.kyant.shapes.Capsule +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * 首页第三段「番剧」。 + * 第一行:最近追番(横向长封面卡片); + * 下面:推荐(两列长封面卡片)。封面 3:4,统计行是「xx万追番」, + * UP 主位置换成「全xx话」,需要大会员的右上角挂与个人主页同款角标。 + */ +@Composable +internal fun BangumiPage(state: BangumiPageStateHolder) { + val context = LocalContext.current + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val freshGridState = rememberLazyGridState() + val gridState = remember(freshGridState) { + DiscoverGridStates.bangumi ?: freshGridState.also { DiscoverGridStates.bangumi = it } + } + var pullOffset by remember { mutableFloatStateOf(0f) } + val thresholdPx = with(density) { 88.dp.toPx() } + val pull = remember(state) { + DiscoverPullSession( + density = density, + thresholdPx = thresholdPx, + maxPullPx = with(density) { 260.dp.toPx() }, + currentOffset = { pullOffset }, + onPull = { pullOffset = it; state.pullFraction = (it / thresholdPx).coerceIn(0f, 1f) }, + onPullDp = { state.pullOffsetDp = it }, + canScrollBackward = { !gridState.canScrollBackward }, + isRefreshing = { state.refreshing }, + onRefresh = { scope.launch { state.refresh() } }, + ) + } + val refreshConnection = remember(state) { pull.connection() } + + LaunchedEffect(state) { state.ensureLoaded() } + LaunchedEffect(state.refreshing) { + if (!state.refreshing && pullOffset > 0f) { + state.pullFraction = 0f + kotlinx.coroutines.delay(160) + pull.animatePullTo(0f, 260f) + } + } + LaunchedEffect(state) { + snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } + .collect { last -> + val total = gridState.layoutInfo.totalItemsCount + if (state.cards.isNotEmpty() && total > 0 && last >= total - 2) state.loadMore() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + state = gridState, + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(0, pullOffset.toInt()) } + .nestedScroll(refreshConnection), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + top = HomeTopBarHeight + statusBarTop + 6.dp, + bottom = 104.dp, + ), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (state.following.isNotEmpty()) { + item(key = "pgc-follow", span = { GridItemSpan(maxLineSpan) }) { + Column { + Text( + text = "最近追番", + color = MiuixTheme.colorScheme.onBackground, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 2.dp, bottom = 8.dp), + ) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(bottom = 2.dp), + ) { + items(state.following, key = { it.seasonId }) { item -> + Box(modifier = Modifier.width(116.dp)) { + BangumiCardItem(item) { openBangumi(context, item.seasonId) } + } + } + } + Spacer(Modifier.height(12.dp)) + } + } + } + + items(state.cards, key = { it.seasonId }) { item -> + BangumiCardItem(item) { openBangumi(context, item.seasonId) } + } + + if (state.cards.isEmpty() && state.loading) { + item(key = "pgc-loading", span = { GridItemSpan(maxLineSpan) }) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 60.dp), + contentAlignment = Alignment.Center, + ) { TeakLoading(size = 40.dp) } + } + } + + state.error?.let { message -> + if (state.cards.isEmpty()) { + item(key = "pgc-error", span = { GridItemSpan(maxLineSpan) }) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(message, color = MiuixTheme.colorScheme.onBackgroundVariant, fontSize = 14.sp) + Spacer(Modifier.height(10.dp)) + TeakGlass( + modifier = Modifier.height(34.dp), + shape = Capsule(), + onClick = { + state.retry() + scope.launch { state.refresh() } + }, + ) { + Text( + text = "重试", + color = MiuixTheme.colorScheme.primary, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 18.dp).align(Alignment.Center), + ) + } + } + } + } + } + } + + HomePullIndicator( + reveal = if (state.refreshing) 1f else ((state.pullOffsetDp - 58f) / 26f).coerceIn(0f, 1f), + animated = state.refreshing, + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = HomeTopBarHeight + 4.dp), + ) + } +} + +@Composable +private fun BangumiCardItem(item: BangumiCard, onClick: () -> Unit) { + val colors = MiuixTheme.colorScheme + Column( + modifier = Modifier + .clip(FeedCardShape) + .background(colors.surfaceContainer) + .clickable(interactionSource = null, indication = null, onClick = onClick), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(3f / 4f) + .background(colors.surfaceContainerHigh), + ) { + AsyncImage( + model = rememberImageRequest(item.cover), + contentDescription = item.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(42.dp) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color(0x4D000000)))), + ) + if (item.followText.isNotBlank()) { + Text( + text = item.followText, + color = Color.White, + fontSize = 10.sp, + lineHeight = 11.sp, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = 6.dp, bottom = 5.dp), + ) + } + if (item.vip) { + // 与个人主页「大会员」完全同款:品牌粉胶囊 + 白字。 + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .clip(CircleShape) + .background(BrandPink) + .padding(horizontal = 5.dp, vertical = 1.dp), + ) { + Text( + text = "大会员", + color = Color.White, + fontSize = 8.5.sp, + lineHeight = 11.sp, + maxLines = 1, + ) + } + } + } + Column(modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 7.dp, bottom = 8.dp)) { + Text( + text = item.title, + color = colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSize = 13.5.sp, + lineHeight = 18.sp, + modifier = Modifier.heightIn(min = 36.dp), + ) + if (item.indexShow.isNotBlank()) { + Spacer(Modifier.height(5.dp)) + Text( + text = item.indexShow, + color = colors.onBackgroundVariant, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +private fun openBangumi(context: android.content.Context, seasonId: Long) { + if (seasonId <= 0L) return + runCatching { + context.startActivity( + android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse("https://www.bilibili.com/bangumi/play/ss$seasonId"), + ), + ) + }.onFailure { android.widget.Toast.makeText(context, "无法打开番剧", android.widget.Toast.LENGTH_SHORT).show() } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/DiscoverPull.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/DiscoverPull.kt new file mode 100644 index 00000000..c34774c8 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/DiscoverPull.kt @@ -0,0 +1,93 @@ +package com.aritxonly.bdfun.presentation.discover + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.Velocity + +/** + * 直播 / 番剧页的下拉刷新:参数与缓动完全照抄推荐页的 HomePullSession + * (8ms 手动缓动、0.6 阻尼、88dp 阈值、260dp 上限),保证手势手感一致。 + */ +internal class DiscoverPullSession( + private val density: Density, + private val thresholdPx: Float, + private val maxPullPx: Float, + private val currentOffset: () -> Float, + private val onPull: (Float) -> Unit, + private val onPullDp: (Float) -> Unit, + private val canScrollBackward: () -> Boolean, + private val isRefreshing: () -> Boolean, + private val onRefresh: () -> Unit, +) { + private fun applyPullRaw(px: Float) { + val next = px.coerceIn(0f, maxPullPx) + onPull(next) + onPullDp(with(density) { next.toDp().value }) + } + + suspend fun animatePullTo( + target: Float, + durationMs: Float = 220f, + easing: androidx.compose.animation.core.Easing = + androidx.compose.animation.core.FastOutSlowInEasing, + ) { + val from = currentOffset() + val to = target.coerceIn(0f, maxPullPx) + if (from == to) return + if (durationMs <= 0f) { + applyPullRaw(to) + return + } + val startNanos = System.nanoTime() + while (true) { + val p = ((System.nanoTime() - startNanos) / 1_000_000f / durationMs) + .coerceIn(0f, 1f) + applyPullRaw(from + (to - from) * easing.transform(p)) + if (p >= 1f) break + kotlinx.coroutines.delay(8) + } + applyPullRaw(to) + } + + fun connection(): NestedScrollConnection = + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (source != NestedScrollSource.UserInput) return Offset.Zero + if (available.y < 0f && currentOffset() > 0f) { + val next = (currentOffset() + available.y).coerceAtLeast(0f) + val consumed = next - currentOffset() + applyPullRaw(next) + return Offset(0f, consumed) + } + if (available.y > 0f && !canScrollBackward()) { + val next = (currentOffset() + available.y * 0.6f).coerceAtMost(maxPullPx) + val consumed = next - currentOffset() + applyPullRaw(next) + return Offset(0f, consumed) + } + return Offset.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + val shouldRefresh = currentOffset() >= thresholdPx && !isRefreshing() + if (shouldRefresh) { + animatePullTo(thresholdPx, 140f) + onRefresh() + } else { + animatePullTo(0f, 220f) + } + return Velocity.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity = Velocity.Zero + } +} + +internal class DiscoverPullUi { + var offset by mutableFloatStateOf(0f) +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageScreen.kt new file mode 100644 index 00000000..5bf13fbb --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageScreen.kt @@ -0,0 +1,360 @@ +package com.aritxonly.bdfun.presentation.discover + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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 +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.aritxonly.bdfun.core.model.feed.FeedLiveRecommendation +import com.aritxonly.bdfun.core.model.live.LiveArea +import com.aritxonly.bdfun.core.model.live.LiveCard +import com.aritxonly.bdfun.feature.discover.application.DiscoverGridStates +import com.aritxonly.bdfun.feature.discover.application.LivePageStateHolder +import com.aritxonly.bdfun.navigateToLiveRoom +import com.aritxonly.bdfun.navigateToLogin +import com.aritxonly.bdfun.ui.FeedCardShape +import com.aritxonly.bdfun.ui.FeedLiveCard +import com.aritxonly.bdfun.ui.HomePullIndicator +import com.aritxonly.bdfun.ui.HomeTopBarHeight +import com.aritxonly.bdfun.ui.TeakLoading +import com.aritxonly.bdfun.ui.rememberImageRequest +import com.aritxonly.bdfun.ui.showToast +import com.aritxonly.teak.common.chrome.TeakGlass +import com.kyant.shapes.Capsule +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * 首页第一段「直播」。 + * 第一行:关注的正在直播(与动态页首个圆角矩形同构); + * 第二行:分区 chips(收藏夹列表同款,去掉高光描边); + * 下面:分区直播流卡片(看过人数 + 直播内容)。 + */ +@Composable +internal fun LivePage(state: LivePageStateHolder, loggedIn: Boolean) { + val context = LocalContext.current + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val freshGridState = rememberLazyGridState() + val gridState = remember(freshGridState) { + DiscoverGridStates.live ?: freshGridState.also { DiscoverGridStates.live = it } + } + var pullOffset by remember { mutableFloatStateOf(0f) } + val thresholdPx = with(density) { 88.dp.toPx() } + val pull = remember(state) { + DiscoverPullSession( + density = density, + thresholdPx = thresholdPx, + maxPullPx = with(density) { 260.dp.toPx() }, + currentOffset = { pullOffset }, + onPull = { pullOffset = it; state.pullFraction = (it / thresholdPx).coerceIn(0f, 1f) }, + onPullDp = { state.pullOffsetDp = it }, + canScrollBackward = { !gridState.canScrollBackward }, + isRefreshing = { state.refreshing }, + onRefresh = { scope.launch { state.refresh() } }, + ) + } + val refreshConnection = remember(state) { pull.connection() } + + LaunchedEffect(state) { state.ensureLoaded() } + // 刷新结束:先收小电视,再让内容平滑回顶(与推荐页同一条时序)。 + LaunchedEffect(state.refreshing) { + if (!state.refreshing && pullOffset > 0f) { + state.pullFraction = 0f + kotlinx.coroutines.delay(160) + pull.animatePullTo(0f, 260f) + } + } + // 滚到底部预取下一页 + LaunchedEffect(state) { + snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } + .collect { last -> + val total = gridState.layoutInfo.totalItemsCount + if (state.cards.isNotEmpty() && total > 0 && last >= total - 2) state.loadMore() + } + } + + Box(modifier = Modifier.fillMaxSize()) { + LazyVerticalGrid( + columns = GridCells.Fixed(2), + state = gridState, + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(0, pullOffset.toInt()) } + .nestedScrollStrict(refreshConnection), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + top = HomeTopBarHeight + statusBarTop + 6.dp, + bottom = 104.dp, + ), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (state.following.isNotEmpty()) { + item(key = "live-follow", span = { GridItemSpan(maxLineSpan) }) { + Column { + SectionTitle("关注的直播") + LazyRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(bottom = 2.dp), + ) { + items(state.following, key = { it.roomId }) { live -> + Box(modifier = Modifier.width(250.dp)) { + FeedLiveCard(live.toFeedLive()) + } + } + } + } + } + } + + if (state.areas.isNotEmpty()) { + item(key = "live-areas", span = { GridItemSpan(maxLineSpan) }) { + Column { + Spacer(Modifier.height(if (state.following.isEmpty()) 0.dp else 12.dp)) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 6.dp), + ) { + item(key = "area-all") { + LiveAreaChip( + text = "推荐", + selected = state.selectedKey == 0L, + onClick = { scope.launch { state.selectArea(LiveArea(0L, "推荐")) } }, + ) + } + items(state.areas, key = { "${it.parentId}-${it.id}" }) { area -> + LiveAreaChip( + text = area.name, + selected = state.selectedKey == area.id, + onClick = { scope.launch { state.selectArea(area) } }, + ) + } + } + } + } + } + + items(state.cards, key = { it.roomId }) { live -> + LiveGridCard(live) { navigateToLiveRoom(context, live.roomId) } + } + + if (state.cards.isEmpty() && state.loading) { + item(key = "live-loading", span = { GridItemSpan(maxLineSpan) }) { + Box( + modifier = Modifier.fillMaxWidth().padding(vertical = 60.dp), + contentAlignment = Alignment.Center, + ) { TeakLoading(size = 40.dp) } + } + } + + state.error?.let { message -> + if (state.cards.isEmpty()) { + item(key = "live-error", span = { GridItemSpan(maxLineSpan) }) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(message, color = MiuixTheme.colorScheme.onBackgroundVariant, fontSize = 14.sp) + Spacer(Modifier.height(10.dp)) + TeakGlass( + modifier = Modifier.height(34.dp), + shape = Capsule(), + onClick = { + state.retry() + scope.launch { state.refresh() } + }, + ) { + Text( + text = "重试", + color = MiuixTheme.colorScheme.primary, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 18.dp).align(Alignment.Center), + ) + } + } + } + } + } + + if (!loggedIn && state.following.isEmpty()) { + item(key = "live-login", span = { GridItemSpan(maxLineSpan) }) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(FeedCardShape) + .background(MiuixTheme.colorScheme.surfaceContainer) + .clickable(interactionSource = null, indication = null) { + navigateToLogin(context) + } + .padding(16.dp), + ) { + Text("登录后在这里看到关注的直播", color = MiuixTheme.colorScheme.onBackgroundVariant, fontSize = 13.sp) + } + } + } + } + + HomePullIndicator( + reveal = if (state.refreshing) 1f else ((state.pullOffsetDp - 58f) / 26f).coerceIn(0f, 1f), + animated = state.refreshing, + modifier = Modifier + .align(Alignment.TopCenter) + .statusBarsPadding() + .padding(top = HomeTopBarHeight + 4.dp), + ) + } +} + +private fun Modifier.nestedScrollStrict(connection: androidx.compose.ui.input.nestedscroll.NestedScrollConnection): Modifier = + this.nestedScroll(connection) + +@Composable +private fun SectionTitle(text: String) { + Text( + text = text, + color = MiuixTheme.colorScheme.onBackground, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 2.dp, bottom = 8.dp), + ) +} + +@Composable +private fun LiveAreaChip(text: String, selected: Boolean, onClick: () -> Unit) { + val colors = MiuixTheme.colorScheme + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(if (selected) colors.primary else colors.surfaceContainer) + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(horizontal = 14.dp, vertical = 6.dp), + ) { + Text( + text = text, + color = if (selected) Color.White else colors.onBackground, + fontSize = 12.sp, + maxLines = 1, + ) + } +} + +/** 直播卡片:结构同首页视频卡片,统计行换成「xx人看过」+ 直播内容。 */ +@Composable +private fun LiveGridCard(live: LiveCard, onClick: () -> Unit) { + val colors = MiuixTheme.colorScheme + Column( + modifier = Modifier + .clip(FeedCardShape) + .background(colors.surfaceContainer) + .clickable(interactionSource = null, indication = null, onClick = onClick), + ) { + Box(modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f).background(colors.surfaceContainerHigh)) { + AsyncImage( + model = rememberImageRequest(live.cover), + contentDescription = live.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(42.dp) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color(0x4D000000)))), + ) + Row( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(start = 6.dp, end = 6.dp, bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (live.watchedText.isNotBlank()) { + Text(live.watchedText, color = Color.White, fontSize = 10.sp, lineHeight = 11.sp) + } + Spacer(Modifier.weight(1f)) + if (live.areaName.isNotBlank()) { + Text(live.areaName, color = Color.White, fontSize = 10.sp, lineHeight = 11.sp) + } + } + } + Column(modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 7.dp, bottom = 8.dp)) { + Text( + text = live.title, + color = colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSize = 13.5.sp, + lineHeight = 18.sp, + modifier = Modifier.heightIn(min = 36.dp), + ) + Spacer(Modifier.height(5.dp)) + Text( + text = live.uname, + color = colors.onBackgroundVariant, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +private fun LiveCard.toFeedLive() = FeedLiveRecommendation( + roomId = roomId, + title = title, + cover = cover, + live = true, + areaName = areaName, + watched = watchedText, +) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/library/LibraryFolders.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/library/LibraryFolders.kt index 9faa0bfa..b757a38d 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/library/LibraryFolders.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/library/LibraryFolders.kt @@ -64,11 +64,6 @@ internal fun LibraryFolderChip(text: String, selected: Boolean, onClick: () -> U modifier = Modifier .clip(RoundedCornerShape(50)) .background(if (selected) colors.primary else colors.surfaceContainer) - .border( - width = if (selected) 1.dp else 0.dp, - color = if (selected) Color.White.copy(alpha = 0.45f) else Color.Transparent, - shape = RoundedCornerShape(50), - ) .clickable(interactionSource = null, indication = null, onClick = onClick) .padding(horizontal = 14.dp, vertical = 6.dp), ) { diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTabPager.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTabPager.kt index 61474043..9440bb48 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTabPager.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTabPager.kt @@ -50,10 +50,15 @@ internal fun MainTabPager( onOpenVideo = { navigateToVideo(context, it) }, cardStyle = settings.homeCardStyle, ) + } else if (homeSection == 1) { + com.aritxonly.bdfun.presentation.discover.LivePage( + state = AppGraph.livePageState, + loggedIn = session != null, + ) } else { - // 直播 / 番剧:页面还没做,先留空。顶栏 tab 可以切换到这里, - // 不显示任何占位文案,避免以后接入真实页面时还要删一遍。 - androidx.compose.foundation.layout.Box(Modifier.fillMaxSize()) + com.aritxonly.bdfun.presentation.discover.BangumiPage( + state = AppGraph.bangumiPageState, + ) } 1 -> FeedScreen( diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/text/EmoteTextContracts.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/text/EmoteTextContracts.kt index 52834fb9..9ef081e1 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/text/EmoteTextContracts.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/text/EmoteTextContracts.kt @@ -17,7 +17,9 @@ internal data class EmoteTextContent(val text: String, val prefix: String? = nul internal data class EmoteTextStyle(val color: Color, val fontSize: TextUnit, val lineHeight: TextUnit, val prefixColor: Color = color, val prefixWeight: FontWeight = FontWeight.Normal, val maxLines: Int = Int.MAX_VALUE, val trailingWidth: TextUnit = 40.sp, - val fontWeight: FontWeight = FontWeight.Normal, val mentionColor: Color = color) + val fontWeight: FontWeight = FontWeight.Normal, val mentionColor: Color = color, + // 用户名字号可以和正文不同(例如折叠回复里名字比正文大 1sp)。 + val prefixFontSize: TextUnit = TextUnit.Unspecified) @androidx.compose.runtime.Immutable internal data class EmoteTextDecorations(val prefixBadge: (@Composable () -> Unit)? = null, val trailingContent: (@Composable () -> Unit)? = null) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/text/RichTextDecorations.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/text/RichTextDecorations.kt index 51a0b4f6..719c982c 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/text/RichTextDecorations.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/text/RichTextDecorations.kt @@ -22,8 +22,9 @@ internal fun AnnotatedString.Builder.appendRichPrefix(runtime: RichTextRenderCon val prefixColor = runtime.style.prefixColor val prefixWeight = runtime.style.prefixWeight val fontSize = runtime.style.fontSize + val prefixSize = runtime.style.prefixFontSize.takeOrElse { fontSize } if (!prefix.isNullOrBlank()) { - withStyle(SpanStyle(color = prefixColor, fontWeight = prefixWeight)) { + withStyle(SpanStyle(color = prefixColor, fontWeight = prefixWeight, fontSize = prefixSize)) { append(prefix) } if (prefixBadge != null) { @@ -31,8 +32,8 @@ internal fun AnnotatedString.Builder.appendRichPrefix(runtime: RichTextRenderCon appendInlineContent("prefixBadge", "UP") inline["prefixBadge"] = InlineTextContent( Placeholder( - width = fontSize * 1.9f, - height = fontSize * 1.35f, + width = prefixSize * 1.9f, + height = prefixSize * 1.35f, placeholderVerticalAlign = PlaceholderVerticalAlign.Center, ), ) { _ -> @@ -44,7 +45,7 @@ internal fun AnnotatedString.Builder.appendRichPrefix(runtime: RichTextRenderCon } } } - withStyle(SpanStyle(color = prefixColor, fontWeight = prefixWeight)) { + withStyle(SpanStyle(color = prefixColor, fontWeight = prefixWeight, fontSize = prefixSize)) { append(":") } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/ui/video/VideoScreenLayout.kt b/app/src/main/kotlin/com/aritxonly/bdfun/ui/video/VideoScreenLayout.kt index 6e3f0105..08def267 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/ui/video/VideoScreenLayout.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/ui/video/VideoScreenLayout.kt @@ -21,5 +21,6 @@ internal fun VideoScreenLayout( viewport, holders.ui, actions, holders.metadata, scope) }, loading = { modifier, color -> TeakLoading(modifier = modifier, color = color) }, togglePlayback = background::togglePlayPause, accentColor = BrandPink, playIcon = TeakIcon.PlayerPlay, + onBack = { actions.leaveVideo() }, )) } diff --git a/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt new file mode 100644 index 00000000..34cc401b --- /dev/null +++ b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt @@ -0,0 +1,20 @@ +package com.aritxonly.bdfun.core.model.live + +/** 直播页卡片:关注列表与分区列表共用。 */ +data class LiveCard( + val roomId: Long, + val title: String, + val uname: String, + val cover: String, + /** 直播内容 / 分区名,例如「原神」「王者荣耀」。 */ + val areaName: String, + /** 接口给的「x.x万人看过」原文;为空时界面按 online 兜底。 */ + val watchedText: String = "", +) + +/** 直播分区(第二行横向列表)。parentId 为 0 表示这是一级分区本身。 */ +data class LiveArea( + val id: Long, + val name: String, + val parentId: Long = 0L, +) diff --git a/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/pgc/BangumiCard.kt b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/pgc/BangumiCard.kt new file mode 100644 index 00000000..c2d7f56f --- /dev/null +++ b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/pgc/BangumiCard.kt @@ -0,0 +1,14 @@ +package com.aritxonly.bdfun.core.model.pgc + +/** 番剧页卡片:最近追番与推荐共用。 */ +data class BangumiCard( + val seasonId: Long, + val title: String, + val cover: String, + /** 集数文案,例如「全12话」「更新至第8话」,显示在视频卡片的 UP 主位置。 */ + val indexShow: String = "", + /** 追番数文案,例如「1234.5万追番」,显示在视频卡片的播放量位置。 */ + val followText: String = "", + /** 需要大会员:封面右上角角标。 */ + val vip: Boolean = false, +) diff --git a/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/VideoCollapseOverlay.kt b/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/VideoCollapseOverlay.kt index fbb4e1c5..d2d18d63 100644 --- a/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/VideoCollapseOverlay.kt +++ b/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/VideoCollapseOverlay.kt @@ -8,11 +8,13 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -22,11 +24,13 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.ChevronBackward @Composable fun androidx.compose.foundation.layout.BoxScope.VideoCollapseOverlay( fullscreen: Boolean, collapseProgress: Float, playerBlockHeight: Dp, statusTop: Dp, - accentColor: Color, playIcon: Int, onRestorePlay: () -> Unit, + accentColor: Color, playIcon: Int, onRestorePlay: () -> Unit, onBack: () -> Unit, ) { // 随着收起进度给整个播放器盖一层粉色遮罩;收满时完全不透明。 // 遮罩高度就是播放器块高度,所以状态栏那一条也会一起变粉。 @@ -43,32 +47,57 @@ fun androidx.compose.foundation.layout.BoxScope.VideoCollapseOverlay( // 粉色遮罩快盖满时,在顶部 56dp 里显示播放入口(接近到顶才出现)。 val collapsedPlayAlpha = ((collapseProgress - 0.85f) / 0.15f).coerceIn(0f, 1f) if (!fullscreen && collapsedPlayAlpha > 0.01f) { - Row( + Box( modifier = Modifier .align(Alignment.TopCenter) .padding(top = statusTop) .fillMaxWidth() .height(56.dp) - .graphicsLayer { alpha = collapsedPlayAlpha } - .clickable(interactionSource = null, indication = null) { - onRestorePlay() - }, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Center, + .graphicsLayer { alpha = collapsedPlayAlpha }, ) { + // 播放入口保持整行居中:点整行都恢复播放。 + Row( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + .clickable(interactionSource = null, indication = null) { + onRestorePlay() + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + Box( + modifier = Modifier.size(18.dp), + contentAlignment = Alignment.Center, + ) { + // 和播放器控件同一个播放图标(TeakIcon.PlayerPlay),无底色、纯白。 + PlayGlyph(playIcon, tint = Color.White, size = 16.dp) + } + Spacer(Modifier.width(7.dp)) + Text( + text = "立即播放", + color = Color.White, + fontSize = 15.sp, + ) + } + // 收起态左侧返回键:和「立即播放」同一行,点它退出页面(走后台播放交棒逻辑)。 Box( - modifier = Modifier.size(18.dp), + modifier = Modifier + .align(Alignment.CenterStart) + .padding(start = 8.dp) + .size(44.dp) + .clickable(interactionSource = null, indication = null) { + onBack() + }, contentAlignment = Alignment.Center, ) { - // 和播放器控件同一个播放图标(TeakIcon.PlayerPlay),无底色、纯白。 - PlayGlyph(playIcon, tint = Color.White, size = 16.dp) + Icon( + imageVector = MiuixIcons.ChevronBackward, + contentDescription = "返回", + tint = Color.White, + modifier = Modifier.size(20.dp), + ) } - Spacer(Modifier.width(7.dp)) - Text( - text = "立即播放", - color = Color.White, - fontSize = 15.sp, - ) } } } diff --git a/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/detail/VideoDetailLayout.kt b/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/detail/VideoDetailLayout.kt index 8ec5f732..4b796761 100644 --- a/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/detail/VideoDetailLayout.kt +++ b/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/detail/VideoDetailLayout.kt @@ -51,11 +51,19 @@ fun VideoDetailLayout( )) slots.player(this, viewport) - VideoCollapseOverlay(fullscreen, viewport.collapseProgress, playerBlockHeight, statusTop, - slots.accentColor, slots.playIcon) { - collapseState.restore() - if (!playing) slots.togglePlayback() - } + VideoCollapseOverlay( + fullscreen = fullscreen, + collapseProgress = viewport.collapseProgress, + playerBlockHeight = playerBlockHeight, + statusTop = statusTop, + accentColor = slots.accentColor, + playIcon = slots.playIcon, + onRestorePlay = { + collapseState.restore() + if (!playing) slots.togglePlayback() + }, + onBack = slots.onBack, + ) } } diff --git a/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/detail/VideoDetailLayoutSlots.kt b/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/detail/VideoDetailLayoutSlots.kt index eb95bd42..784061f0 100644 --- a/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/detail/VideoDetailLayoutSlots.kt +++ b/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/detail/VideoDetailLayoutSlots.kt @@ -11,6 +11,7 @@ class VideoDetailLayoutSlots( val player: @Composable BoxScope.(VideoPlayerViewport) -> Unit, val loading: @Composable (Modifier, Color) -> Unit, val togglePlayback: () -> Unit, val accentColor: Color, val playIcon: Int, + val onBack: () -> Unit, ) class VideoCollapseHost( val commentThreadVisible: () -> Boolean, diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliBackend.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliBackend.kt index 4cf5e79b..c2f99209 100644 --- a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliBackend.kt +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliBackend.kt @@ -39,6 +39,13 @@ open class BiliBackend(context: Context, debug: Boolean, log: (String, String) - suspend fun liveEmoticons(roomId: Long) = api.liveEmoticons(roomId) suspend fun liveDanmuInfo(roomId: Long) = api.liveDanmuInfo(roomId) suspend fun livePlayUrl(roomId: Long, qn: Int = 10000) = api.livePlayUrl(roomId, qn) + // 直播页 / 番剧页(PiliPlus 同款接口) + suspend fun liveFollowing(page: Int = 1) = api.liveFollowing(page) + suspend fun liveRecommend(page: Int = 1, parentAreaId: Long = 0L, areaId: Long = 0L) = + api.liveRecommend(page, parentAreaId, areaId) + suspend fun liveAreaList(parentId: Long = 0L) = api.liveAreaList(parentId) + suspend fun pgcFollowing(page: Int = 1) = api.pgcFollowing(page) + suspend fun pgcIndex(page: Int = 1, pageSize: Int = 20) = api.pgcIndex(page, pageSize) suspend fun nav() = api.nav() suspend fun qrGenerate() = api.qrGenerate() suspend fun qrPoll(key: String) = api.qrPoll(key) diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt new file mode 100644 index 00000000..75f4523d --- /dev/null +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt @@ -0,0 +1,107 @@ +package com.aritxonly.bdfun.infra.bili + +import com.aritxonly.bdfun.core.model.live.LiveArea +import com.aritxonly.bdfun.core.model.live.LiveCard +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +private const val livePageBase = "https://api.live.bilibili.com" +private const val liveReferer = "https://live.bilibili.com/" + +/** + * 关注的正在直播(PiliPlus 同款接口 /xlive/web-ucenter/user/following)。 + * 未登录或被风控时返回空列表,页面隐藏这一行。 + */ +internal suspend fun BiliApiContext.liveFollowing(page: Int = 1): List = + withContext(Dispatchers.IO) { + cancellableCatching { + val data = dataRequest( + "/xlive/web-ucenter/user/following", + mapOf( + "page" to page.toString(), + "page_size" to "9", + "ignoreRecord" to "1", + "hit_ab" to "true", + ), + base = livePageBase, + referer = liveReferer, + ) + val list = data.optJSONArray("list") ?: return@cancellableCatching emptyList() + (0 until list.length()).mapNotNull { i -> + val item = list.optJSONObject(i) ?: return@mapNotNull null + if (item.optInt("live_status", 0) != 1) return@mapNotNull null + item.toLiveCard() + } + }.getOrDefault(emptyList()) + } + +/** + * 推荐直播(沿用首页已有的 web 接口 /second/getList)。 + * parentAreaId / areaId 传 0 表示全部;切分区时传对应 id。 + */ +internal suspend fun BiliApiContext.liveRecommend( + page: Int = 1, parentAreaId: Long = 0L, areaId: Long = 0L, +): List = withContext(Dispatchers.IO) { + cancellableCatching { + val data = dataRequest( + "/xlive/web-interface/v1/second/getList", + mapOf( + "platform" to "web", + "parent_area_id" to parentAreaId.toString(), + "area_id" to areaId.toString(), + "sort_type" to "online", + "page" to page.toString(), + "page_size" to "20", + ), + base = livePageBase, + referer = liveReferer, + ) + val list = data.optJSONArray("list") ?: return@cancellableCatching emptyList() + (0 until list.length()).mapNotNull { i -> + list.optJSONObject(i)?.toLiveCard() + } + }.getOrDefault(emptyList()) +} + +/** 直播分区:web 端 /room/v1/Area/getList,data 本身是数组。parentId=0 取一级分区。 */ +internal suspend fun BiliApiContext.liveAreaList(parentId: Long = 0L): List = withContext(Dispatchers.IO) { + cancellableCatching { + val root = rawRequest( + "/room/v1/Area/getList", + mapOf("parent_id" to parentId.toString()), + base = livePageBase, + referer = liveReferer, + ) + val list = root.optJSONArray("data") ?: return@cancellableCatching emptyList() + (0 until list.length()).mapNotNull { i -> + val item = list.optJSONObject(i) ?: return@mapNotNull null + val id = item.optLong("id", 0L) + val name = item.optString("name").orEmpty() + if (id <= 0L || name.isBlank()) null else LiveArea(id, name, parentId) + } + }.getOrDefault(emptyList()) +} + +private fun JSONObject.toLiveCard(): LiveCard? { + val roomId = optLong("roomid", 0L).takeIf { it > 0L } ?: return null + val watched = optJSONObject("watched_show")?.optString("text_small").orEmpty() + .ifBlank { optString("text_small").orEmpty() } + .ifBlank { + val online = optLong("online", 0L) + if (online > 0L) "${formatLiveCount(online)}人看过" else "" + } + return LiveCard( + roomId = roomId, + title = optString("title").orEmpty().ifBlank { optString("uname").orEmpty() }, + uname = optString("uname").orEmpty(), + cover = biliImage(optString("cover").orEmpty().ifBlank { optString("room_cover").orEmpty() }), + areaName = optString("area_name").orEmpty(), + watchedText = watched, + ) +} + +private fun formatLiveCount(value: Long): String = when { + value >= 10_000L -> String.format("%.1f万", value / 10_000f) + else -> value.toString() +} diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcApi.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcApi.kt new file mode 100644 index 00000000..a5f1d310 --- /dev/null +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcApi.kt @@ -0,0 +1,82 @@ +package com.aritxonly.bdfun.infra.bili + +import com.aritxonly.bdfun.core.model.pgc.BangumiCard +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject + +private const val pgcReferer = "https://www.bilibili.com/anime/" + +/** + * 最近追番(PiliPlus 的「我的追番」接口 /x/space/bangumi/follow/list?type=1)。 + * 未登录返回空列表,页面只显示推荐。 + */ +internal suspend fun BiliApiContext.pgcFollowing(page: Int = 1): List = + withContext(Dispatchers.IO) { + cancellableCatching { + val data = dataRequest( + "/x/space/bangumi/follow/list", + mapOf( + "type" to "1", + "pn" to page.toString(), + "ps" to "15", + ), + referer = pgcReferer, + ) + val list = data.optJSONArray("list") ?: return@cancellableCatching emptyList() + (0 until list.length()).mapNotNull { i -> + list.optJSONObject(i)?.toBangumiCard() + } + }.getOrDefault(emptyList()) + } + +/** + * 番剧推荐(PiliPlus 番剧索引同款 /pgc/season/index/result)。 + * order=3 为「追番人数」排序,接口因此会带回 order 字段(xx万追番)。 + */ +internal suspend fun BiliApiContext.pgcIndex(page: Int = 1, pageSize: Int = 20): List = + withContext(Dispatchers.IO) { + cancellableCatching { + val data = dataRequest( + "/pgc/season/index/result", + mapOf( + "st" to "1", + "order" to "3", + "sort" to "0", + "season_type" to "1", + "type" to "1", + "season_version" to "-1", + "spoken_language_type" to "-1", + "area" to "-1", + "is_finish" to "-1", + "copyright" to "-1", + "season_status" to "-1", + "season_month" to "-1", + "year" to "-1", + "style_id" to "-1", + "page" to page.toString(), + "pagesize" to pageSize.toString(), + ), + referer = pgcReferer, + ) + val list = data.optJSONArray("list") ?: return@cancellableCatching emptyList() + (0 until list.length()).mapNotNull { i -> + list.optJSONObject(i)?.toBangumiCard() + } + }.getOrDefault(emptyList()) + } + +private fun JSONObject.toBangumiCard(): BangumiCard? { + val seasonId = optLong("season_id", 0L).takeIf { it > 0L } ?: return null + val badge = optString("badge").orEmpty() + return BangumiCard( + seasonId = seasonId, + title = optString("title").orEmpty(), + cover = biliImage(optString("cover").orEmpty()), + indexShow = optString("index_show").orEmpty().ifBlank { + optJSONObject("new_ep")?.optString("index_show").orEmpty() + }, + followText = optString("order").orEmpty().takeIf { it.isNotBlank() }?.let { "$it追番" }.orEmpty(), + vip = badge.contains("会员"), + ) +} From 691fb5a775f71af6115a1b06fac96b0537586bda Mon Sep 17 00:00:00 2001 From: QxTeak <351827445+QxTeak@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:19:17 +0800 Subject: [PATCH 2/8] =?UTF-8?q?fix(home):=20=E7=9B=B4=E6=92=AD/=E7=95=AA?= =?UTF-8?q?=E5=89=A7=E9=A1=B5=E6=8E=A5=E5=85=A5=E9=A1=B6=E6=A0=8F=E6=B8=90?= =?UTF-8?q?=E8=BF=9B=E6=A8=A1=E7=B3=8A=E4=B8=8E=E7=8E=BB=E7=92=83=E3=80=81?= =?UTF-8?q?App=E5=B1=82=E4=B8=8B=E6=8B=89=E5=88=B7=E6=96=B0=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E7=9B=B4=E6=92=AD=E6=8E=A5=E5=8F=A3-352?= =?UTF-8?q?=E4=B8=8E=E8=BF=BD=E7=95=AA=E6=95=B0=E6=96=87=E6=A1=88=EF=BC=9B?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E6=9F=A5=E7=9C=8B=E5=99=A8=E6=8C=89=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E9=A1=B5=E6=94=B6=E6=8B=A2=E3=80=81=E7=82=B9=E7=A9=BA?= =?UTF-8?q?=E7=99=BD=E9=80=80=E5=87=BA=EF=BC=9B=E4=BF=AE=E5=A4=8D=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E5=8A=A8=E7=94=BB=E5=BA=95=E6=A0=8F=E6=A8=A1=E7=B3=8A?= =?UTF-8?q?=E8=B7=B3=E5=8F=98=E4=B8=8E=E9=A1=B6=E6=A0=8F=E6=A8=A1=E7=B3=8A?= =?UTF-8?q?=E5=88=87=E5=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/search/TeakSearchTransition.kt | 5 +- .../application/DiscoverStateHolders.kt | 9 +- .../comment/row/CommentTypography.kt | 6 +- .../common/photo/PhotoViewerOverlay.kt | 20 +- .../common/photo/PhotoViewerPage.kt | 24 +- .../presentation/discover/BangumiPageItems.kt | 181 +++++++++ .../discover/BangumiPageScreen.kt | 303 +++------------ .../presentation/discover/LivePageItems.kt | 266 +++++++++++++ .../presentation/discover/LivePageScreen.kt | 352 +++--------------- .../presentation/shell/MainHomeChrome.kt | 34 +- .../presentation/shell/MainShellNavigation.kt | 7 +- .../presentation/shell/MainShellState.kt | 2 + .../shell/MainShellStateFactory.kt | 7 +- .../presentation/text/RichTextDecorations.kt | 1 + .../aritxonly/bdfun/infra/bili/LiveApi2.kt | 31 +- .../com/aritxonly/bdfun/infra/bili/PgcApi.kt | 13 +- 16 files changed, 671 insertions(+), 590 deletions(-) create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageItems.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageItems.kt diff --git a/TeakUI/src/main/kotlin/com/aritxonly/teak/common/search/TeakSearchTransition.kt b/TeakUI/src/main/kotlin/com/aritxonly/teak/common/search/TeakSearchTransition.kt index 6465e97b..2c688892 100644 --- a/TeakUI/src/main/kotlin/com/aritxonly/teak/common/search/TeakSearchTransition.kt +++ b/TeakUI/src/main/kotlin/com/aritxonly/teak/common/search/TeakSearchTransition.kt @@ -117,8 +117,9 @@ fun Modifier.teakSearchUnderlay( val a = alpha.coerceIn(0f, 1f) val b = blurProgress.coerceIn(0f, 1f) if (p <= 0.001f && a >= 0.999f) return this - // 完全展开 / 已经全透明:不需要位移和模糊,只保留透明度,省掉每帧一遍全屏模糊。 - if (p >= 0.999f || a <= 0.001f) return this.graphicsLayer { this.alpha = a } + // 只按「是否完全不可见」跳过:退出动画开头 p 还停在 1、alpha 已经回升, + // 这里如果因为 p>=0.999 就省掉模糊,会出现"先清晰一下再变模糊"的跳变。 + if (a <= 0.001f) return this.graphicsLayer { this.alpha = a } val density = LocalDensity.current val liftPx = with(density) { TeakSearchTransitionDefaults.ContentLift.toPx() } val blurRadius = with(density) { diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/discover/application/DiscoverStateHolders.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/discover/application/DiscoverStateHolders.kt index f8695cbe..73733233 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/feature/discover/application/DiscoverStateHolders.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/discover/application/DiscoverStateHolders.kt @@ -17,6 +17,8 @@ import com.aritxonly.bdfun.data.BiliApi * 刷新/加载失败只写到 error,不改动已有列表,界面按「空态 + 重试」处理。 */ internal class LivePageStateHolder(private val api: BiliApi) { + /** 与推荐页一样由 shell 的 TrackTeakPageScroll 接管,驱动顶栏渐进模糊与玻璃。 */ + val gridState = LazyGridState() var following by mutableStateOf>(emptyList()) private set var areas by mutableStateOf>(emptyList()) @@ -128,6 +130,7 @@ internal class LivePageStateHolder(private val api: BiliApi) { /** 首页「番剧」段的状态:最近追番 + 推荐。 */ internal class BangumiPageStateHolder(private val api: BiliApi) { + val gridState = LazyGridState() var following by mutableStateOf>(emptyList()) private set var cards by mutableStateOf>(emptyList()) @@ -206,8 +209,4 @@ internal class BangumiPageStateHolder(private val api: BiliApi) { } } -/** 两个页面共用的网格滚动状态缓存,避免切段/返回时丢位置。 */ -internal object DiscoverGridStates { - var live: LazyGridState? = null - var bangumi: LazyGridState? = null -} +/** 两个页面共用的网格滚动状态由各自的 StateHolder 持有(见 gridState)。 */ diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt index e472df06..58696e49 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt @@ -8,7 +8,7 @@ internal object CommentTypography { val bodyLineHeight = 21.8.sp /** 折叠回复:正文比主评论小 1sp,用户名比折叠正文大 1sp(即回到主评论字号)。 */ - val previewBodySize = bodySize - 1.sp - val previewLineHeight = bodyLineHeight - 1.sp - val previewNameSize = previewBodySize + 1.sp + val previewBodySize = (bodySize.value - 1).sp + val previewLineHeight = (bodyLineHeight.value - 1).sp + val previewNameSize = (previewBodySize.value + 1).sp } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt index c4382bf2..49759ab5 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt @@ -53,7 +53,7 @@ internal fun PhotoViewerOverlay( Box(Modifier.fillMaxSize().photoViewerDragGestures(view, scope, onDismiss)) { Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = backgroundAlpha))) - PhotoViewerPager(request, view) + PhotoViewerPager(request, view, onDismiss) PhotoViewerChrome(request, view, onDismiss) } } @@ -105,13 +105,27 @@ private fun PhotoViewerTopBar(onDismiss: () -> Unit) { } @Composable -private fun PhotoViewerPager(request: PhotoViewerRequest, view: PhotoViewerTransitionState) { +private fun PhotoViewerPager( + request: PhotoViewerRequest, + view: PhotoViewerTransitionState, + onDismiss: () -> Unit, +) { val pager = rememberPagerState( initialPage = request.index.coerceIn(0, request.images.lastIndex), pageCount = { request.images.size }, ) HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page -> - PhotoViewerPage(url = request.images[page], request = request, view = view) + // 起点矩形只属于点开的那一张:滑到别的图后不能再借用它的位置, + // 否则退出时会往第一张缩略图飞。 + val isSource = page == request.index + PhotoViewerPage( + url = request.images[page], + request = request, + view = view, + origin = if (isSource) request.origin else null, + originRadiusPx = if (isSource) request.originRadiusPx else 0f, + onDismiss = onDismiss, + ) } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPage.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPage.kt index 473068af..b04959e7 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPage.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPage.kt @@ -42,6 +42,10 @@ internal fun PhotoViewerPage( url: String, request: PhotoViewerRequest, view: PhotoViewerTransitionState, + /** 只有从缩略图打开的那一页才有起点矩形;翻到别的图片后不再借用它的位置。 */ + origin: androidx.compose.ui.geometry.Rect?, + originRadiusPx: Float, + onDismiss: () -> Unit, ) { val page = remember(url) { PhotoViewerPageState() } LaunchedEffect(page.zoom) { view.zoom = page.zoom } @@ -51,7 +55,6 @@ internal fun PhotoViewerPage( val screenHeight = view.size.height.toFloat() if (screenWidth <= 0f || screenHeight <= 0f) return - val origin = request.origin val morph = photoMorphFrame( progress = view.progress.value, originX = origin?.left ?: 0f, @@ -61,7 +64,7 @@ internal fun PhotoViewerPage( screenWidth = screenWidth, screenHeight = screenHeight, imageAspect = page.aspect, - originRadius = request.originRadiusPx, + originRadius = originRadiusPx, ) // 拖动位移随进度衰减:进度归零时正好落在缩略图上,不会带着偏移落地再跳回去。 val dragY = view.dragY * view.progress.value @@ -73,9 +76,22 @@ internal fun PhotoViewerPage( Box( modifier = Modifier .fillMaxSize() - .pointerInput(Unit) { + .pointerInput(morph.clipLeft, morph.clipTop, morph.clipWidth, morph.clipHeight) { detectTapGestures( - onTap = { view.chromeVisible = !view.chromeVisible }, + // 点图片本体还是原来的「显隐 chrome」;点图片外的空白等同返回键, + // 不再落给 Pager 或拖动判定。 + onTap = { tap -> + val insideImage = view.progress.value > 0.99f && + tap.x >= morph.clipLeft && + tap.x <= morph.clipLeft + morph.clipWidth && + tap.y >= morph.clipTop && + tap.y <= morph.clipTop + morph.clipHeight + if (insideImage) { + view.chromeVisible = !view.chromeVisible + } else { + onDismiss() + } + }, onDoubleTap = { tap -> page.zoom = toggleZoom(page.zoom, tap, view.size) }, ) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageItems.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageItems.kt new file mode 100644 index 00000000..5bc25127 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageItems.kt @@ -0,0 +1,181 @@ +package com.aritxonly.bdfun.presentation.discover + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.aritxonly.bdfun.BrandPink +import com.aritxonly.bdfun.core.model.pgc.BangumiCard +import com.aritxonly.bdfun.feature.discover.application.BangumiPageStateHolder +import com.aritxonly.bdfun.ui.FeedCardShape +import com.aritxonly.bdfun.ui.TeakLoading +import com.aritxonly.bdfun.ui.rememberImageRequest +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** 第一行:最近追番,横向长封面。 */ +internal fun LazyGridScope.bangumiFollowSection( + state: BangumiPageStateHolder, + context: android.content.Context, +) { + if (state.following.isEmpty()) return + item(key = "pgc-follow", span = { GridItemSpan(maxLineSpan) }) { + Column { + DiscoverSectionTitle("最近追番") + LazyRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(bottom = 2.dp), + ) { + items(state.following, key = { it.seasonId }) { item -> + Box(Modifier.width(116.dp)) { + BangumiCardItem(item) { openBangumi(context, item.seasonId) } + } + } + } + Spacer(Modifier.height(12.dp)) + } + } +} + +internal fun LazyGridScope.bangumiPageStateItems( + state: BangumiPageStateHolder, + scope: kotlinx.coroutines.CoroutineScope, +) { + if (state.cards.isEmpty() && state.loading) { + item(key = "pgc-loading", span = { GridItemSpan(maxLineSpan) }) { + Box(Modifier.fillMaxWidth().padding(vertical = 60.dp), contentAlignment = Alignment.Center) { + TeakLoading(size = 40.dp) + } + } + } + state.error?.let { message -> + if (state.cards.isNotEmpty()) return@let + item(key = "pgc-error", span = { GridItemSpan(maxLineSpan) }) { + DiscoverErrorState(message) { + state.retry() + scope.launch { state.refresh() } + } + } + } +} + +/** 番剧卡片:3:4 长封面,「xx万追番」在封面左下,UP 主位置换成「全xx话」。 */ +@Composable +internal fun BangumiCardItem(item: BangumiCard, onClick: () -> Unit) { + val colors = MiuixTheme.colorScheme + Column( + modifier = Modifier + .clip(FeedCardShape) + .background(colors.surfaceContainer) + .clickable(interactionSource = null, indication = null, onClick = onClick), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(3f / 4f) + .background(colors.surfaceContainerHigh), + ) { + AsyncImage( + model = rememberImageRequest(item.cover), + contentDescription = item.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + Box( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(42.dp) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color(0x4D000000)))), + ) + if (item.followText.isNotBlank()) { + Text( + text = item.followText, + color = Color.White, + fontSize = 10.sp, + lineHeight = 11.sp, + modifier = Modifier.align(Alignment.BottomStart).padding(start = 6.dp, bottom = 5.dp), + ) + } + if (item.vip) BangumiVipBadge() + } + Column(Modifier.padding(start = 8.dp, end = 8.dp, top = 7.dp, bottom = 8.dp)) { + Text( + text = item.title, + color = colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSize = 13.5.sp, + lineHeight = 18.sp, + modifier = Modifier.heightIn(min = 36.dp), + ) + if (item.indexShow.isNotBlank()) { + Spacer(Modifier.height(5.dp)) + Text( + text = item.indexShow, + color = colors.onBackgroundVariant, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +/** 与个人主页完全同款:品牌粉胶囊 + 白字。 */ +@Composable +private fun androidx.compose.foundation.layout.BoxScope.BangumiVipBadge() { + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(6.dp) + .clip(CircleShape) + .background(BrandPink) + .padding(horizontal = 5.dp, vertical = 1.dp), + ) { + Text("大会员", color = Color.White, fontSize = 8.5.sp, lineHeight = 11.sp, maxLines = 1) + } +} + +internal fun openBangumi(context: android.content.Context, seasonId: Long) { + if (seasonId <= 0L) return + runCatching { + context.startActivity( + android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse("https://www.bilibili.com/bangumi/play/ss$seasonId"), + ), + ) + }.onFailure { + android.widget.Toast.makeText(context, "无法打开番剧", android.widget.Toast.LENGTH_SHORT).show() + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageScreen.kt index b05c5194..d2c743fd 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageScreen.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageScreen.kt @@ -1,308 +1,111 @@ package com.aritxonly.bdfun.presentation.discover -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable 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 -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import com.aritxonly.bdfun.BrandPink -import com.aritxonly.bdfun.core.model.pgc.BangumiCard import com.aritxonly.bdfun.feature.discover.application.BangumiPageStateHolder -import com.aritxonly.bdfun.feature.discover.application.DiscoverGridStates -import com.aritxonly.bdfun.ui.FeedCardShape -import com.aritxonly.bdfun.ui.HomePullIndicator import com.aritxonly.bdfun.ui.HomeTopBarHeight -import com.aritxonly.bdfun.ui.TeakLoading -import com.aritxonly.bdfun.ui.rememberImageRequest -import com.aritxonly.teak.common.chrome.TeakGlass -import com.kyant.shapes.Capsule import kotlinx.coroutines.launch -import top.yukonga.miuix.kmp.theme.MiuixTheme +import kotlin.math.roundToInt /** - * 首页第三段「番剧」。 - * 第一行:最近追番(横向长封面卡片); - * 下面:推荐(两列长封面卡片)。封面 3:4,统计行是「xx万追番」, - * UP 主位置换成「全xx话」,需要大会员的右上角挂与个人主页同款角标。 + * 首页第三段「番剧」。外壳与推荐页一致(顶栏渐进模糊 + 下滑玻璃 + App 层下拉小电视), + * 内容为「最近追番」横排 + 竖长封面推荐网格。 */ @Composable internal fun BangumiPage(state: BangumiPageStateHolder) { val context = LocalContext.current - val density = LocalDensity.current val scope = rememberCoroutineScope() val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val freshGridState = rememberLazyGridState() - val gridState = remember(freshGridState) { - DiscoverGridStates.bangumi ?: freshGridState.also { DiscoverGridStates.bangumi = it } + val gridState = state.gridState + val pullUi = remember { DiscoverPullUi() } + val pull = rememberBangumiPullSession(state, pullUi, scope) + BangumiPageEffects(state, pull, pullUi) + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + state = gridState, + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(0, pullUi.offset.roundToInt()) } + .nestedScroll(pull.connection()), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + top = HomeTopBarHeight + statusBarTop + 6.dp, + bottom = 104.dp, + ), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + bangumiFollowSection(state, context) + items(state.cards, key = { it.seasonId }) { item -> + BangumiCardItem(item) { openBangumi(context, item.seasonId) } + } + bangumiPageStateItems(state, scope) } - var pullOffset by remember { mutableFloatStateOf(0f) } - val thresholdPx = with(density) { 88.dp.toPx() } - val pull = remember(state) { +} + +@Composable +private fun rememberBangumiPullSession( + state: BangumiPageStateHolder, + ui: DiscoverPullUi, + scope: kotlinx.coroutines.CoroutineScope, +): DiscoverPullSession { + val density = LocalDensity.current + val threshold = with(density) { 88.dp.toPx() } + return remember(state) { DiscoverPullSession( density = density, - thresholdPx = thresholdPx, + thresholdPx = threshold, maxPullPx = with(density) { 260.dp.toPx() }, - currentOffset = { pullOffset }, - onPull = { pullOffset = it; state.pullFraction = (it / thresholdPx).coerceIn(0f, 1f) }, + currentOffset = { ui.offset }, + onPull = { + ui.offset = it + state.pullFraction = (it / threshold).coerceIn(0f, 1f) + }, onPullDp = { state.pullOffsetDp = it }, - canScrollBackward = { !gridState.canScrollBackward }, + canScrollBackward = { !state.gridState.canScrollBackward }, isRefreshing = { state.refreshing }, onRefresh = { scope.launch { state.refresh() } }, ) } - val refreshConnection = remember(state) { pull.connection() } +} +@Composable +private fun BangumiPageEffects(state: BangumiPageStateHolder, pull: DiscoverPullSession, ui: DiscoverPullUi) { LaunchedEffect(state) { state.ensureLoaded() } LaunchedEffect(state.refreshing) { - if (!state.refreshing && pullOffset > 0f) { + if (!state.refreshing && ui.offset > 0f) { state.pullFraction = 0f kotlinx.coroutines.delay(160) pull.animatePullTo(0f, 260f) } } LaunchedEffect(state) { - snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } + snapshotFlow { state.gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } .collect { last -> - val total = gridState.layoutInfo.totalItemsCount + val total = state.gridState.layoutInfo.totalItemsCount if (state.cards.isNotEmpty() && total > 0 && last >= total - 2) state.loadMore() } } - - Box(modifier = Modifier.fillMaxSize()) { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - state = gridState, - modifier = Modifier - .fillMaxSize() - .offset { IntOffset(0, pullOffset.toInt()) } - .nestedScroll(refreshConnection), - contentPadding = PaddingValues( - start = 12.dp, - end = 12.dp, - top = HomeTopBarHeight + statusBarTop + 6.dp, - bottom = 104.dp, - ), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (state.following.isNotEmpty()) { - item(key = "pgc-follow", span = { GridItemSpan(maxLineSpan) }) { - Column { - Text( - text = "最近追番", - color = MiuixTheme.colorScheme.onBackground, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 2.dp, bottom = 8.dp), - ) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = PaddingValues(bottom = 2.dp), - ) { - items(state.following, key = { it.seasonId }) { item -> - Box(modifier = Modifier.width(116.dp)) { - BangumiCardItem(item) { openBangumi(context, item.seasonId) } - } - } - } - Spacer(Modifier.height(12.dp)) - } - } - } - - items(state.cards, key = { it.seasonId }) { item -> - BangumiCardItem(item) { openBangumi(context, item.seasonId) } - } - - if (state.cards.isEmpty() && state.loading) { - item(key = "pgc-loading", span = { GridItemSpan(maxLineSpan) }) { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 60.dp), - contentAlignment = Alignment.Center, - ) { TeakLoading(size = 40.dp) } - } - } - - state.error?.let { message -> - if (state.cards.isEmpty()) { - item(key = "pgc-error", span = { GridItemSpan(maxLineSpan) }) { - Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 60.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(message, color = MiuixTheme.colorScheme.onBackgroundVariant, fontSize = 14.sp) - Spacer(Modifier.height(10.dp)) - TeakGlass( - modifier = Modifier.height(34.dp), - shape = Capsule(), - onClick = { - state.retry() - scope.launch { state.refresh() } - }, - ) { - Text( - text = "重试", - color = MiuixTheme.colorScheme.primary, - fontSize = 13.sp, - modifier = Modifier.padding(horizontal = 18.dp).align(Alignment.Center), - ) - } - } - } - } - } - } - - HomePullIndicator( - reveal = if (state.refreshing) 1f else ((state.pullOffsetDp - 58f) / 26f).coerceIn(0f, 1f), - animated = state.refreshing, - modifier = Modifier - .align(Alignment.TopCenter) - .statusBarsPadding() - .padding(top = HomeTopBarHeight + 4.dp), - ) - } -} - -@Composable -private fun BangumiCardItem(item: BangumiCard, onClick: () -> Unit) { - val colors = MiuixTheme.colorScheme - Column( - modifier = Modifier - .clip(FeedCardShape) - .background(colors.surfaceContainer) - .clickable(interactionSource = null, indication = null, onClick = onClick), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(3f / 4f) - .background(colors.surfaceContainerHigh), - ) { - AsyncImage( - model = rememberImageRequest(item.cover), - contentDescription = item.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(42.dp) - .background(Brush.verticalGradient(listOf(Color.Transparent, Color(0x4D000000)))), - ) - if (item.followText.isNotBlank()) { - Text( - text = item.followText, - color = Color.White, - fontSize = 10.sp, - lineHeight = 11.sp, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = 6.dp, bottom = 5.dp), - ) - } - if (item.vip) { - // 与个人主页「大会员」完全同款:品牌粉胶囊 + 白字。 - Box( - modifier = Modifier - .align(Alignment.TopEnd) - .padding(6.dp) - .clip(CircleShape) - .background(BrandPink) - .padding(horizontal = 5.dp, vertical = 1.dp), - ) { - Text( - text = "大会员", - color = Color.White, - fontSize = 8.5.sp, - lineHeight = 11.sp, - maxLines = 1, - ) - } - } - } - Column(modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 7.dp, bottom = 8.dp)) { - Text( - text = item.title, - color = colors.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - fontSize = 13.5.sp, - lineHeight = 18.sp, - modifier = Modifier.heightIn(min = 36.dp), - ) - if (item.indexShow.isNotBlank()) { - Spacer(Modifier.height(5.dp)) - Text( - text = item.indexShow, - color = colors.onBackgroundVariant, - fontSize = 11.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - -private fun openBangumi(context: android.content.Context, seasonId: Long) { - if (seasonId <= 0L) return - runCatching { - context.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse("https://www.bilibili.com/bangumi/play/ss$seasonId"), - ), - ) - }.onFailure { android.widget.Toast.makeText(context, "无法打开番剧", android.widget.Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageItems.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageItems.kt new file mode 100644 index 00000000..bc2bf0d2 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageItems.kt @@ -0,0 +1,266 @@ +package com.aritxonly.bdfun.presentation.discover + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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 +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.aritxonly.bdfun.core.model.feed.FeedLiveRecommendation +import com.aritxonly.bdfun.core.model.live.LiveArea +import com.aritxonly.bdfun.core.model.live.LiveCard +import com.aritxonly.bdfun.feature.discover.application.LivePageStateHolder +import com.aritxonly.bdfun.navigateToLogin +import com.aritxonly.bdfun.ui.FeedCardShape +import com.aritxonly.bdfun.ui.FeedLiveCard +import com.aritxonly.bdfun.ui.TeakLoading +import com.aritxonly.bdfun.ui.rememberImageRequest +import com.aritxonly.teak.common.chrome.TeakGlass +import com.kyant.shapes.Capsule +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** 第一行:关注的正在直播,直接复用动态页那个圆角直播卡片。 */ +internal fun LazyGridScope.liveFollowSection(state: LivePageStateHolder) { + if (state.following.isEmpty()) return + item(key = "live-follow", span = { GridItemSpan(maxLineSpan) }) { + Column { + DiscoverSectionTitle("关注的直播") + LazyRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(bottom = 2.dp), + ) { + items(state.following, key = { it.roomId }) { live -> + Box(modifier = Modifier.width(250.dp)) { + FeedLiveCard(live.toFeedLive()) + } + } + } + } + } +} + +/** 第二行:分区胶囊,收藏夹列表同款(选中没有高光描边)。 */ +internal fun LazyGridScope.liveAreaSection( + state: LivePageStateHolder, + scope: kotlinx.coroutines.CoroutineScope, +) { + if (state.areas.isEmpty()) return + item(key = "live-areas", span = { GridItemSpan(maxLineSpan) }) { + Column { + Spacer(Modifier.height(if (state.following.isEmpty()) 0.dp else 12.dp)) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(vertical = 6.dp), + ) { + item(key = "area-all") { + LiveAreaChip( + text = "推荐", + selected = state.selectedKey == 0L, + onClick = { scope.launch { state.selectArea(LiveArea(0L, "推荐")) } }, + ) + } + items(state.areas, key = { "${it.parentId}-${it.id}" }) { area -> + LiveAreaChip( + text = area.name, + selected = state.selectedKey == area.id, + onClick = { scope.launch { state.selectArea(area) } }, + ) + } + } + } + } +} + +/** 网格里的加载 / 空态 / 未登录提示。 */ +internal fun LazyGridScope.livePageStateItems( + state: LivePageStateHolder, + scope: kotlinx.coroutines.CoroutineScope, + loggedIn: Boolean, +) { + if (state.cards.isEmpty() && state.loading) { + item(key = "live-loading", span = { GridItemSpan(maxLineSpan) }) { + Box(Modifier.fillMaxWidth().padding(vertical = 60.dp), contentAlignment = Alignment.Center) { + TeakLoading(size = 40.dp) + } + } + } + state.error?.let { message -> + if (state.cards.isNotEmpty()) return@let + item(key = "live-error", span = { GridItemSpan(maxLineSpan) }) { + DiscoverErrorState(message) { + state.retry() + scope.launch { state.refresh() } + } + } + } + if (!loggedIn && state.following.isEmpty()) { + item(key = "live-login", span = { GridItemSpan(maxLineSpan) }) { + val context = LocalContext.current + Box( + modifier = Modifier + .fillMaxWidth() + .clip(FeedCardShape) + .background(MiuixTheme.colorScheme.surfaceContainer) + .clickable(interactionSource = null, indication = null) { + navigateToLogin(context) + } + .padding(16.dp), + ) { + Text( + "登录后在这里看到关注的直播", + color = MiuixTheme.colorScheme.onBackgroundVariant, + fontSize = 13.sp, + ) + } + } + } +} + +@Composable +private fun LiveAreaChip(text: String, selected: Boolean, onClick: () -> Unit) { + val colors = MiuixTheme.colorScheme + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(if (selected) colors.primary else colors.surfaceContainer) + .clickable(interactionSource = null, indication = null, onClick = onClick) + .padding(horizontal = 14.dp, vertical = 6.dp), + ) { + Text( + text = text, + color = if (selected) Color.White else colors.onBackground, + fontSize = 12.sp, + maxLines = 1, + ) + } +} + +/** 直播卡片:结构同首页视频卡片,统计行换成「xx人看过」+ 直播内容。 */ +@Composable +internal fun LiveGridCard(live: LiveCard, onClick: () -> Unit) { + val colors = MiuixTheme.colorScheme + Column( + modifier = Modifier + .clip(FeedCardShape) + .background(colors.surfaceContainer) + .clickable(interactionSource = null, indication = null, onClick = onClick), + ) { + Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f).background(colors.surfaceContainerHigh)) { + AsyncImage( + model = rememberImageRequest(live.cover), + contentDescription = live.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + Box( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(42.dp) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color(0x4D000000)))), + ) + Row( + modifier = Modifier + .align(Alignment.BottomStart) + .fillMaxWidth() + .padding(start = 6.dp, end = 6.dp, bottom = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (live.watchedText.isNotBlank()) { + Text(live.watchedText, color = Color.White, fontSize = 10.sp, lineHeight = 11.sp) + } + Spacer(Modifier.weight(1f)) + if (live.areaName.isNotBlank()) { + Text(live.areaName, color = Color.White, fontSize = 10.sp, lineHeight = 11.sp) + } + } + } + Column(Modifier.padding(start = 8.dp, end = 8.dp, top = 7.dp, bottom = 8.dp)) { + Text( + text = live.title, + color = colors.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSize = 13.5.sp, + lineHeight = 18.sp, + modifier = Modifier.heightIn(min = 36.dp), + ) + Spacer(Modifier.height(5.dp)) + Text( + text = live.uname, + color = colors.onBackgroundVariant, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +internal fun DiscoverSectionTitle(text: String) { + Text( + text = text, + color = MiuixTheme.colorScheme.onBackground, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 2.dp, bottom = 8.dp), + ) +} + +@Composable +internal fun DiscoverErrorState(message: String, onRetry: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(message, color = MiuixTheme.colorScheme.onBackgroundVariant, fontSize = 14.sp) + Spacer(Modifier.height(10.dp)) + TeakGlass(modifier = Modifier.height(34.dp), shape = Capsule(), onClick = onRetry) { + Text( + text = "重试", + color = MiuixTheme.colorScheme.primary, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 18.dp).align(Alignment.Center), + ) + } + } +} + +private fun LiveCard.toFeedLive() = FeedLiveRecommendation( + roomId = roomId, + title = title, + cover = cover, + live = true, + areaName = areaName, + watched = watchedText, +) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageScreen.kt index 5bf13fbb..17db5b07 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageScreen.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageScreen.kt @@ -1,113 +1,105 @@ package com.aritxonly.bdfun.presentation.discover -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable 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 -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.statusBarsPadding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import coil.compose.AsyncImage -import com.aritxonly.bdfun.core.model.feed.FeedLiveRecommendation -import com.aritxonly.bdfun.core.model.live.LiveArea -import com.aritxonly.bdfun.core.model.live.LiveCard -import com.aritxonly.bdfun.feature.discover.application.DiscoverGridStates import com.aritxonly.bdfun.feature.discover.application.LivePageStateHolder import com.aritxonly.bdfun.navigateToLiveRoom -import com.aritxonly.bdfun.navigateToLogin -import com.aritxonly.bdfun.ui.FeedCardShape -import com.aritxonly.bdfun.ui.FeedLiveCard -import com.aritxonly.bdfun.ui.HomePullIndicator import com.aritxonly.bdfun.ui.HomeTopBarHeight -import com.aritxonly.bdfun.ui.TeakLoading -import com.aritxonly.bdfun.ui.rememberImageRequest -import com.aritxonly.bdfun.ui.showToast -import com.aritxonly.teak.common.chrome.TeakGlass -import com.kyant.shapes.Capsule import kotlinx.coroutines.launch -import top.yukonga.miuix.kmp.theme.MiuixTheme +import kotlin.math.roundToInt /** * 首页第一段「直播」。 - * 第一行:关注的正在直播(与动态页首个圆角矩形同构); - * 第二行:分区 chips(收藏夹列表同款,去掉高光描边); - * 下面:分区直播流卡片(看过人数 + 直播内容)。 + * 与推荐页共用同一套外壳:TrackTeakPageScroll 驱动顶栏渐进模糊/玻璃, + * 下拉刷新在 App 层画小电视,本页只负责内容与手势。 */ @Composable internal fun LivePage(state: LivePageStateHolder, loggedIn: Boolean) { val context = LocalContext.current - val density = LocalDensity.current val scope = rememberCoroutineScope() val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val freshGridState = rememberLazyGridState() - val gridState = remember(freshGridState) { - DiscoverGridStates.live ?: freshGridState.also { DiscoverGridStates.live = it } + val gridState = state.gridState + val pullUi = remember { DiscoverPullUi() } + val pull = rememberLivePullSession(state, pullUi, scope) + LivePageEffects(state, pull, pullUi) + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + state = gridState, + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(0, pullUi.offset.roundToInt()) } + .nestedScroll(pull.connection()), + contentPadding = PaddingValues( + start = 12.dp, + end = 12.dp, + top = HomeTopBarHeight + statusBarTop + 6.dp, + bottom = 104.dp, + ), + horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + liveFollowSection(state) + liveAreaSection(state, scope) + items(state.cards, key = { it.roomId }) { live -> + LiveGridCard(live) { navigateToLiveRoom(context, live.roomId) } + } + livePageStateItems(state, scope, loggedIn) } - var pullOffset by remember { mutableFloatStateOf(0f) } - val thresholdPx = with(density) { 88.dp.toPx() } - val pull = remember(state) { +} + +@Composable +private fun rememberLivePullSession( + state: LivePageStateHolder, + ui: DiscoverPullUi, + scope: kotlinx.coroutines.CoroutineScope, +): DiscoverPullSession { + val density = LocalDensity.current + val threshold = with(density) { 88.dp.toPx() } + return remember(state) { DiscoverPullSession( density = density, - thresholdPx = thresholdPx, + thresholdPx = threshold, maxPullPx = with(density) { 260.dp.toPx() }, - currentOffset = { pullOffset }, - onPull = { pullOffset = it; state.pullFraction = (it / thresholdPx).coerceIn(0f, 1f) }, + currentOffset = { ui.offset }, + onPull = { + ui.offset = it + state.pullFraction = (it / threshold).coerceIn(0f, 1f) + }, onPullDp = { state.pullOffsetDp = it }, - canScrollBackward = { !gridState.canScrollBackward }, + canScrollBackward = { !state.gridState.canScrollBackward }, isRefreshing = { state.refreshing }, onRefresh = { scope.launch { state.refresh() } }, ) } - val refreshConnection = remember(state) { pull.connection() } +} +@Composable +private fun LivePageEffects(state: LivePageStateHolder, pull: DiscoverPullSession, ui: DiscoverPullUi) { LaunchedEffect(state) { state.ensureLoaded() } // 刷新结束:先收小电视,再让内容平滑回顶(与推荐页同一条时序)。 LaunchedEffect(state.refreshing) { - if (!state.refreshing && pullOffset > 0f) { + if (!state.refreshing && ui.offset > 0f) { state.pullFraction = 0f kotlinx.coroutines.delay(160) pull.animatePullTo(0f, 260f) @@ -115,246 +107,10 @@ internal fun LivePage(state: LivePageStateHolder, loggedIn: Boolean) { } // 滚到底部预取下一页 LaunchedEffect(state) { - snapshotFlow { gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } + snapshotFlow { state.gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } .collect { last -> - val total = gridState.layoutInfo.totalItemsCount + val total = state.gridState.layoutInfo.totalItemsCount if (state.cards.isNotEmpty() && total > 0 && last >= total - 2) state.loadMore() } } - - Box(modifier = Modifier.fillMaxSize()) { - LazyVerticalGrid( - columns = GridCells.Fixed(2), - state = gridState, - modifier = Modifier - .fillMaxSize() - .offset { IntOffset(0, pullOffset.toInt()) } - .nestedScrollStrict(refreshConnection), - contentPadding = PaddingValues( - start = 12.dp, - end = 12.dp, - top = HomeTopBarHeight + statusBarTop + 6.dp, - bottom = 104.dp, - ), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - if (state.following.isNotEmpty()) { - item(key = "live-follow", span = { GridItemSpan(maxLineSpan) }) { - Column { - SectionTitle("关注的直播") - LazyRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = PaddingValues(bottom = 2.dp), - ) { - items(state.following, key = { it.roomId }) { live -> - Box(modifier = Modifier.width(250.dp)) { - FeedLiveCard(live.toFeedLive()) - } - } - } - } - } - } - - if (state.areas.isNotEmpty()) { - item(key = "live-areas", span = { GridItemSpan(maxLineSpan) }) { - Column { - Spacer(Modifier.height(if (state.following.isEmpty()) 0.dp else 12.dp)) - LazyRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(vertical = 6.dp), - ) { - item(key = "area-all") { - LiveAreaChip( - text = "推荐", - selected = state.selectedKey == 0L, - onClick = { scope.launch { state.selectArea(LiveArea(0L, "推荐")) } }, - ) - } - items(state.areas, key = { "${it.parentId}-${it.id}" }) { area -> - LiveAreaChip( - text = area.name, - selected = state.selectedKey == area.id, - onClick = { scope.launch { state.selectArea(area) } }, - ) - } - } - } - } - } - - items(state.cards, key = { it.roomId }) { live -> - LiveGridCard(live) { navigateToLiveRoom(context, live.roomId) } - } - - if (state.cards.isEmpty() && state.loading) { - item(key = "live-loading", span = { GridItemSpan(maxLineSpan) }) { - Box( - modifier = Modifier.fillMaxWidth().padding(vertical = 60.dp), - contentAlignment = Alignment.Center, - ) { TeakLoading(size = 40.dp) } - } - } - - state.error?.let { message -> - if (state.cards.isEmpty()) { - item(key = "live-error", span = { GridItemSpan(maxLineSpan) }) { - Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 60.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(message, color = MiuixTheme.colorScheme.onBackgroundVariant, fontSize = 14.sp) - Spacer(Modifier.height(10.dp)) - TeakGlass( - modifier = Modifier.height(34.dp), - shape = Capsule(), - onClick = { - state.retry() - scope.launch { state.refresh() } - }, - ) { - Text( - text = "重试", - color = MiuixTheme.colorScheme.primary, - fontSize = 13.sp, - modifier = Modifier.padding(horizontal = 18.dp).align(Alignment.Center), - ) - } - } - } - } - } - - if (!loggedIn && state.following.isEmpty()) { - item(key = "live-login", span = { GridItemSpan(maxLineSpan) }) { - Box( - modifier = Modifier - .fillMaxWidth() - .clip(FeedCardShape) - .background(MiuixTheme.colorScheme.surfaceContainer) - .clickable(interactionSource = null, indication = null) { - navigateToLogin(context) - } - .padding(16.dp), - ) { - Text("登录后在这里看到关注的直播", color = MiuixTheme.colorScheme.onBackgroundVariant, fontSize = 13.sp) - } - } - } - } - - HomePullIndicator( - reveal = if (state.refreshing) 1f else ((state.pullOffsetDp - 58f) / 26f).coerceIn(0f, 1f), - animated = state.refreshing, - modifier = Modifier - .align(Alignment.TopCenter) - .statusBarsPadding() - .padding(top = HomeTopBarHeight + 4.dp), - ) - } -} - -private fun Modifier.nestedScrollStrict(connection: androidx.compose.ui.input.nestedscroll.NestedScrollConnection): Modifier = - this.nestedScroll(connection) - -@Composable -private fun SectionTitle(text: String) { - Text( - text = text, - color = MiuixTheme.colorScheme.onBackground, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 2.dp, bottom = 8.dp), - ) -} - -@Composable -private fun LiveAreaChip(text: String, selected: Boolean, onClick: () -> Unit) { - val colors = MiuixTheme.colorScheme - Box( - modifier = Modifier - .clip(RoundedCornerShape(50)) - .background(if (selected) colors.primary else colors.surfaceContainer) - .clickable(interactionSource = null, indication = null, onClick = onClick) - .padding(horizontal = 14.dp, vertical = 6.dp), - ) { - Text( - text = text, - color = if (selected) Color.White else colors.onBackground, - fontSize = 12.sp, - maxLines = 1, - ) - } -} - -/** 直播卡片:结构同首页视频卡片,统计行换成「xx人看过」+ 直播内容。 */ -@Composable -private fun LiveGridCard(live: LiveCard, onClick: () -> Unit) { - val colors = MiuixTheme.colorScheme - Column( - modifier = Modifier - .clip(FeedCardShape) - .background(colors.surfaceContainer) - .clickable(interactionSource = null, indication = null, onClick = onClick), - ) { - Box(modifier = Modifier.fillMaxWidth().aspectRatio(16f / 9f).background(colors.surfaceContainerHigh)) { - AsyncImage( - model = rememberImageRequest(live.cover), - contentDescription = live.title, - contentScale = ContentScale.Crop, - modifier = Modifier.fillMaxSize(), - ) - Box( - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .height(42.dp) - .background(Brush.verticalGradient(listOf(Color.Transparent, Color(0x4D000000)))), - ) - Row( - modifier = Modifier - .align(Alignment.BottomStart) - .fillMaxWidth() - .padding(start = 6.dp, end = 6.dp, bottom = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (live.watchedText.isNotBlank()) { - Text(live.watchedText, color = Color.White, fontSize = 10.sp, lineHeight = 11.sp) - } - Spacer(Modifier.weight(1f)) - if (live.areaName.isNotBlank()) { - Text(live.areaName, color = Color.White, fontSize = 10.sp, lineHeight = 11.sp) - } - } - } - Column(modifier = Modifier.padding(start = 8.dp, end = 8.dp, top = 7.dp, bottom = 8.dp)) { - Text( - text = live.title, - color = colors.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - fontSize = 13.5.sp, - lineHeight = 18.sp, - modifier = Modifier.heightIn(min = 36.dp), - ) - Spacer(Modifier.height(5.dp)) - Text( - text = live.uname, - color = colors.onBackgroundVariant, - fontSize = 11.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } } - -private fun LiveCard.toFeedLive() = FeedLiveRecommendation( - roomId = roomId, - title = title, - cover = cover, - live = true, - areaName = areaName, - watched = watchedText, -) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainHomeChrome.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainHomeChrome.kt index 8a0cde64..3bf63e94 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainHomeChrome.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainHomeChrome.kt @@ -23,6 +23,16 @@ internal fun androidx.compose.foundation.layout.BoxScope.MainHomeChrome(settings val ui = shell.ui val homeFeed = shell.feeds.home val homeChrome = shell.chrome.home + val activeChrome = when (ui.homeSection) { + 1 -> shell.chrome.live + 2 -> shell.chrome.bangumi + else -> homeChrome + } + // 下拉刷新指示器:三段各自一份状态,但都画在 App 层同一个位置。 + val liveState = if (ui.homeSection == 1) AppGraph.livePageState else null + val bangumiState = if (ui.homeSection == 2) AppGraph.bangumiPageState else null + val pullRefreshing = liveState?.refreshing ?: bangumiState?.refreshing ?: homeFeed.refreshing + val pullOffsetDp = liveState?.pullOffsetDp ?: bangumiState?.pullOffsetDp ?: homeFeed.pullOffsetDp val searchChrome = shell.chrome.search val searchBackdrop = shell.backdrops.search // 底栏搜索的一镜到底会把内容整体上移并模糊;顶栏画在变换之外, @@ -48,22 +58,29 @@ internal fun androidx.compose.foundation.layout.BoxScope.MainHomeChrome(settings LocalTopBarScrollProgress provides if (ui.showSearch) { searchChrome.materialProgress } else { - homeChrome.materialProgress + activeChrome.materialProgress }, ) { ProgressiveTopBarMaterial( underlayLiftPx = underlayLiftPx, underlayBlurPx = underlayBlurPx, + // 只把淡出交给材质层。缩放/横移放到内容层:渐进模糊的采样矩形如果跟着 + // 缩到 0.9,边缘会在搜索动画里切出一条清晰/模糊的硬边。 modifier = Modifier.align(Alignment.TopCenter) .graphicsLayer { + alpha = (1f - pageFloat).coerceIn(0f, 1f) * (1f - shell.bottom.bottomTopBarAlpha.value) + }, + ) { + androidx.compose.foundation.layout.Box( + modifier = Modifier.graphicsLayer { val searchScale = 1f - 0.1f * shell.bottom.bottomSearchProgress.value scaleX = searchScale scaleY = searchScale translationX = -pageFloat * size.width * 0.92f - alpha = (1f - pageFloat).coerceIn(0f, 1f) * (1f - shell.bottom.bottomTopBarAlpha.value) }, - ) { - MainHomeBarContent(settings, shell) + ) { + MainHomeBarContent(settings, shell) + } } } } @@ -74,15 +91,12 @@ internal fun androidx.compose.foundation.layout.BoxScope.MainHomeChrome(settings // 透明度直接由下拉位移决定:位移 58dp 开始淡入、84dp 全显; // 卡片在位移 <46dp 时才会撞到小电视,因此永远追不上。 HomePullIndicator( - // 直播 / 番剧还没接入下拉刷新,切到这两段时不要把推荐的下拉指示器带过去。 - reveal = if (ui.homeSection != 0) { - 0f - } else if (homeFeed.refreshing) { + reveal = if (pullRefreshing) { 1f } else { - ((homeFeed.pullOffsetDp - 58f) / 26f).coerceIn(0f, 1f) + ((pullOffsetDp - 58f) / 26f).coerceIn(0f, 1f) }, - animated = homeFeed.refreshing, + animated = pullRefreshing, modifier = Modifier .align(Alignment.TopCenter) .statusBarsPadding() diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainShellNavigation.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainShellNavigation.kt index eaddb547..65e97a19 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainShellNavigation.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainShellNavigation.kt @@ -25,7 +25,12 @@ internal fun androidx.compose.foundation.layout.BoxScope.MainShellNavigation(set if (index == selected) { // 再次点击当前页 → 刷新 when (index) { - 0 -> homeFeed.requestRefresh() + // 首页三段各有自己的刷新入口,别让直播/番剧去刷推荐。 + 0 -> when (ui.homeSection) { + 1 -> appScope.launch { AppGraph.livePageState.refresh() } + 2 -> appScope.launch { AppGraph.bangumiPageState.refresh() } + else -> homeFeed.requestRefresh() + } 1 -> ui.dynamicRefreshTick++ } } else { diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainShellState.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainShellState.kt index f1eb1f22..9e045e92 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainShellState.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainShellState.kt @@ -24,6 +24,8 @@ internal data class MainShellNavigation( ) internal data class MainShellChrome( val home: com.aritxonly.teak.common.chrome.TeakPageScrollState, + val live: com.aritxonly.teak.common.chrome.TeakPageScrollState, + val bangumi: com.aritxonly.teak.common.chrome.TeakPageScrollState, val dynamic: com.aritxonly.teak.common.chrome.TeakPageScrollState, val search: com.aritxonly.teak.common.chrome.TeakPageScrollState, val mine: com.aritxonly.teak.common.chrome.TeakPageScrollState, diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainShellStateFactory.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainShellStateFactory.kt index 3662b1a9..697b16b9 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainShellStateFactory.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainShellStateFactory.kt @@ -30,6 +30,8 @@ internal fun rememberMainShellState(background: Color): MainShellState { val tabPagerState = rememberPagerState(pageCount = { destinations.size }) val selected = tabPagerState.currentPage val homeChrome = rememberTeakPageScrollState() + val liveChrome = rememberTeakPageScrollState() + val bangumiChrome = rememberTeakPageScrollState() val dynamicChrome = rememberTeakPageScrollState() val searchChrome = rememberTeakPageScrollState() val mineChrome = rememberTeakPageScrollState() @@ -58,6 +60,9 @@ internal fun rememberMainShellState(background: Color): MainShellState { TrackTeakPageScroll(homeFeed.gridState, homeChrome) TrackTeakPageScroll(dynamicFeed.listState, dynamicChrome) TrackTeakPageScroll(mineList, mineChrome) + // 直播 / 番剧与推荐页同一套:滚动进度驱动顶栏渐进模糊和下滑玻璃。 + TrackTeakPageScroll(AppGraph.livePageState.gridState, liveChrome) + TrackTeakPageScroll(AppGraph.bangumiPageState.gridState, bangumiChrome) // 参考 HyperIsland 的做法:整屏一块 backdrop,录制时先铺一层 surface 再画内容 val backdrop = rememberLayerBackdrop { drawRect(background); drawContent() } // 搜索页自己一块:首页顶栏的渐变模糊和筛选菜单都采样它,复用同一套页面结构。 @@ -79,7 +84,7 @@ internal fun rememberMainShellState(background: Color): MainShellState { val bottom = remember { BottomSearchShellStateHolder(ui, appScope, { searchChrome.reset() }) { AppGraph.settings.animationsEnabled } } return MainShellState( ui, MainShellNavigation(tabPagerState, destinations, tabItems, appScope), - MainShellChrome(homeChrome, dynamicChrome, searchChrome, mineChrome, mineList), + MainShellChrome(homeChrome, liveChrome, bangumiChrome, dynamicChrome, searchChrome, mineChrome, mineList), MainShellFeeds(session, homeFeed, dynamicFeed, unreadMessageCount), MainShellBackdrops(backdrop, searchBackdrop, wideNavigation), searchReveal, bottom, { AppGraph.searchStateHolder() }, diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/text/RichTextDecorations.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/text/RichTextDecorations.kt index 719c982c..1798d668 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/text/RichTextDecorations.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/text/RichTextDecorations.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.PlaceholderVerticalAlign import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.takeOrElse import com.aritxonly.bdfun.core.application.* internal fun AnnotatedString.Builder.appendRichPrefix(runtime: RichTextRenderContext, inline: MutableMap) { diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt index 75f4523d..294b41df 100644 --- a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt @@ -43,25 +43,32 @@ internal suspend fun BiliApiContext.liveFollowing(page: Int = 1): List internal suspend fun BiliApiContext.liveRecommend( page: Int = 1, parentAreaId: Long = 0L, areaId: Long = 0L, ): List = withContext(Dispatchers.IO) { - cancellableCatching { + // 这个 web 接口现在不带 WBI 会直接 -352(风控),和 getDanmuInfo 一样: + // 先签名请求,失败就清一次密钥再签一遍。 + val params = mapOf( + "platform" to "web", + "parent_area_id" to parentAreaId.toString(), + "area_id" to areaId.toString(), + "sort_type" to "online", + "page" to page.toString(), + "page_size" to "20", + ) + suspend fun load(signed: Boolean): List = cancellableCatching { val data = dataRequest( "/xlive/web-interface/v1/second/getList", - mapOf( - "platform" to "web", - "parent_area_id" to parentAreaId.toString(), - "area_id" to areaId.toString(), - "sort_type" to "online", - "page" to page.toString(), - "page_size" to "20", - ), + params, + signed = signed, base = livePageBase, referer = liveReferer, ) val list = data.optJSONArray("list") ?: return@cancellableCatching emptyList() - (0 until list.length()).mapNotNull { i -> - list.optJSONObject(i)?.toLiveCard() - } + (0 until list.length()).mapNotNull { i -> list.optJSONObject(i)?.toLiveCard() } }.getOrDefault(emptyList()) + + load(signed = true).ifEmpty { load(signed = false) }.ifEmpty { + wbiSigner.clear() + load(signed = true) + } } /** 直播分区:web 端 /room/v1/Area/getList,data 本身是数组。parentId=0 取一级分区。 */ diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcApi.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcApi.kt index a5f1d310..665ef57a 100644 --- a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcApi.kt +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcApi.kt @@ -13,11 +13,15 @@ private const val pgcReferer = "https://www.bilibili.com/anime/" */ internal suspend fun BiliApiContext.pgcFollowing(page: Int = 1): List = withContext(Dispatchers.IO) { + // 这个接口必须带自己的 mid;未登录时直接返回空,别把 -101 当成页面错误。 + if (!isLoggedIn || mid <= 0L) return@withContext emptyList() cancellableCatching { val data = dataRequest( "/x/space/bangumi/follow/list", mapOf( + "vmid" to mid.toString(), "type" to "1", + "follow_status" to "0", "pn" to page.toString(), "ps" to "15", ), @@ -76,7 +80,14 @@ private fun JSONObject.toBangumiCard(): BangumiCard? { indexShow = optString("index_show").orEmpty().ifBlank { optJSONObject("new_ep")?.optString("index_show").orEmpty() }, - followText = optString("order").orEmpty().takeIf { it.isNotBlank() }?.let { "$it追番" }.orEmpty(), + // order 已经是「1770.2万追番」这种成品文案,别再拼一次后缀。 + followText = optString("order").orEmpty().let { raw -> + when { + raw.isBlank() -> "" + raw.contains("追番") -> raw + else -> "${raw}追番" + } + }, vip = badge.contains("会员"), ) } From f0eddb7e14568772a537f84dbb1ee5e4cf5b16f8 Mon Sep 17 00:00:00 2001 From: QxTeak <351827445+QxTeak@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:28:53 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(photo):=20=E5=A4=9A=E5=9B=BE=E9=80=80?= =?UTF-8?q?=E5=87=BA=E6=94=B6=E6=8B=A2=E5=88=B0=E5=BD=93=E5=89=8D=E9=A1=B5?= =?UTF-8?q?=E8=87=AA=E5=B7=B1=E7=9A=84=E7=BC=A9=E7=95=A5=E5=9B=BE=E4=BD=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../presentation/comment/images/CommentPictures.kt | 4 ++-- .../presentation/comment/row/CommentRowMessage.kt | 4 ++-- .../presentation/common/photo/PhotoViewerOverlay.kt | 9 ++++----- .../presentation/common/photo/PhotoViewerState.kt | 10 +++++++++- .../com/aritxonly/bdfun/presentation/feed/FeedCard.kt | 3 ++- .../aritxonly/bdfun/presentation/feed/FeedImageGrid.kt | 7 ++++--- .../bdfun/presentation/feed/ForwardedFeedBlock.kt | 3 ++- 7 files changed, 25 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/images/CommentPictures.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/images/CommentPictures.kt index 92e5492b..308882e8 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/images/CommentPictures.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/images/CommentPictures.kt @@ -24,7 +24,7 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme @OptIn(ExperimentalFoundationApi::class) @Composable -internal fun CommentPictures(urls: List, onOpen: (Int, Rect, Float) -> Unit) { +internal fun CommentPictures(urls: List, onOpen: (Int, Rect, Float, List) -> Unit) { val colors = MiuixTheme.colorScheme val density = LocalDensity.current val radiusPx = with(density) { CommentPictureRadius.toPx() } @@ -42,7 +42,7 @@ internal fun CommentPictures(urls: List, onOpen: (Int, Rect, Float) -> U .background(colors.surfaceContainerHigh) .onGloballyPositioned { bounds[index] = it.boundsInRoot() } .clickable(interactionSource = null, indication = null) { - onOpen(index, bounds[index] ?: Rect.Zero, radiusPx) + onOpen(index, bounds[index] ?: Rect.Zero, radiusPx, urls.map { bounds[it] }) }, ) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentRowMessage.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentRowMessage.kt index 6ea82022..891a6bcb 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentRowMessage.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentRowMessage.kt @@ -42,8 +42,8 @@ internal fun CommentRowMessage(item: CommentItem) { ) if (item.pictures.isNotEmpty()) { Spacer(Modifier.height(8.dp)) - CommentPictures(item.pictures) { index, rect, radius -> - photo.open(item.pictures, index, rect, radius) + CommentPictures(item.pictures) { index, rect, radius, origins -> + photo.open(item.pictures, index, rect, radius, origins) } } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt index 49759ab5..19ac70be 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt @@ -115,15 +115,14 @@ private fun PhotoViewerPager( pageCount = { request.images.size }, ) HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page -> - // 起点矩形只属于点开的那一张:滑到别的图后不能再借用它的位置, - // 否则退出时会往第一张缩略图飞。 - val isSource = page == request.index + // 每一页用自己的缩略图矩形收拢:滑到第二张再退出,就落到第二张的位置。 + val origin = request.originFor(page) PhotoViewerPage( url = request.images[page], request = request, view = view, - origin = if (isSource) request.origin else null, - originRadiusPx = if (isSource) request.originRadiusPx else 0f, + origin = origin, + originRadiusPx = if (origin != null) request.originRadiusPx else 0f, onDismiss = onDismiss, ) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerState.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerState.kt index af65db90..775c766e 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerState.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerState.kt @@ -20,9 +20,15 @@ internal data class PhotoViewerRequest( val index: Int, val origin: Rect? = null, val originRadiusPx: Float = 0f, + /** 每张图自己的缩略图矩形(与 images 同序);缺省的那张没有共享元素起点。 */ + val origins: List = emptyList(), /** 底部的操作区由发起方提供,宿主不需要知道这里是动态还是评论。 */ val footer: (@Composable (dismiss: () -> Unit) -> Unit)? = null, -) +) { + /** 当前页的收拢目标:优先用它自己的缩略图,没有才退回点开那张的起点。 */ + fun originFor(page: Int): Rect? = + origins.getOrNull(page) ?: if (page == index) origin else null +} /** * 图片查看器的页面级状态。 @@ -39,6 +45,7 @@ internal class PhotoViewerStateHolder { index: Int, origin: Rect? = null, originRadiusPx: Float = 0f, + origins: List = emptyList(), footer: (@Composable (dismiss: () -> Unit) -> Unit)? = null, ) { if (images.isEmpty()) return @@ -47,6 +54,7 @@ internal class PhotoViewerStateHolder { index = index.coerceIn(0, images.lastIndex), origin = origin, originRadiusPx = originRadiusPx, + origins = origins, footer = footer, ) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedCard.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedCard.kt index f2be9ce2..f24615c4 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedCard.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedCard.kt @@ -47,7 +47,7 @@ internal fun FeedCard( FeedCardText(item, presentation.expanded) if (item.images.isNotEmpty()) { Spacer(Modifier.height(8.dp)) - FeedImageGrid(images = item.images, onOpen = { url, rect, radius -> + FeedImageGrid(images = item.images, onOpen = { url, rect, radius, origins -> // 查看器由页面级宿主统一持有:这样关闭动画能作用在整页上(背后页面缩小), // 卡片只负责把缩略图矩形和圆角交出去。 photo.open( @@ -55,6 +55,7 @@ internal fun FeedCard( index = item.images.indexOf(url), origin = rect, originRadiusPx = radius, + origins = origins, footer = { dismiss -> FeedViewerFooter( item = item, diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedImageGrid.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedImageGrid.kt index 9225f195..1f87d68c 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedImageGrid.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedImageGrid.kt @@ -24,7 +24,8 @@ import top.yukonga.miuix.kmp.theme.MiuixTheme @Composable internal fun FeedImageGrid( images: List, - onOpen: (String, Rect, Float) -> Unit, + /** origins 与 images 同序:查看器靠它把每一页分别收拢回自己的缩略图。 */ + onOpen: (String, Rect, Float, List) -> Unit, ) { if (images.isEmpty()) return val colors = MiuixTheme.colorScheme @@ -45,7 +46,7 @@ internal fun FeedImageGrid( .background(colors.surfaceContainerHigh) .onGloballyPositioned { bounds[url] = it.boundsInRoot() } .clickable(interactionSource = null, indication = null) { - onOpen(url, bounds[url] ?: Rect.Zero, singleRadiusPx) + onOpen(url, bounds[url] ?: Rect.Zero, singleRadiusPx, images.map { bounds[it] }) }, ) return @@ -67,7 +68,7 @@ internal fun FeedImageGrid( .background(colors.surfaceContainerHigh) .onGloballyPositioned { bounds[url] = it.boundsInRoot() } .clickable(interactionSource = null, indication = null) { - onOpen(url, bounds[url] ?: Rect.Zero, gridRadiusPx) + onOpen(url, bounds[url] ?: Rect.Zero, gridRadiusPx, images.map { bounds[it] }) }, ) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/ForwardedFeedBlock.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/ForwardedFeedBlock.kt index 3724c0cb..1bdcfc11 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/ForwardedFeedBlock.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/ForwardedFeedBlock.kt @@ -49,12 +49,13 @@ internal fun ForwardedFeedBlock( Spacer(Modifier.height(6.dp)) FeedImageGrid( images = item.images, - onOpen = { url, rect, radius -> + onOpen = { url, rect, radius, origins -> photo.open( images = item.images, index = item.images.indexOf(url), origin = rect, originRadiusPx = radius, + origins = origins, footer = { dismiss -> FeedViewerFooter(item, item.liked, dismiss, {}, onOpen, onOpen) }, From c2a76eb1ef383adb4d36735a1fdab99356813aec Mon Sep 17 00:00:00 2001 From: QxTeak <351827445+QxTeak@users.noreply.github.com> Date: Fri, 13 Nov 2026 23:57:58 +0800 Subject: [PATCH 4/8] =?UTF-8?q?refactor(live/pgc):=20=E6=8C=89=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=88=86=E5=B1=82=E6=8E=A5=E5=85=A5=20PiliPlus=20?= =?UTF-8?q?=E7=9B=B4=E6=92=AD=20feed=20=E6=8E=A5=E5=8F=A3=EF=BC=9B?= =?UTF-8?q?=E7=9B=B4=E6=92=AD=E5=8D=A1=E7=89=87=E8=BF=9B=E7=AB=99=E5=86=85?= =?UTF-8?q?=E7=9B=B4=E6=92=AD=E9=97=B4=EF=BC=9B=E7=95=AA=E5=89=A7=E4=B8=89?= =?UTF-8?q?=E5=88=97=E4=B8=8E=E6=8E=A8=E8=8D=90=E6=A0=87=E9=A2=98=EF=BC=9B?= =?UTF-8?q?=E5=8A=A8=E6=80=81=E9=A1=B5=E9=A1=B6=E6=A0=8F=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=A8=A1=E7=B3=8A=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/aritxonly/bdfun/di/AppContainer.kt | 12 +- .../application/DiscoverStateHolders.kt | 212 ------------------ .../live/application/LivePageStateHolder.kt | 133 +++++++++++ .../pgc/application/BangumiPageStateHolder.kt | 98 ++++++++ .../comment/images/CommentPictures.kt | 2 +- .../common/discover/DiscoverCommon.kt | 49 ++++ .../{ => common}/discover/DiscoverPull.kt | 2 +- .../bdfun/presentation/feed/FeedLiveCard.kt | 4 +- .../{discover => live}/LivePageItems.kt | 36 +-- .../{discover => live}/LivePageScreen.kt | 14 +- .../{discover => pgc}/BangumiPageItems.kt | 37 +-- .../{discover => pgc}/BangumiPageScreen.kt | 31 ++- .../presentation/shell/MainFeedChrome.kt | 25 ++- .../bdfun/presentation/shell/MainTabPager.kt | 4 +- .../bdfun/core/domain/DiscoverRepository.kt | 24 ++ .../bdfun/core/model/live/LiveCard.kt | 7 + .../aritxonly/bdfun/infra/bili/BiliBackend.kt | 2 +- .../infra/bili/BiliDiscoverRepositories.kt | 25 +++ .../aritxonly/bdfun/infra/bili/LiveApi2.kt | 151 ++++++++----- 19 files changed, 525 insertions(+), 343 deletions(-) delete mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/feature/discover/application/DiscoverStateHolders.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/feature/live/application/LivePageStateHolder.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiPageStateHolder.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt rename app/src/main/kotlin/com/aritxonly/bdfun/presentation/{ => common}/discover/DiscoverPull.kt (98%) rename app/src/main/kotlin/com/aritxonly/bdfun/presentation/{discover => live}/LivePageItems.kt (88%) rename app/src/main/kotlin/com/aritxonly/bdfun/presentation/{discover => live}/LivePageScreen.kt (88%) rename app/src/main/kotlin/com/aritxonly/bdfun/presentation/{discover => pgc}/BangumiPageItems.kt (84%) rename app/src/main/kotlin/com/aritxonly/bdfun/presentation/{discover => pgc}/BangumiPageScreen.kt (77%) create mode 100644 core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/DiscoverRepository.kt create mode 100644 infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliDiscoverRepositories.kt diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt b/app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt index 3bb06c39..b4db4205 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt @@ -139,9 +139,15 @@ internal class AppContainer( HomeSlot(job, view) }, { it.view.close(); it.job.cancel() }) val homeFeedState: HomeFeedState get() = homeSlot.get().view - // 首页「直播」「番剧」两段的状态:进程内单例,切 tab 不丢列表与滚动位置。 - val livePageState by lazy { com.aritxonly.bdfun.feature.discover.application.LivePageStateHolder(api) } - val bangumiPageState by lazy { com.aritxonly.bdfun.feature.discover.application.BangumiPageStateHolder(api) } + // 首页「直播」「番剧」两段:与推荐页同一套壳,状态在各自 feature 下,接口走 infra 仓储。 + val livePageState by lazy { + com.aritxonly.bdfun.feature.live.application.LivePageStateHolder( + com.aritxonly.bdfun.infra.bili.BiliLiveDiscoverRepository(api)) + } + val bangumiPageState by lazy { + com.aritxonly.bdfun.feature.pgc.application.BangumiPageStateHolder( + com.aritxonly.bdfun.infra.bili.BiliPgcDiscoverRepository(api)) + } private val dynamicRepository by lazy { BiliFeedPageRepository({ type, offset, mid -> api.dynamicPage(type, offset, mid) }, api::dynamicPortal, api::dynamicUpList, api::likeFeed) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/discover/application/DiscoverStateHolders.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/discover/application/DiscoverStateHolders.kt deleted file mode 100644 index 73733233..00000000 --- a/app/src/main/kotlin/com/aritxonly/bdfun/feature/discover/application/DiscoverStateHolders.kt +++ /dev/null @@ -1,212 +0,0 @@ -package com.aritxonly.bdfun.feature.discover.application - -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import com.aritxonly.bdfun.core.model.live.LiveArea -import com.aritxonly.bdfun.core.model.live.LiveCard -import com.aritxonly.bdfun.core.model.pgc.BangumiCard -import com.aritxonly.bdfun.data.BiliApi - -/** - * 首页「直播」段的状态:关注直播 + 分区 + 分区直播流。 - * 刷新/加载失败只写到 error,不改动已有列表,界面按「空态 + 重试」处理。 - */ -internal class LivePageStateHolder(private val api: BiliApi) { - /** 与推荐页一样由 shell 的 TrackTeakPageScroll 接管,驱动顶栏渐进模糊与玻璃。 */ - val gridState = LazyGridState() - var following by mutableStateOf>(emptyList()) - private set - var areas by mutableStateOf>(emptyList()) - private set - var cards by mutableStateOf>(emptyList()) - private set - var selectedKey by mutableStateOf(0L) - private set - private var selectedParent = 0L - private var selectedSub = 0L - var loading by mutableStateOf(false) - private set - var refreshing by mutableStateOf(false) - private set - var error by mutableStateOf(null) - private set - var pullFraction by mutableFloatStateOf(0f) - var pullOffsetDp by mutableFloatStateOf(0f) - private var page = 1 - private var hasMore = true - var loadMoreFailed by mutableStateOf(false) - private set - private var token = 0 - - suspend fun ensureLoaded() { - if (cards.isNotEmpty() || loading) return - load(reset = true) - } - - suspend fun refresh() { - if (refreshing) return - refreshing = true - try { load(reset = true) } finally { refreshing = false } - } - - suspend fun selectArea(area: LiveArea) { - val key = area.id - if (key == selectedKey) return - selectedKey = key - selectedParent = if (area.parentId > 0L) area.parentId else area.id - selectedSub = if (area.parentId > 0L) area.id else 0L - cards = emptyList() - page = 1 - hasMore = true - load(reset = true) - } - - suspend fun loadMore() { - if (loading || refreshing || !hasMore) return - val next = page + 1 - val current = token - loading = true - loadMoreFailed = false - try { - val more = api.liveRecommend(next, selectedParent, selectedSub) - if (current != token) return - if (more.isEmpty()) { hasMore = false } else { - cards = cards + more - page = next - } - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - loadMoreFailed = true - } finally { - if (current == token) loading = false - } - } - - fun retry() { error = null } - - /** 一级分区 + 「娱乐」的子分区(聊天室 / 颜值等),顺序与 B 站直播页一致。 */ - private suspend fun loadAreas(): List { - val parents = runCatching { api.liveAreaList(0L) }.getOrDefault(emptyList()) - if (parents.isEmpty()) return emptyList() - val sub = parents.firstOrNull { it.name.contains("娱乐") }?.let { parent -> - runCatching { api.liveAreaList(parent.id) }.getOrDefault(emptyList()) - }.orEmpty() - return parents + sub - } - - private suspend fun load(reset: Boolean) { - token++ - val current = token - if (cards.isEmpty()) loading = true - loadMoreFailed = false - try { - if (areas.isEmpty()) { - areas = loadAreas() - } - val followingNow = runCatching { api.liveFollowing(1) }.getOrDefault(emptyList()) - val list = api.liveRecommend(1, selectedParent, selectedSub) - if (current != token) return - following = followingNow - cards = list - page = 1 - hasMore = list.isNotEmpty() - error = null - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - if (current != token) return - if (cards.isEmpty()) error = e.message.orEmpty().ifBlank { "加载失败" } - } finally { - if (current == token) loading = false - } - } -} - -/** 首页「番剧」段的状态:最近追番 + 推荐。 */ -internal class BangumiPageStateHolder(private val api: BiliApi) { - val gridState = LazyGridState() - var following by mutableStateOf>(emptyList()) - private set - var cards by mutableStateOf>(emptyList()) - private set - var loading by mutableStateOf(false) - private set - var refreshing by mutableStateOf(false) - private set - var error by mutableStateOf(null) - private set - var pullFraction by mutableFloatStateOf(0f) - var pullOffsetDp by mutableFloatStateOf(0f) - private var page = 1 - private var hasMore = true - var loadMoreFailed by mutableStateOf(false) - private set - private var token = 0 - - suspend fun ensureLoaded() { - if (cards.isNotEmpty() || loading) return - load() - } - - suspend fun refresh() { - if (refreshing) return - refreshing = true - try { load() } finally { refreshing = false } - } - - suspend fun loadMore() { - if (loading || refreshing || !hasMore) return - val next = page + 1 - val current = token - loading = true - loadMoreFailed = false - try { - val more = api.pgcIndex(next) - if (current != token) return - if (more.isEmpty()) { hasMore = false } else { - cards = cards + more - page = next - } - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - loadMoreFailed = true - } finally { - if (current == token) loading = false - } - } - - fun retry() { error = null } - - private suspend fun load() { - token++ - val current = token - if (cards.isEmpty()) loading = true - loadMoreFailed = false - try { - val followed = runCatching { api.pgcFollowing(1) }.getOrDefault(emptyList()) - val list = api.pgcIndex(1) - if (current != token) return - following = followed - cards = list - page = 1 - hasMore = list.isNotEmpty() - error = null - } catch (e: kotlinx.coroutines.CancellationException) { - throw e - } catch (e: Exception) { - if (current != token) return - if (cards.isEmpty()) error = e.message.orEmpty().ifBlank { "加载失败" } - } finally { - if (current == token) loading = false - } - } -} - -/** 两个页面共用的网格滚动状态由各自的 StateHolder 持有(见 gridState)。 */ diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/live/application/LivePageStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/live/application/LivePageStateHolder.kt new file mode 100644 index 00000000..4c4a4ac0 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/live/application/LivePageStateHolder.kt @@ -0,0 +1,133 @@ +package com.aritxonly.bdfun.feature.live.application + +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.aritxonly.bdfun.core.common.AppError +import com.aritxonly.bdfun.core.common.Outcome +import com.aritxonly.bdfun.core.domain.LiveDiscoverRepository +import com.aritxonly.bdfun.core.model.live.LiveArea +import com.aritxonly.bdfun.core.model.live.LiveCard + +/** + * 首页「直播」段的状态:关注直播 + 分区 + 分区直播流。 + * 与推荐页一样由 shell 的 TrackTeakPageScroll 接管 gridState(顶栏模糊/玻璃), + * 下拉刷新状态供 App 层小电视读取。 + */ +internal class LivePageStateHolder(private val repository: LiveDiscoverRepository) { + val gridState = LazyGridState() + var following by mutableStateOf>(emptyList()) + private set + var areas by mutableStateOf>(emptyList()) + private set + var cards by mutableStateOf>(emptyList()) + private set + var selectedKey by mutableStateOf(0L) + private set + var loading by mutableStateOf(false) + private set + var refreshing by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + var pullFraction by mutableFloatStateOf(0f) + var pullOffsetDp by mutableFloatStateOf(0f) + private var selectedParent = 0L + private var selectedSub = 0L + private var page = 1 + private var hasMore = true + private var token = 0 + + suspend fun ensureLoaded() { + if (cards.isNotEmpty() || loading) return + load() + } + + suspend fun refresh() { + if (refreshing) return + refreshing = true + try { load() } finally { refreshing = false } + } + + suspend fun selectArea(area: LiveArea) { + if (area.id == selectedKey) return + selectedKey = area.id + selectedParent = if (area.parentId > 0L) area.parentId else area.id + selectedSub = if (area.parentId > 0L) area.id else 0L + cards = emptyList() + page = 1 + hasMore = true + load() + } + + suspend fun loadMore() { + if (loading || refreshing || !hasMore) return + val next = page + 1 + val current = token + loading = true + try { + when (val result = repository.list(next, selectedParent, selectedSub)) { + is Outcome.Success -> if (current == token) { + val more = result.value + if (more.isEmpty()) hasMore = false else { + cards = cards + more + page = next + } + } + is Outcome.Failure -> Unit + } + } finally { + if (current == token) loading = false + } + } + + fun retry() { error = null } + + private suspend fun load() { + token++ + val current = token + if (cards.isEmpty()) loading = true + try { + var failure: AppError? = null + val home = when (val result = repository.home(1)) { + is Outcome.Success -> result.value + is Outcome.Failure -> { failure = result.error; null } + } + val list = when (val result = repository.list(1, selectedParent, selectedSub)) { + is Outcome.Success -> result.value + is Outcome.Failure -> { failure = result.error; null } + } + if (current != token) return + if (home != null) { + following = home.following + if (home.areas.isNotEmpty()) areas = home.areas + } + // 推荐位优先用首页卡片,避免多打一次分区列表;切分区时才用 list。 + val first = if (selectedKey == 0L && home != null && home.cards.isNotEmpty()) { + home.cards + } else { + list + } + if (first == null) { + if (cards.isEmpty()) error = failure?.readable() ?: "加载失败" + return + } + cards = first + page = 1 + hasMore = first.isNotEmpty() + error = null + } finally { + if (current == token) loading = false + } + } +} + +internal fun AppError.readable(): String = when (this) { + is AppError.RiskControl -> "请求被风控,稍后重试" + AppError.LoginExpired -> "登录已失效" + is AppError.Network -> message.ifBlank { "网络错误" } + is AppError.Parsing -> "数据解析失败" + is AppError.Unknown -> message.ifBlank { "加载失败" } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiPageStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiPageStateHolder.kt new file mode 100644 index 00000000..9b6aefb4 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiPageStateHolder.kt @@ -0,0 +1,98 @@ +package com.aritxonly.bdfun.feature.pgc.application + +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.aritxonly.bdfun.core.common.AppError +import com.aritxonly.bdfun.core.common.Outcome +import com.aritxonly.bdfun.core.domain.PgcDiscoverRepository +import com.aritxonly.bdfun.core.model.pgc.BangumiCard +import com.aritxonly.bdfun.feature.live.application.readable + +/** + * 首页「番剧」段的状态:最近追番 + 推荐。 + * 刷新/滚动状态与推荐页同一套(shell 绑定 gridState,App 层画下拉小电视)。 + */ +internal class BangumiPageStateHolder(private val repository: PgcDiscoverRepository) { + val gridState = LazyGridState() + var following by mutableStateOf>(emptyList()) + private set + var cards by mutableStateOf>(emptyList()) + private set + var loading by mutableStateOf(false) + private set + var refreshing by mutableStateOf(false) + private set + var error by mutableStateOf(null) + private set + var pullFraction by mutableFloatStateOf(0f) + var pullOffsetDp by mutableFloatStateOf(0f) + private var page = 1 + private var hasMore = true + private var token = 0 + + suspend fun ensureLoaded() { + if (cards.isNotEmpty() || loading) return + load() + } + + suspend fun refresh() { + if (refreshing) return + refreshing = true + try { load() } finally { refreshing = false } + } + + suspend fun loadMore() { + if (loading || refreshing || !hasMore) return + val next = page + 1 + val current = token + loading = true + try { + when (val result = repository.recommend(next)) { + is Outcome.Success -> if (current == token) { + val more = result.value + if (more.isEmpty()) hasMore = false else { + cards = cards + more + page = next + } + } + is Outcome.Failure -> Unit + } + } finally { + if (current == token) loading = false + } + } + + fun retry() { error = null } + + private suspend fun load() { + token++ + val current = token + if (cards.isEmpty()) loading = true + try { + var failure: AppError? = null + val followed = when (val result = repository.following(1)) { + is Outcome.Success -> result.value + is Outcome.Failure -> emptyList() + } + val list = when (val result = repository.recommend(1)) { + is Outcome.Success -> result.value + is Outcome.Failure -> { failure = result.error; null } + } + if (current != token) return + following = followed + if (list == null) { + if (cards.isEmpty()) error = failure?.readable() ?: "加载失败" + return + } + cards = list + page = 1 + hasMore = list.isNotEmpty() + error = null + } finally { + if (current == token) loading = false + } + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/images/CommentPictures.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/images/CommentPictures.kt index 308882e8..e17cf211 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/images/CommentPictures.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/images/CommentPictures.kt @@ -42,7 +42,7 @@ internal fun CommentPictures(urls: List, onOpen: (Int, Rect, Float, List .background(colors.surfaceContainerHigh) .onGloballyPositioned { bounds[index] = it.boundsInRoot() } .clickable(interactionSource = null, indication = null) { - onOpen(index, bounds[index] ?: Rect.Zero, radiusPx, urls.map { bounds[it] }) + onOpen(index, bounds[index] ?: Rect.Zero, radiusPx, urls.indices.map { bounds[it] }) }, ) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt new file mode 100644 index 00000000..6d03f94b --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt @@ -0,0 +1,49 @@ +package com.aritxonly.bdfun.presentation.common.discover + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.aritxonly.teak.common.chrome.TeakGlass +import com.kyant.shapes.Capsule +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** 直播 / 番剧两段共用的小节标题。 */ +@Composable +internal fun DiscoverSectionTitle(text: String) { + Text( + text = text, + color = MiuixTheme.colorScheme.onBackground, + fontSize = 15.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(start = 2.dp, bottom = 8.dp), + ) +} + +/** 直播 / 番剧两段共用的「空态 + 重试」。 */ +@Composable +internal fun DiscoverErrorState(message: String, onRetry: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 60.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text(message, color = MiuixTheme.colorScheme.onBackgroundVariant, fontSize = 14.sp) + Spacer(Modifier.height(10.dp)) + TeakGlass(modifier = Modifier.height(34.dp), shape = Capsule(), onClick = onRetry) { + Text( + text = "重试", + color = MiuixTheme.colorScheme.primary, + fontSize = 13.sp, + modifier = Modifier.padding(horizontal = 18.dp).align(Alignment.Center), + ) + } + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/DiscoverPull.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverPull.kt similarity index 98% rename from app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/DiscoverPull.kt rename to app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverPull.kt index c34774c8..eb509027 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/DiscoverPull.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverPull.kt @@ -1,4 +1,4 @@ -package com.aritxonly.bdfun.presentation.discover +package com.aritxonly.bdfun.presentation.common.discover import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedLiveCard.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedLiveCard.kt index 27f5e0db..0842d3ba 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedLiveCard.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedLiveCard.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.kyant.shapes.Capsule import com.aritxonly.bdfun.core.model.feed.FeedLiveRecommendation +import com.aritxonly.bdfun.navigateToLiveRoom import top.yukonga.miuix.kmp.theme.MiuixTheme @Composable @@ -29,7 +30,8 @@ internal fun FeedLiveCard(live: FeedLiveRecommendation) { val context = LocalContext.current Column(Modifier.fillMaxWidth().clip(ContinuousCornerShape(12.dp)) .background(colors.surfaceContainerHigh).clickable(enabled = live.roomId > 0) { - openFeedLive(context, live.roomId) + // 站内直播详情页,而不是甩给浏览器。 + navigateToLiveRoom(context, live.roomId) }) { Box(Modifier.fillMaxWidth().aspectRatio(16f / 9f)) { AsyncImage(model = rememberImageRequest(live.cover), contentDescription = live.title, diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageItems.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt similarity index 88% rename from app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageItems.kt rename to app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt index bc2bf0d2..2e208814 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageItems.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt @@ -1,4 +1,4 @@ -package com.aritxonly.bdfun.presentation.discover +package com.aritxonly.bdfun.presentation.live import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -37,7 +37,9 @@ import coil.compose.AsyncImage import com.aritxonly.bdfun.core.model.feed.FeedLiveRecommendation import com.aritxonly.bdfun.core.model.live.LiveArea import com.aritxonly.bdfun.core.model.live.LiveCard -import com.aritxonly.bdfun.feature.discover.application.LivePageStateHolder +import com.aritxonly.bdfun.feature.live.application.LivePageStateHolder +import com.aritxonly.bdfun.presentation.common.discover.DiscoverErrorState +import com.aritxonly.bdfun.presentation.common.discover.DiscoverSectionTitle import com.aritxonly.bdfun.navigateToLogin import com.aritxonly.bdfun.ui.FeedCardShape import com.aritxonly.bdfun.ui.FeedLiveCard @@ -226,36 +228,6 @@ internal fun LiveGridCard(live: LiveCard, onClick: () -> Unit) { } } -@Composable -internal fun DiscoverSectionTitle(text: String) { - Text( - text = text, - color = MiuixTheme.colorScheme.onBackground, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(start = 2.dp, bottom = 8.dp), - ) -} - -@Composable -internal fun DiscoverErrorState(message: String, onRetry: () -> Unit) { - Column( - modifier = Modifier.fillMaxWidth().padding(vertical = 60.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text(message, color = MiuixTheme.colorScheme.onBackgroundVariant, fontSize = 14.sp) - Spacer(Modifier.height(10.dp)) - TeakGlass(modifier = Modifier.height(34.dp), shape = Capsule(), onClick = onRetry) { - Text( - text = "重试", - color = MiuixTheme.colorScheme.primary, - fontSize = 13.sp, - modifier = Modifier.padding(horizontal = 18.dp).align(Alignment.Center), - ) - } - } -} - private fun LiveCard.toFeedLive() = FeedLiveRecommendation( roomId = roomId, title = title, diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageScreen.kt similarity index 88% rename from app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageScreen.kt rename to app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageScreen.kt index 17db5b07..b6cdb81e 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/LivePageScreen.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageScreen.kt @@ -1,4 +1,4 @@ -package com.aritxonly.bdfun.presentation.discover +package com.aritxonly.bdfun.presentation.live import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -22,9 +22,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import com.aritxonly.bdfun.feature.discover.application.LivePageStateHolder +import com.aritxonly.bdfun.feature.live.application.LivePageStateHolder import com.aritxonly.bdfun.navigateToLiveRoom import com.aritxonly.bdfun.ui.HomeTopBarHeight +import com.aritxonly.bdfun.presentation.common.discover.DiscoverPullSession +import com.aritxonly.bdfun.presentation.common.discover.DiscoverPullUi import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -88,8 +90,12 @@ private fun rememberLivePullSession( }, onPullDp = { state.pullOffsetDp = it }, canScrollBackward = { !state.gridState.canScrollBackward }, - isRefreshing = { state.refreshing }, - onRefresh = { scope.launch { state.refresh() } }, + isRefreshing = { state.refreshing || state.loading }, + onRefresh = { + // 与推荐页一致:松手立即把指示器顶到阈值,再开始刷新。 + state.pullFraction = 1f + scope.launch { state.refresh() } + }, ) } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageItems.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageItems.kt similarity index 84% rename from app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageItems.kt rename to app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageItems.kt index 5bc25127..731956ba 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageItems.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageItems.kt @@ -1,4 +1,4 @@ -package com.aritxonly.bdfun.presentation.discover +package com.aritxonly.bdfun.presentation.pgc import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -34,7 +34,9 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.aritxonly.bdfun.BrandPink import com.aritxonly.bdfun.core.model.pgc.BangumiCard -import com.aritxonly.bdfun.feature.discover.application.BangumiPageStateHolder +import com.aritxonly.bdfun.feature.pgc.application.BangumiPageStateHolder +import com.aritxonly.bdfun.presentation.common.discover.DiscoverErrorState +import com.aritxonly.bdfun.presentation.common.discover.DiscoverSectionTitle import com.aritxonly.bdfun.ui.FeedCardShape import com.aritxonly.bdfun.ui.TeakLoading import com.aritxonly.bdfun.ui.rememberImageRequest @@ -55,7 +57,7 @@ internal fun LazyGridScope.bangumiFollowSection( contentPadding = PaddingValues(bottom = 2.dp), ) { items(state.following, key = { it.seasonId }) { item -> - Box(Modifier.width(116.dp)) { + Box(Modifier.width(104.dp)) { BangumiCardItem(item) { openBangumi(context, item.seasonId) } } } @@ -127,25 +129,28 @@ internal fun BangumiCardItem(item: BangumiCard, onClick: () -> Unit) { } if (item.vip) BangumiVipBadge() } - Column(Modifier.padding(start = 8.dp, end = 8.dp, top = 7.dp, bottom = 8.dp)) { + Column(Modifier.padding(start = 6.dp, end = 6.dp, top = 6.dp, bottom = 6.dp)) { Text( text = item.title, color = colors.onBackground, maxLines = 2, overflow = TextOverflow.Ellipsis, - fontSize = 13.5.sp, - lineHeight = 18.sp, - modifier = Modifier.heightIn(min = 36.dp), + fontSize = 12.5.sp, + lineHeight = 16.5.sp, + modifier = Modifier.heightIn(min = 33.dp), ) - if (item.indexShow.isNotBlank()) { - Spacer(Modifier.height(5.dp)) - Text( - text = item.indexShow, - color = colors.onBackgroundVariant, - fontSize = 11.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Spacer(Modifier.height(2.dp)) + // 固定高度:没有「全x话」的卡片也留出同样空间,保证一排卡片底边齐平。 + Box(Modifier.height(15.dp), contentAlignment = Alignment.CenterStart) { + if (item.indexShow.isNotBlank()) { + Text( + text = item.indexShow, + color = colors.onBackgroundVariant, + fontSize = 10.5.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageScreen.kt similarity index 77% rename from app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageScreen.kt rename to app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageScreen.kt index d2c743fd..f844a62a 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/discover/BangumiPageScreen.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageScreen.kt @@ -1,4 +1,4 @@ -package com.aritxonly.bdfun.presentation.discover +package com.aritxonly.bdfun.presentation.pgc import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable @@ -22,8 +23,11 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import com.aritxonly.bdfun.feature.discover.application.BangumiPageStateHolder +import com.aritxonly.bdfun.feature.pgc.application.BangumiPageStateHolder +import com.aritxonly.bdfun.presentation.common.discover.DiscoverSectionTitle import com.aritxonly.bdfun.ui.HomeTopBarHeight +import com.aritxonly.bdfun.presentation.common.discover.DiscoverPullSession +import com.aritxonly.bdfun.presentation.common.discover.DiscoverPullUi import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -42,22 +46,27 @@ internal fun BangumiPage(state: BangumiPageStateHolder) { BangumiPageEffects(state, pull, pullUi) LazyVerticalGrid( - columns = GridCells.Fixed(2), + columns = GridCells.Fixed(3), state = gridState, modifier = Modifier .fillMaxSize() .offset { IntOffset(0, pullUi.offset.roundToInt()) } .nestedScroll(pull.connection()), contentPadding = PaddingValues( - start = 12.dp, - end = 12.dp, + start = 10.dp, + end = 10.dp, top = HomeTopBarHeight + statusBarTop + 6.dp, bottom = 104.dp, ), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), - verticalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(10.dp), ) { bangumiFollowSection(state, context) + if (state.cards.isNotEmpty()) { + item(key = "pgc-recommend-title", span = { GridItemSpan(maxLineSpan) }) { + DiscoverSectionTitle("推荐") + } + } items(state.cards, key = { it.seasonId }) { item -> BangumiCardItem(item) { openBangumi(context, item.seasonId) } } @@ -85,8 +94,12 @@ private fun rememberBangumiPullSession( }, onPullDp = { state.pullOffsetDp = it }, canScrollBackward = { !state.gridState.canScrollBackward }, - isRefreshing = { state.refreshing }, - onRefresh = { scope.launch { state.refresh() } }, + isRefreshing = { state.refreshing || state.loading }, + onRefresh = { + // 与推荐页一致:松手立即把指示器顶到阈值,再开始刷新。 + state.pullFraction = 1f + scope.launch { state.refresh() } + }, ) } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainFeedChrome.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainFeedChrome.kt index eeb752b6..0e0e0692 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainFeedChrome.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainFeedChrome.kt @@ -33,23 +33,28 @@ internal fun androidx.compose.foundation.layout.BoxScope.MainFeedChrome(shell: M ProgressiveTopBarMaterial( underlayLiftPx = underlayLiftPx, underlayBlurPx = underlayBlurPx, + // 与首页同一条规则:材质层只做淡出,缩放/横移放到内容层, + // 避免渐进模糊的采样矩形被缩小后在搜索动画里切出硬边。 modifier = Modifier.align(Alignment.TopCenter) .graphicsLayer { - // 和首页顶栏对齐:底栏搜索时同样缩到 0.9 并淡出。 - // 之前这里少了这两句,两个页面的过渡观感不一致。 + alpha = (1f - kotlin.math.abs(pageFloat - 1f)).coerceIn(0f, 1f) * + (1f - shell.bottom.bottomTopBarAlpha.value) + }, + ) { + androidx.compose.foundation.layout.Box( + modifier = Modifier.graphicsLayer { val searchScale = 1f - 0.1f * shell.bottom.bottomSearchProgress.value scaleX = searchScale scaleY = searchScale translationX = (1f - pageFloat) * size.width * 0.92f - alpha = (1f - kotlin.math.abs(pageFloat - 1f)).coerceIn(0f, 1f) * - (1f - shell.bottom.bottomTopBarAlpha.value) }, - ) { - FeedTopBar( - state = dynamicFeed, - loggedIn = session != null, - modifier = Modifier, - ) + ) { + FeedTopBar( + state = dynamicFeed, + loggedIn = session != null, + modifier = Modifier, + ) + } } } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTabPager.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTabPager.kt index 9440bb48..dcae9eff 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTabPager.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTabPager.kt @@ -51,12 +51,12 @@ internal fun MainTabPager( cardStyle = settings.homeCardStyle, ) } else if (homeSection == 1) { - com.aritxonly.bdfun.presentation.discover.LivePage( + com.aritxonly.bdfun.presentation.live.LivePage( state = AppGraph.livePageState, loggedIn = session != null, ) } else { - com.aritxonly.bdfun.presentation.discover.BangumiPage( + com.aritxonly.bdfun.presentation.pgc.BangumiPage( state = AppGraph.bangumiPageState, ) } diff --git a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/DiscoverRepository.kt b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/DiscoverRepository.kt new file mode 100644 index 00000000..4353ca5e --- /dev/null +++ b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/DiscoverRepository.kt @@ -0,0 +1,24 @@ +package com.aritxonly.bdfun.core.domain + +import com.aritxonly.bdfun.core.common.Outcome +import com.aritxonly.bdfun.core.model.live.LiveCard +import com.aritxonly.bdfun.core.model.live.LiveHome +import com.aritxonly.bdfun.core.model.pgc.BangumiCard + +/** + * 首页「直播」段的读取接口。实现放在 infra:bili,界面只依赖这层。 + */ +interface LiveDiscoverRepository { + suspend fun home(page: Int = 1): Outcome + + suspend fun list(page: Int = 1, parentAreaId: Long = 0L, areaId: Long = 0L): Outcome> +} + +/** + * 首页「番剧」段的读取接口:最近追番 + 推荐。 + */ +interface PgcDiscoverRepository { + suspend fun following(page: Int = 1): Outcome> + + suspend fun recommend(page: Int = 1): Outcome> +} diff --git a/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt index 34cc401b..049b135f 100644 --- a/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt +++ b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt @@ -12,6 +12,13 @@ data class LiveCard( val watchedText: String = "", ) +/** 直播首页一屏:关注直播 + 分区入口 + 推荐卡片(对应 PiliPlus 的 card_list)。 */ +data class LiveHome( + val following: List = emptyList(), + val areas: List = emptyList(), + val cards: List = emptyList(), +) + /** 直播分区(第二行横向列表)。parentId 为 0 表示这是一级分区本身。 */ data class LiveArea( val id: Long, diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliBackend.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliBackend.kt index c2f99209..1195f4f1 100644 --- a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliBackend.kt +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliBackend.kt @@ -40,7 +40,7 @@ open class BiliBackend(context: Context, debug: Boolean, log: (String, String) - suspend fun liveDanmuInfo(roomId: Long) = api.liveDanmuInfo(roomId) suspend fun livePlayUrl(roomId: Long, qn: Int = 10000) = api.livePlayUrl(roomId, qn) // 直播页 / 番剧页(PiliPlus 同款接口) - suspend fun liveFollowing(page: Int = 1) = api.liveFollowing(page) + suspend fun liveHome(page: Int = 1) = api.liveHome(page) suspend fun liveRecommend(page: Int = 1, parentAreaId: Long = 0L, areaId: Long = 0L) = api.liveRecommend(page, parentAreaId, areaId) suspend fun liveAreaList(parentId: Long = 0L) = api.liveAreaList(parentId) diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliDiscoverRepositories.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliDiscoverRepositories.kt new file mode 100644 index 00000000..fb302dc8 --- /dev/null +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliDiscoverRepositories.kt @@ -0,0 +1,25 @@ +package com.aritxonly.bdfun.infra.bili + +import com.aritxonly.bdfun.core.domain.LiveDiscoverRepository +import com.aritxonly.bdfun.core.domain.PgcDiscoverRepository +import com.aritxonly.bdfun.core.model.live.LiveCard +import com.aritxonly.bdfun.core.model.live.LiveHome +import com.aritxonly.bdfun.core.model.pgc.BangumiCard + +/** 首页直播段:包装 infra:bili 里的 PiliPlus 同款接口。 */ +class BiliLiveDiscoverRepository(private val api: BiliBackend) : LiveDiscoverRepository { + override suspend fun home(page: Int): com.aritxonly.bdfun.core.common.Outcome = + biliOutcome { api.liveHome(page) } + + override suspend fun list(page: Int, parentAreaId: Long, areaId: Long): com.aritxonly.bdfun.core.common.Outcome> = + biliOutcome { api.liveRecommend(page, parentAreaId, areaId) } +} + +/** 首页番剧段:最近追番 + 推荐。 */ +class BiliPgcDiscoverRepository(private val api: BiliBackend) : PgcDiscoverRepository { + override suspend fun following(page: Int): com.aritxonly.bdfun.core.common.Outcome> = + biliOutcome { api.pgcFollowing(page) } + + override suspend fun recommend(page: Int): com.aritxonly.bdfun.core.common.Outcome> = + biliOutcome { api.pgcIndex(page) } +} diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt index 294b41df..c158405f 100644 --- a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt @@ -2,76 +2,74 @@ package com.aritxonly.bdfun.infra.bili import com.aritxonly.bdfun.core.model.live.LiveArea import com.aritxonly.bdfun.core.model.live.LiveCard +import com.aritxonly.bdfun.core.model.live.LiveHome import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.json.JSONArray import org.json.JSONObject private const val livePageBase = "https://api.live.bilibili.com" private const val liveReferer = "https://live.bilibili.com/" /** - * 关注的正在直播(PiliPlus 同款接口 /xlive/web-ucenter/user/following)。 - * 未登录或被风控时返回空列表,页面隐藏这一行。 + * 直播首页,照搬 PiliPlus 的 `LiveHttp.liveFeedIndex`: + * app 端 `/xlive/app-interface/v2/index/feed`,必须 AppSign,web 的 WBI 在这条接口上无效。 */ -internal suspend fun BiliApiContext.liveFollowing(page: Int = 1): List = - withContext(Dispatchers.IO) { - cancellableCatching { - val data = dataRequest( - "/xlive/web-ucenter/user/following", - mapOf( - "page" to page.toString(), - "page_size" to "9", - "ignoreRecord" to "1", - "hit_ab" to "true", - ), - base = livePageBase, - referer = liveReferer, - ) - val list = data.optJSONArray("list") ?: return@cancellableCatching emptyList() - (0 until list.length()).mapNotNull { i -> - val item = list.optJSONObject(i) ?: return@mapNotNull null - if (item.optInt("live_status", 0) != 1) return@mapNotNull null - item.toLiveCard() +internal suspend fun BiliApiContext.liveHome(page: Int = 1): LiveHome = withContext(Dispatchers.IO) { + cancellableCatching { + val params = liveAppParams(mapOf("page" to page.toString())) + val data = dataRequest( + "/xlive/app-interface/v2/index/feed", + params, + base = livePageBase, + userAgent = BILI_APP_UA, + headers = BILI_APP_HEADERS, + ) + val list = data.optJSONArray("card_list") ?: return@cancellableCatching LiveHome() + var following = emptyList() + var areas = emptyList() + val cards = mutableListOf() + for (i in 0 until list.length()) { + val card = list.optJSONObject(i) ?: continue + val cardData = card.optJSONObject("card_data") + when (card.optString("card_type")) { + "my_idol_v1" -> following = cardData.parseLiveCardList("my_idol_v1") + "area_entrance_v3" -> areas = cardData.parseLiveAreas("area_entrance_v3") + "small_card_v1" -> cards += cardData.parseLiveCardList("small_card_v1") } - }.getOrDefault(emptyList()) - } + } + LiveHome(following, areas, cards) + }.getOrDefault(LiveHome()) +} -/** - * 推荐直播(沿用首页已有的 web 接口 /second/getList)。 - * parentAreaId / areaId 传 0 表示全部;切分区时传对应 id。 - */ +/** 分区直播流,照搬 PiliPlus 的 `LiveHttp.liveSecondList`(app 端 second/getList)。 */ internal suspend fun BiliApiContext.liveRecommend( page: Int = 1, parentAreaId: Long = 0L, areaId: Long = 0L, ): List = withContext(Dispatchers.IO) { - // 这个 web 接口现在不带 WBI 会直接 -352(风控),和 getDanmuInfo 一样: - // 先签名请求,失败就清一次密钥再签一遍。 - val params = mapOf( - "platform" to "web", - "parent_area_id" to parentAreaId.toString(), - "area_id" to areaId.toString(), - "sort_type" to "online", - "page" to page.toString(), - "page_size" to "20", - ) - suspend fun load(signed: Boolean): List = cancellableCatching { + cancellableCatching { + val params = liveAppParams( + mapOf( + "area_id" to areaId.toString(), + "parent_area_id" to parentAreaId.toString(), + "page" to page.toString(), + "page_size" to "20", + "tag_version" to "1", + "qn" to "0", + "sort_type" to "online", + ), + ) val data = dataRequest( - "/xlive/web-interface/v1/second/getList", + "/xlive/app-interface/v2/second/getList", params, - signed = signed, base = livePageBase, - referer = liveReferer, + userAgent = BILI_APP_UA, + headers = BILI_APP_HEADERS, ) - val list = data.optJSONArray("list") ?: return@cancellableCatching emptyList() - (0 until list.length()).mapNotNull { i -> list.optJSONObject(i)?.toLiveCard() } + data.optJSONArray("list").toLiveCards() }.getOrDefault(emptyList()) - - load(signed = true).ifEmpty { load(signed = false) }.ifEmpty { - wbiSigner.clear() - load(signed = true) - } } -/** 直播分区:web 端 /room/v1/Area/getList,data 本身是数组。parentId=0 取一级分区。 */ +/** 一级分区兜底列表(卡片里没有 area_entrance_v3 时用)。 */ internal suspend fun BiliApiContext.liveAreaList(parentId: Long = 0L): List = withContext(Dispatchers.IO) { cancellableCatching { val root = rawRequest( @@ -90,19 +88,70 @@ internal suspend fun BiliApiContext.liveAreaList(parentId: Long = 0L): List): MutableMap { + val params = mutableMapOf( + "channel" to "master", + "actionKey" to "appkey", + "build" to "8430300", + "version" to "8.43.0", + "c_locale" to "zh_CN", + "s_locale" to "zh_CN", + "device" to "android", + "device_name" to "android", + "device_type" to "0", + "disable_rcmd" to "0", + "https_url_req" to "1", + "mobi_app" to "android", + "platform" to "android", + "statistics" to "{\"appId\":1,\"platform\":3,\"version\":\"8.43.0\",\"abtest\":\"\"}", + ) + params.putAll(extra) + if (isLoggedIn) params["relation_page"] = "1" + signAppParams(params) + return params +} + +private fun JSONObject?.parseLiveCardList(key: String): List = + this?.optJSONObject(key)?.optJSONArray("list").toLiveCards() + +private fun JSONObject?.parseLiveAreas(key: String): List { + val list = this?.optJSONObject(key)?.optJSONArray("list") ?: return emptyList() + val areas = ArrayList(list.length()) + for (i in 0 until list.length()) { + val item = list.optJSONObject(i) ?: continue + val parent = item.optLong("area_v2_parent_id", 0L) + val area = item.optLong("area_v2_id", 0L) + val title = item.optString("title").orEmpty() + // 分区入口以 parent 为主键;像「颜值」这种带 area_id 的用 area 做 key。 + val id = if (area > 0L) area else parent + if (id > 0L && title.isNotBlank()) areas += LiveArea(id, title, parent) + } + return areas +} + +private fun JSONArray?.toLiveCards(): List { + if (this == null) return emptyList() + return (0 until length()).mapNotNull { i -> optJSONObject(i)?.toLiveCard() } +} + private fun JSONObject.toLiveCard(): LiveCard? { - val roomId = optLong("roomid", 0L).takeIf { it > 0L } ?: return null + val roomId = optLong("roomid", 0L).takeIf { it > 0L } + ?: optLong("id", 0L).takeIf { it > 0L } + ?: return null val watched = optJSONObject("watched_show")?.optString("text_small").orEmpty() .ifBlank { optString("text_small").orEmpty() } .ifBlank { val online = optLong("online", 0L) if (online > 0L) "${formatLiveCount(online)}人看过" else "" } + val cover = optString("cover").orEmpty() + .ifBlank { optString("system_cover").orEmpty() } + .ifBlank { optString("room_cover").orEmpty() } return LiveCard( roomId = roomId, title = optString("title").orEmpty().ifBlank { optString("uname").orEmpty() }, uname = optString("uname").orEmpty(), - cover = biliImage(optString("cover").orEmpty().ifBlank { optString("room_cover").orEmpty() }), + cover = biliImage(cover), areaName = optString("area_name").orEmpty(), watchedText = watched, ) From 1cdac4a6da848c85d13c960d74429b4a40757c04 Mon Sep 17 00:00:00 2001 From: QxTeak <351827445+QxTeak@users.noreply.github.com> Date: Sat, 14 Nov 2026 00:00:45 +0800 Subject: [PATCH 5/8] =?UTF-8?q?fix(live):=20=E7=9B=B4=E6=92=AD=E9=A6=96?= =?UTF-8?q?=E9=A1=B5=20app=20=E6=8E=A5=E5=8F=A3=E5=A4=B1=E8=B4=A5=E6=97=B6?= =?UTF-8?q?=E5=9B=9E=E9=80=80=20web=20=E5=88=86=E5=8C=BA=E5=88=97=E8=A1=A8?= =?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=E6=95=B4=E9=A1=B5=E7=A9=BA=E7=99=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aritxonly/bdfun/infra/bili/LiveApi2.kt | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt index c158405f..120f948f 100644 --- a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt @@ -16,7 +16,7 @@ private const val liveReferer = "https://live.bilibili.com/" * app 端 `/xlive/app-interface/v2/index/feed`,必须 AppSign,web 的 WBI 在这条接口上无效。 */ internal suspend fun BiliApiContext.liveHome(page: Int = 1): LiveHome = withContext(Dispatchers.IO) { - cancellableCatching { + val result = cancellableCatching { val params = liveAppParams(mapOf("page" to page.toString())) val data = dataRequest( "/xlive/app-interface/v2/index/feed", @@ -40,6 +40,16 @@ internal suspend fun BiliApiContext.liveHome(page: Int = 1): LiveHome = withCont } LiveHome(following, areas, cards) }.getOrDefault(LiveHome()) + // app 端整段拿不到时(签名被拒/接口调整),退回 web 分区列表,至少页面不是空的。 + if (result.cards.isNotEmpty() || result.areas.isNotEmpty()) { + result + } else { + LiveHome( + following = result.following, + areas = liveAreaList(0L), + cards = webLiveList(1, 0L, 0L), + ) + } } /** 分区直播流,照搬 PiliPlus 的 `LiveHttp.liveSecondList`(app 端 second/getList)。 */ @@ -66,7 +76,32 @@ internal suspend fun BiliApiContext.liveRecommend( headers = BILI_APP_HEADERS, ) data.optJSONArray("list").toLiveCards() + }.getOrDefault(emptyList()).ifEmpty { webLiveList(page, parentAreaId, areaId) } +} + +/** App 端接口异常时的兜底:老的 web 分区列表(有 buvid 时会话通常能过)。 */ +private suspend fun BiliApiContext.webLiveList( + page: Int, parentAreaId: Long, areaId: Long, +): List { + val params = mapOf( + "platform" to "web", + "parent_area_id" to parentAreaId.toString(), + "area_id" to areaId.toString(), + "sort_type" to "online", + "page" to page.toString(), + "page_size" to "20", + ) + suspend fun once(signed: Boolean): List = cancellableCatching { + val data = dataRequest( + "/xlive/web-interface/v1/second/getList", + params, + signed = signed, + base = livePageBase, + referer = liveReferer, + ) + data.optJSONArray("list").toLiveCards() }.getOrDefault(emptyList()) + return once(true).ifEmpty { once(false) } } /** 一级分区兜底列表(卡片里没有 area_entrance_v3 时用)。 */ From 033c0a4816d6df5ff47fd2179f3f9e1fbb6e099e Mon Sep 17 00:00:00 2001 From: QxTeak <351827445+QxTeak@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:06:46 +0800 Subject: [PATCH 6/8] =?UTF-8?q?feat(live/pgc/photo/comment):=20=E7=9B=B4?= =?UTF-8?q?=E6=92=AD=E9=A1=B5=E9=87=8D=E5=81=9A=E3=80=81=E7=95=AA=E5=89=A7?= =?UTF-8?q?=E6=A6=9C=E5=8D=95=E4=B8=8E=E6=92=AD=E6=94=BE=E3=80=81=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E4=B8=8D=E8=A3=81=E5=88=87=E3=80=81=E5=BC=B9=E5=B9=95?= =?UTF-8?q?=E8=AE=A1=E6=95=B0=E9=81=BF=E8=AE=A9=E4=B8=8E=E8=AF=84=E8=AE=BA?= =?UTF-8?q?=E8=8F=9C=E5=8D=95=E9=87=8D=E5=81=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 直播页 - feed 接口补齐 fnval/network/scale,small_card_v1 按单个房间对象解析,人数取 watched_show.text_large - 分区列表改用 /room/v3/area/getRoomList(app/web 的 second/getList 实测均 -352) - 关注直播改为「正在直播」头像行:粉色脉冲圈 + 外扩白边「直播中」胶囊,套圆角卡片 - 直播卡片主播名前加「主播」标识(与卡片「UP」同一组件) - 直播拉流补 Referer/UA/Cookie,修复有地址但黑屏;竖屏顶栏接入页面背景 - 直播卡片点击进站内直播间,动态页直播入口同步 番剧 - 新增番剧榜单(分节 + 单榜全量页)与榜单数据接入 - 番剧播放走视频详情:season 解析首集 bvid - 三列网格、推荐小标题、卡片高度与副标题对齐;上滑加载与推荐页同一套触发 图片 - 动态/评论图片保留原图 URL,缩略图只缩宽度不裁切;查看器两层图(缩略图 + 原图淡入)无缝进出 - 长按菜单改为评论同款玻璃菜单,位置按长按点,单击画面关闭,可保存到相册 评论 - 长按菜单在按下位置弹出;带图评论追加「保存全部图片」 - 「自由复制」改为可自由拖选的弹层 - 展开页底色与左右留白对齐主评论区,折叠回复用户名与正文同字号,右上角加关闭键 其它 - 弹幕计数移到画面顶部,弹幕行顶部让出 42dp 避让 - 首页三段与动态页顶栏过渡合并为 MainTopBarChrome - 默认清晰度新增 1080P 60FPS Closes #31 Closes #24 Closes #14 --- AGENTS.md | 5 + app/src/main/AndroidManifest.xml | 1 + .../kotlin/com/aritxonly/bdfun/AppGraph.kt | 5 + .../bdfun/BangumiRankBoardActivity.kt | 58 +++ .../com/aritxonly/bdfun/di/AppContainer.kt | 25 +- .../live/application/LivePageStateHolder.kt | 60 +-- .../BangumiRankBoardStateHolder.kt | 68 ++++ .../pgc/application/BangumiRankStateHolder.kt | 219 ++++++++++ .../comment/images/CommentPictures.kt | 5 +- .../comment/page/CommentPageThread.kt | 1 + .../presentation/comment/row/CommentRow.kt | 36 +- .../comment/row/CommentTypography.kt | 4 +- .../comment/thread/CommentThreadContent.kt | 14 +- .../thread/CommentThreadDialogContracts.kt | 2 + .../comment/thread/CommentThreadFades.kt | 28 +- .../comment/thread/CommentThreadList.kt | 5 +- .../comment/thread/CommentThreadSheet.kt | 4 +- .../common/discover/DiscoverCommon.kt | 12 +- .../common/photo/PhotoViewerOverlay.kt | 85 +++- .../common/photo/PhotoViewerPage.kt | 93 +++-- .../common/photo/PhotoViewerPageState.kt | 6 +- .../common/photo/PhotoViewerRoot.kt | 33 +- .../presentation/feed/FeedAuthorSidebar.kt | 11 +- .../bdfun/presentation/feed/FeedAvatarCard.kt | 19 +- .../bdfun/presentation/feed/FeedImageGrid.kt | 15 +- .../presentation/feed/FeedLiveNavigation.kt | 9 +- .../bdfun/presentation/live/LivePageItems.kt | 93 +++-- .../bdfun/presentation/live/LivePageScreen.kt | 14 +- .../presentation/live/PortraitLiveLayout.kt | 9 +- .../presentation/pgc/BangumiPageItems.kt | 75 ++-- .../presentation/pgc/BangumiPageScreen.kt | 41 +- .../pgc/BangumiRankBoardScreen.kt | 156 +++++++ .../bdfun/presentation/pgc/BangumiRankPage.kt | 381 ++++++++++++++++++ .../player/danmaku/DanmakuEngineConfig.kt | 10 +- .../player/danmaku/DanmakuLayerOptions.kt | 9 +- .../settings/page/SettingsFeatureCards.kt | 50 +++ .../settings/page/SettingsOptions.kt | 5 +- .../settings/page/SettingsScreen.kt | 1 + .../presentation/shell/MainFeedChrome.kt | 75 ++-- .../presentation/shell/MainHomeChrome.kt | 82 +--- .../presentation/shell/MainTopBarChrome.kt | 86 ++++ .../com/aritxonly/bdfun/ui/ImageSaver.kt | 79 ++++ .../com/aritxonly/bdfun/ui/LiveAvatar.kt | 136 +++++++ .../com/aritxonly/bdfun/ui/TeakChips.kt | 19 +- .../bdfun/ui/comment/CommentCopySheet.kt | 116 ++++++ .../aritxonly/bdfun/ui/comment/CommentMenu.kt | 42 +- .../bdfun/core/common/BiliImageUrl.kt | 22 + .../bdfun/core/domain/DiscoverRepository.kt | 4 + .../core/domain/PgcPlaybackRepository.kt | 18 + .../bdfun/core/domain/PgcRankRepository.kt | 25 ++ .../bdfun/core/model/live/LiveCard.kt | 32 +- .../bdfun/core/model/pgc/BangumiPlayback.kt | 45 +++ .../bdfun/core/model/pgc/BangumiRank.kt | 39 ++ .../player/PlayerPictureOverlays.kt | 4 +- .../aritxonly/bdfun/infra/bili/BiliBackend.kt | 3 + .../bdfun/infra/bili/BiliCookieJar.kt | 3 + .../infra/bili/BiliDiscoverRepositories.kt | 3 + .../infra/bili/BiliPgcPlaybackRepository.kt | 11 + .../bdfun/infra/bili/BiliPgcRankRepository.kt | 13 + .../aritxonly/bdfun/infra/bili/LiveApi2.kt | 168 ++++---- .../bdfun/infra/bili/PgcPlaybackApi.kt | 51 +++ .../aritxonly/bdfun/infra/bili/PgcRankApi.kt | 62 +++ .../infra/bili/mapper/CommentItemMapper.kt | 2 +- .../infra/bili/mapper/FeedContentMapper.kt | 16 +- .../infra/bili/mapper/PgcPlaybackMapper.kt | 110 +++++ .../bdfun/infra/bili/mapper/PgcRankMapper.kt | 70 ++++ .../infra/bili/mapper/CommentMapperTest.kt | 5 +- .../infra/bili/mapper/FeedItemMapperTest.kt | 3 +- .../infra/bili/mapper/PgcRankMapperTest.kt | 108 +++++ .../infra/playback/Media3LivePlayback.kt | 53 ++- 70 files changed, 2764 insertions(+), 408 deletions(-) create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/BangumiRankBoardActivity.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiRankBoardStateHolder.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiRankStateHolder.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiRankBoardScreen.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiRankPage.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTopBarChrome.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/ui/ImageSaver.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/ui/LiveAvatar.kt create mode 100644 app/src/main/kotlin/com/aritxonly/bdfun/ui/comment/CommentCopySheet.kt create mode 100644 core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/PgcPlaybackRepository.kt create mode 100644 core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/PgcRankRepository.kt create mode 100644 core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/pgc/BangumiPlayback.kt create mode 100644 core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/pgc/BangumiRank.kt create mode 100644 infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliPgcPlaybackRepository.kt create mode 100644 infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliPgcRankRepository.kt create mode 100644 infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcPlaybackApi.kt create mode 100644 infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcRankApi.kt create mode 100644 infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/PgcPlaybackMapper.kt create mode 100644 infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/PgcRankMapper.kt create mode 100644 infra/bili/src/test/kotlin/com/aritxonly/bdfun/infra/bili/mapper/PgcRankMapperTest.kt diff --git a/AGENTS.md b/AGENTS.md index cf48ff9f..7e29f798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -581,3 +581,8 @@ MainTabPager位于主壳layerBackdrop的录制树内,因此MineScreen及其资 HyperOS 中性色以 Deadliner 的 HyperOS 适配为准:surface 为页面层(浅色 F3F3F3 / 深色 000000),surfaceContainer 为卡片层(FFFFFF / 242424)。应用层旧 surface/surfaceContainer 引用及配对前景已交换,TeakUI 标准组件仍遵循自身语义;不要再次把两个 token 的定义反转。独立 Activity、直播与主壳 Material 色板统一由 appMaterialColors 投影,粉/蓝品牌设置保持。 原本显式启用平面背景的玻璃控件统一通过 TeakGlassDefaults.flatBackgroundAlpha 读取深色 0.9 / 浅色 0.96;无平面背景的默认值仍为 0,不要为纯图标按钮补背景。 + +### 2026-11-14 直播页内容 / 分区 / 关注与图片保存检查点 + +直播首页接口 `/xlive/app-interface/v2/index/feed` 必须带 `fnval=912&network=wifi&scale=2`,少一个就 -400;`card_data.small_card_v1` 是**单个房间对象**,不是 `{list:[]}`,按 list 解析会把整页卡片丢光;观看人数取 `watched_show.text_large`。分区列表改用 BiliPai 验证过的 `/room/v3/area/getRoomList`(app 端 `second/getList` 与 web `getList` 实测均 -352,WBI 也救不回来),分区请求参数必须分开存 `area_id`/`parent_area_id`。登录态首页不下发 `area_entrance_v3`,按 PiliPlus 的做法用 `/room/v1/Area/getList` 补默认分区。关注直播不再用卡片横排,改成和动态页同源的 portal `live_users` 头像行(`LiveFollower`)。直播拉流必须在 ExoPlayer 数据源上带 `Referer: https://live.bilibili.com/`、web UA 和 Cookie,否则有地址也黑屏;竖屏顶栏不再用纯黑色块,改用页面 BackdropGlow + 顶部渐变。图片查看器长按弹 `TeakDropdownPanel`(保存该图片 / 保存全部图片 / 举报红字),位置按长按点换算锚点;保存走 MediaStore `Pictures/bdFun`。评论菜单锚点改成行内记录的按下坐标,带图评论追加「保存全部图片」;「自由复制」改为弹 `CommentCopySheet`,正文进 `SelectionContainer` 可自由拖选。首页三段与动态页顶栏过渡合并为单一 `MainTopBarChrome`,不要再各写一份。下拉刷新的 `canScrollBackward` 调用方不得再取反(会变成不在顶部也能刷新);分页触发统一 `drop(1)` + 按卡片数比较。 + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index aa2e9639..b7b5e142 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -106,6 +106,7 @@ android:windowSoftInputMode="adjustNothing" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|smallestScreenSize|density" /> + + result.value.episodes.firstOrNull { it.sectionTitle == "正片" && it.bvid.isNotBlank() } + ?: result.value.episodes.firstOrNull { it.bvid.isNotBlank() } + is com.aritxonly.bdfun.core.common.Outcome.Failure -> null + } private val dynamicRepository by lazy { BiliFeedPageRepository({ type, offset, mid -> api.dynamicPage(type, offset, mid) }, api::dynamicPortal, api::dynamicUpList, api::likeFeed) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/live/application/LivePageStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/live/application/LivePageStateHolder.kt index 4c4a4ac0..55580bb0 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/feature/live/application/LivePageStateHolder.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/live/application/LivePageStateHolder.kt @@ -10,15 +10,20 @@ import com.aritxonly.bdfun.core.common.Outcome import com.aritxonly.bdfun.core.domain.LiveDiscoverRepository import com.aritxonly.bdfun.core.model.live.LiveArea import com.aritxonly.bdfun.core.model.live.LiveCard +import com.aritxonly.bdfun.core.model.live.LiveFollower /** * 首页「直播」段的状态:关注直播 + 分区 + 分区直播流。 * 与推荐页一样由 shell 的 TrackTeakPageScroll 接管 gridState(顶栏模糊/玻璃), * 下拉刷新状态供 App 层小电视读取。 + * + * 翻页和首页一样走同一个接口:推荐 tab 用 `/index/feed` 的下一页(PiliPlus 同款), + * 切到分区后才换成 `second/getList`。 */ internal class LivePageStateHolder(private val repository: LiveDiscoverRepository) { val gridState = LazyGridState() - var following by mutableStateOf>(emptyList()) + /** 我的关注里正在直播的 UP(和动态页头像行同一数据源)。 */ + var followers by mutableStateOf>(emptyList()) private set var areas by mutableStateOf>(emptyList()) private set @@ -54,8 +59,8 @@ internal class LivePageStateHolder(private val repository: LiveDiscoverRepositor suspend fun selectArea(area: LiveArea) { if (area.id == selectedKey) return selectedKey = area.id - selectedParent = if (area.parentId > 0L) area.parentId else area.id - selectedSub = if (area.parentId > 0L) area.id else 0L + selectedParent = area.parentAreaId + selectedSub = area.areaId cards = emptyList() page = 1 hasMore = true @@ -68,15 +73,23 @@ internal class LivePageStateHolder(private val repository: LiveDiscoverRepositor val current = token loading = true try { - when (val result = repository.list(next, selectedParent, selectedSub)) { - is Outcome.Success -> if (current == token) { - val more = result.value - if (more.isEmpty()) hasMore = false else { - cards = cards + more - page = next - } + val more = if (selectedKey == 0L) { + when (val result = repository.home(next)) { + is Outcome.Success -> result.value.cards + is Outcome.Failure -> return } - is Outcome.Failure -> Unit + } else { + when (val result = repository.list(next, selectedParent, selectedSub)) { + is Outcome.Success -> result.value + is Outcome.Failure -> return + } + } + if (current != token) return + if (more.isEmpty()) { + hasMore = false + } else { + cards = cards + more + page = next } } finally { if (current == token) loading = false @@ -91,32 +104,33 @@ internal class LivePageStateHolder(private val repository: LiveDiscoverRepositor if (cards.isEmpty()) loading = true try { var failure: AppError? = null + // 首页卡片带分区入口;关注直播单独走 portal(和动态页头像行同源)。 val home = when (val result = repository.home(1)) { is Outcome.Success -> result.value is Outcome.Failure -> { failure = result.error; null } } - val list = when (val result = repository.list(1, selectedParent, selectedSub)) { - is Outcome.Success -> result.value - is Outcome.Failure -> { failure = result.error; null } - } + val followersResult = repository.followers() + val list = if (selectedKey == 0L) null else + when (val result = repository.list(1, selectedParent, selectedSub)) { + is Outcome.Success -> result.value + is Outcome.Failure -> { failure = result.error; null } + } if (current != token) return + if (followersResult is Outcome.Success) { + followers = followersResult.value.distinctBy { it.roomId } + } if (home != null) { - following = home.following if (home.areas.isNotEmpty()) areas = home.areas } - // 推荐位优先用首页卡片,避免多打一次分区列表;切分区时才用 list。 - val first = if (selectedKey == 0L && home != null && home.cards.isNotEmpty()) { - home.cards - } else { - list - } + val first = if (selectedKey == 0L) home?.cards else list if (first == null) { if (cards.isEmpty()) error = failure?.readable() ?: "加载失败" return } cards = first page = 1 - hasMore = first.isNotEmpty() + // 推荐 tab 用接口的 has_more;分区列表接口没有该字段,按「这一页非空」继续。 + hasMore = if (selectedKey == 0L) home?.hasMore ?: false else first.isNotEmpty() error = null } finally { if (current == token) loading = false diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiRankBoardStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiRankBoardStateHolder.kt new file mode 100644 index 00000000..fba325c6 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiRankBoardStateHolder.kt @@ -0,0 +1,68 @@ +package com.aritxonly.bdfun.feature.pgc.application + +import com.aritxonly.bdfun.core.common.Outcome +import com.aritxonly.bdfun.core.domain.PgcRankRepository +import com.aritxonly.bdfun.core.model.pgc.BangumiRankEntry +import com.aritxonly.bdfun.core.model.pgc.BangumiRankTab +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +/** 单榜全量页的状态:一个 tab 只取一次,失败可重试。 */ +internal data class BangumiRankBoardUiState( + val note: String = "", + val entries: List = emptyList(), + val loading: Boolean = false, + val error: String? = null, +) + +/** + * 「查看更多」进去的单榜页状态。 + * + * 榜单接口一次给整榜(番剧热门榜 99 条),没有分页,所以这里只加载一次。 + */ +internal class BangumiRankBoardStateHolder( + private val repository: PgcRankRepository, + private val tab: BangumiRankTab, +) { + private val mutableState = MutableStateFlow(BangumiRankBoardUiState()) + val state: StateFlow = mutableState.asStateFlow() + + private var requested = false + private var token = 0 + + suspend fun ensureLoaded() { + if (requested) return + requested = true + load() + } + + suspend fun retry() { + if (mutableState.value.loading) return + requested = true + load() + } + + private suspend fun load() { + token++ + val current = token + mutableState.update { it.copy(loading = true, error = null) } + try { + when (val result = repository.board(tab)) { + is Outcome.Success -> { + if (current != token) return + mutableState.update { + it.copy(note = result.value.note, entries = result.value.entries, error = null) + } + } + is Outcome.Failure -> { + if (current != token) return + mutableState.update { it.copy(error = result.error.readable()) } + } + } + } finally { + if (current == token) mutableState.update { it.copy(loading = false) } + } + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiRankStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiRankStateHolder.kt new file mode 100644 index 00000000..b7a102b9 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiRankStateHolder.kt @@ -0,0 +1,219 @@ +package com.aritxonly.bdfun.feature.pgc.application + +import com.aritxonly.bdfun.core.common.AppError +import com.aritxonly.bdfun.core.common.Outcome +import com.aritxonly.bdfun.core.domain.PgcRankRepository +import com.aritxonly.bdfun.core.model.pgc.BangumiRankEntry +import com.aritxonly.bdfun.core.model.pgc.BangumiRankTab +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +/** 排行榜的一节:榜名 + 一个或两个榜单(首节「热门动画榜」是番剧+国创合并)。 */ +internal data class BangumiRankSection( + val title: String, + val tabs: List, + val entries: List = emptyList(), + val loading: Boolean = false, + val loaded: Boolean = false, + val error: String? = null, +) { + val key: String get() = tabs.joinToString("-") { it.rankId.toString() } +} + +/** 页面一次性需要的状态;展示层只读。 */ +internal data class BangumiRankUiState( + val sections: List = emptyList(), + val loading: Boolean = false, + val error: String? = null, +) + +/** + * 番剧页「排行榜」状态。 + * + * 目录是两个 index(番剧 1 / 国创 4),首屏只取前 3 个榜,其余等滚到再取, + * 避免一次打十几个请求。单榜失败只影响那一节,不整页报错。 + */ +internal class BangumiRankStateHolder(private val repository: PgcRankRepository) { + + private val mutableState = MutableStateFlow(BangumiRankUiState()) + val state: StateFlow = mutableState.asStateFlow() + + private var requested = false + private var token = 0 + + /** + * 单节加载用它自己的作用域。 + * + * 之前是挂在每个 item 的 `LaunchedEffect` 上:item 一滑出屏幕协程就被取消, + * `loading` 停在 true 或者反复「取消-重启」,表现就是榜单一直空白。 + */ + private val sectionScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + /** 需要大会员的 season_id;榜单接口没有,靠网页榜单对照补。 */ + private var vipSeasons: Set = emptySet() + + suspend fun ensureLoaded() { + if (requested) return + requested = true + load() + } + + suspend fun refresh() { + if (mutableState.value.loading) return + load(force = true) + } + + /** 滚到哪一节就取哪一节;重复调用会被 loaded/loading 挡掉。 */ + fun ensureSection(index: Int) { + val section = mutableState.value.sections.getOrNull(index) ?: return + if (section.loaded || section.loading) return + updateSection(index) { it.copy(loading = true, error = null) } + val current = token + sectionScope.launch { loadSection(index, section.tabs, current) } + } + + /** 用户点「重试」:清掉错误再取一次。 */ + fun retrySection(index: Int) { + updateSection(index) { it.copy(error = null, loading = false) } + ensureSection(index) + } + + private suspend fun loadSection(index: Int, tabs: List, current: Int) { + try { + var failure: String? = null + val collected = ArrayList() + for (tab in tabs) { + when (val result = repository.board(tab)) { + is Outcome.Success -> collected += result.value.entries + is Outcome.Failure -> failure = result.error.readable() + } + } + if (current != token) return + + // 合并榜(番剧热门 + 国创热门)两边的名次各自从 1 开始,直接拼会出现两个 1/2/3, + // 所以统一按合并后的顺序重编号。 + val merged = collected + .distinctBy { it.seasonId } + .sortedBy { it.rank } + .mapIndexed { position, entry -> + entry.copy(rank = position + 1, vip = entry.seasonId in vipSeasons) + } + updateSection(index) { + it.copy( + entries = merged, + loaded = merged.isNotEmpty(), + error = if (merged.isEmpty()) failure ?: "榜单加载失败" else null, + ) + } + } finally { + updateSection(index) { if (it.loading) it.copy(loading = false) else it } + } + } + + private suspend fun load(force: Boolean = false) { + if (!force && mutableState.value.sections.isNotEmpty()) return + token++ + val current = token + mutableState.update { it.copy(loading = true, error = null) } + try { + val bangumi = repository.tabs(PGC_INDEX_BANGUMI) + val guochuang = repository.tabs(PGC_INDEX_GUOCHUANG) + if (current != token) return + + val bangumiTabs = if (bangumi is Outcome.Success) bangumi.value else emptyList() + val guochuangTabs = if (guochuang is Outcome.Success) guochuang.value else emptyList() + val failure = when { + bangumi is Outcome.Failure -> bangumi.error + guochuang is Outcome.Failure -> guochuang.error + else -> null + } + if (bangumiTabs.isEmpty() && guochuangTabs.isEmpty()) { + mutableState.update { + it.copy(sections = emptyList(), error = failure?.readable() ?: "暂无榜单") + } + return + } + mutableState.update { it.copy(sections = buildSections(bangumiTabs, guochuangTabs)) } + } finally { + if (current == token) mutableState.update { it.copy(loading = false) } + } + + // 大会员对照表:两个 season_type 各一次;拿不到就当没有会员内容,不影响榜单。 + vipSeasons = loadVipSeasons() + if (current != token) return + + // 首屏三节;后面的由列表滚到再取。 + for (index in 0 until minOf(FIRST_SCREEN_SECTIONS, mutableState.value.sections.size)) { + ensureSection(index) + } + } + + private suspend fun loadVipSeasons(): Set { + val result = HashSet() + listOf(PGC_SEASON_TYPE_BANGUMI, PGC_SEASON_TYPE_GUOCHUANG).forEach { seasonType -> + when (val vip = repository.vipSeasons(seasonType)) { + is Outcome.Success -> result += vip.value + is Outcome.Failure -> Unit + } + } + return result + } + + private fun buildSections( + bangumi: List, + guochuang: List, + ): List { + val result = ArrayList(bangumi.size + guochuang.size) + val hotBangumi = bangumi.firstOrNull() + val hotGuochuang = guochuang.firstOrNull() + if (hotBangumi != null || hotGuochuang != null) { + result += BangumiRankSection( + title = "热门动画榜", + tabs = listOfNotNull(hotBangumi, hotGuochuang), + ) + } + // 番剧和国创都有「搞笑/热血/奇幻」等同名榜;同名只保留先出现的那个, + // 否则页面上会出现第二个热血榜、奇幻榜、搞笑榜。 + val seen = HashSet() + (bangumi.drop(1) + guochuang.drop(1)).forEach { tab -> + if (seen.add(tab.title)) result += BangumiRankSection(tab.title, listOf(tab)) + } + return result + } + + private fun updateSection(index: Int, block: (BangumiRankSection) -> BangumiRankSection) { + mutableState.update { current -> + val sections = current.sections + if (index !in sections.indices) return@update current + current.copy( + sections = sections.toMutableList().also { it[index] = block(it[index]) }, + ) + } + } + + private companion object { + const val PGC_INDEX_BANGUMI = 1 + const val PGC_INDEX_GUOCHUANG = 4 + const val PGC_SEASON_TYPE_BANGUMI = 1 + const val PGC_SEASON_TYPE_GUOCHUANG = 4 + const val FIRST_SCREEN_SECTIONS = 3 + } +} + +/** + * 错误文案与直播页同款。放在这里而不是跨 feature 引用 + * `feature.live.application.readable()`,是为了不把两个 feature 绑在一起。 + */ +internal fun AppError.readable(): String = when (this) { + is AppError.RiskControl -> "请求被风控,稍后重试" + AppError.LoginExpired -> "登录已失效" + is AppError.Network -> message.ifBlank { "网络错误" } + is AppError.Parsing -> "数据解析失败" + is AppError.Unknown -> message.ifBlank { "加载失败" } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/images/CommentPictures.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/images/CommentPictures.kt index e17cf211..09cb646a 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/images/CommentPictures.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/images/CommentPictures.kt @@ -33,7 +33,10 @@ internal fun CommentPictures(urls: List, onOpen: (Int, Rect, Float, List Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { urls.take(3).forEachIndexed { index, url -> AsyncImage( - model = rememberImageRequest(url), + // 缩略图按 96dp 缩小、但保持原始比例;查看器用同一张源图做中心裁切。 + model = rememberImageRequest( + com.aritxonly.bdfun.core.common.biliImageThumbUrl(url, 240), + ), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/page/CommentPageThread.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/page/CommentPageThread.kt index 5c82af78..bb2064fe 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/page/CommentPageThread.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/page/CommentPageThread.kt @@ -43,6 +43,7 @@ internal fun CommentPageThread(runtime: CommentPageRuntime, layout: CommentScree ui.replySheetFocusRpid = 0L }, ), + flatList = layout.flatList, ) } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentRow.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentRow.kt index 24400915..743bef90 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentRow.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentRow.kt @@ -6,15 +6,20 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.ui.layout.boundsInWindow -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.unit.dp import com.aritxonly.bdfun.core.model.comment.CommentItem -import androidx.compose.runtime.getValue @OptIn(ExperimentalFoundationApi::class) @Composable @@ -28,17 +33,40 @@ internal fun CommentRow(item: CommentItem, disliked: Boolean, actions: CommentRo val like by remember(item.rpid, item.like) { derivedStateOf { likes.countMap[item.rpid] ?: item.like } } + // combinedClickable 的 onLongClick 不给坐标,这里单独记一下按下的位置, + // 菜单就会弹在手指按下的地方,而不是固定贴在这一行的省略号上。 + var pressPosition by remember { mutableStateOf(Offset.Zero) } Column( modifier = Modifier .fillMaxWidth() .onGloballyPositioned { ui.bounds = it.boundsInWindow() } + // 只旁听指针位置,不消费任何事件:长按由 combinedClickable 判定, + // 我们只是把「按下的坐标」记下来,好让菜单弹在手指处。 + .pointerInput(item.rpid) { + awaitPointerEventScope { + while (true) { + val event = awaitPointerEvent(androidx.compose.ui.input.pointer.PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.pressed } ?: continue + pressPosition = change.position + } + } + } .combinedClickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = actions.onReply, - onLongClick = { actions.onLongPress(ui.bounds) }, + onLongClick = { + actions.onLongPress( + Rect( + left = ui.bounds.left + pressPosition.x, + top = ui.bounds.top + pressPosition.y, + right = ui.bounds.left + pressPosition.x, + bottom = ui.bounds.top + pressPosition.y, + ), + ) + }, ) .padding( horizontal = if (com.aritxonly.teak.common.theme.TeakUiStyle.isClassic) 6.dp else 12.dp, diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt index 58696e49..13d72192 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt @@ -7,8 +7,8 @@ internal object CommentTypography { val bodySize = 14.85.sp val bodyLineHeight = 21.8.sp - /** 折叠回复:正文比主评论小 1sp,用户名比折叠正文大 1sp(即回到主评论字号)。 */ + /** 折叠回复:正文比主评论小 1sp;用户名和后面的正文同样大小(不再放大 1sp)。 */ val previewBodySize = (bodySize.value - 1).sp val previewLineHeight = (bodyLineHeight.value - 1).sp - val previewNameSize = (previewBodySize.value + 1).sp + val previewNameSize = previewBodySize } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadContent.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadContent.kt index eb5d478b..e5f891cd 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadContent.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadContent.kt @@ -7,17 +7,19 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp +import com.aritxonly.teak.common.theme.LocalTeakContentColors @Composable internal fun androidx.compose.foundation.layout.BoxScope.CommentThreadContent(render: CommentThreadRender) { val progress = render.layout.progress - val background = MaterialTheme.colorScheme.background + // 主评论区(视频页/动态页)用的是 TeakContentColors,展开页必须同一份底色, + // 否则弹层和下面的评论区会明显差一个色调。 + val background = LocalTeakContentColors.current.background Column( modifier = Modifier .align(Alignment.TopCenter) @@ -29,15 +31,17 @@ internal fun androidx.compose.foundation.layout.BoxScope.CommentThreadContent(re } .background(background), ) { - CommentThreadPane(render) + CommentThreadPane(render, background) } } @Composable -private fun androidx.compose.foundation.layout.ColumnScope.CommentThreadPane(render: CommentThreadRender) { +private fun androidx.compose.foundation.layout.ColumnScope.CommentThreadPane( + render: CommentThreadRender, + background: androidx.compose.ui.graphics.Color, +) { val transient = render.transient val callbacks = render.callbacks - val background = MaterialTheme.colorScheme.background Box( modifier = Modifier .weight(1f) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadDialogContracts.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadDialogContracts.kt index 3f4592f3..df3f7f0c 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadDialogContracts.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadDialogContracts.kt @@ -11,6 +11,8 @@ internal data class ThreadSheetContent( internal data class ThreadSheetLayout( val progress: Float, val minTopPx: Float, val playerBottomDp: androidx.compose.ui.unit.Dp?, val paneWidth: androidx.compose.ui.unit.Dp?, val imeVisible: Boolean, val navBottom: androidx.compose.ui.unit.Dp, + /** 主评论区是否是「视频详情平铺」布局;展开页左右留白要跟着它走。 */ + val flatList: Boolean = false, ) internal class ThreadSheetCallbacks( val onDismiss: () -> Unit, val onToggleSort: () -> Unit, val onExitReply: () -> Unit, diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadFades.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadFades.kt index 14500fb9..24921539 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadFades.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadFades.kt @@ -7,14 +7,20 @@ 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.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import top.yukonga.miuix.kmp.basic.Icon +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.extended.Close import top.yukonga.miuix.kmp.theme.MiuixTheme @Composable @@ -45,7 +51,7 @@ internal fun androidx.compose.foundation.layout.BoxScope.CommentThreadFades( .align(Alignment.TopCenter) .fillMaxWidth() .height(headerHeight) - .padding(start = 16.dp), + .padding(start = 16.dp, end = 8.dp), contentAlignment = Alignment.CenterStart, ) { Text( @@ -54,6 +60,26 @@ internal fun androidx.compose.foundation.layout.BoxScope.CommentThreadFades( fontSize = 15.sp, fontWeight = FontWeight.Medium, ) + // 右上角关闭键:和返回键同一个效果、同一套尺寸比例(40dp 热区 + 22dp 图标)。 + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .size(40.dp) + .clip(CircleShape) + .clickable( + interactionSource = null, + indication = null, + onClick = { callbacks.onDismiss() }, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = MiuixIcons.Close, + contentDescription = "关闭", + tint = colors.onBackgroundVariant, + modifier = Modifier.size(22.dp), + ) + } } Box( modifier = Modifier diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadList.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadList.kt index ff8c120e..9e3ec4d7 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadList.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadList.kt @@ -31,8 +31,9 @@ internal fun CommentThreadList(render: CommentThreadRender) { .fillMaxSize() .nestedScroll(playerCollapseConnection), contentPadding = PaddingValues( - start = 12.dp, - end = 12.dp, + // 左右留白必须和主评论区一致:平铺列表(视频详情)是 4dp,其它入口 12dp。 + start = if (render.layout.flatList) 4.dp else 12.dp, + end = if (render.layout.flatList) 4.dp else 12.dp, top = headerHeight + 8.dp, bottom = if (transient.replyTarget != null) 78.dp else 16.dp, ), diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadSheet.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadSheet.kt index d88d5a2e..6c5e5976 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadSheet.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/thread/CommentThreadSheet.kt @@ -9,6 +9,8 @@ import androidx.compose.runtime.* @Composable internal fun CommentThreadSheet( input: CommentThreadInput, viewport: CommentThreadViewport, navigation: CommentThreadNavigation, + /** 和主评论区同一套左右留白:视频详情是平铺列表(4dp),其它入口 12dp。 */ + flatList: Boolean = false, ) { val runtime = rememberCommentThreadRuntime(input) val state by key(runtime.controller) { runtime.controller.state.collectAsState() } @@ -33,7 +35,7 @@ internal fun CommentThreadSheet( CommentThreadDialog( content = ThreadSheetContent(input.comment, allReplies, state.extra.size, input.loading, input.end, input.sortHot, actions.sending), - layout = ThreadSheetLayout(progress, viewport.minTopPx, viewport.playerBottomDp, viewport.paneWidth, imeVisible, navBottom), + layout = ThreadSheetLayout(progress, viewport.minTopPx, viewport.playerBottomDp, viewport.paneWidth, imeVisible, navBottom, flatList), transient = runtime.transient, listState = runtime.listState, playerCollapseConnection = connection, focusRequester = runtime.focusRequester, callbacks = ThreadSheetCallbacks(navigation.onDismiss, navigation.onToggleSort, runtime::exitReply, diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt index 6d03f94b..50f9c3e2 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt @@ -16,14 +16,20 @@ import com.aritxonly.teak.common.chrome.TeakGlass import com.kyant.shapes.Capsule import top.yukonga.miuix.kmp.theme.MiuixTheme -/** 直播 / 番剧两段共用的小节标题。 */ +/** + * 直播 / 番剧两段共用的小节标题。 + * + * 字号/字重和番剧页榜单分节标题(`BangumiSectionTitle`)完全一致(20sp / Bold / 24sp 行高), + * 全站只保留这一份:不要再各自的页面里另写小标题样式,否则一定会漂。 + */ @Composable internal fun DiscoverSectionTitle(text: String) { Text( text = text, color = MiuixTheme.colorScheme.onBackground, - fontSize = 15.sp, - fontWeight = FontWeight.Medium, + fontSize = 20.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 2.dp, bottom = 8.dp), ) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt index 19ac70be..b6e245f6 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt @@ -15,21 +15,31 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text 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.draw.clip +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.aritxonly.teak.common.chrome.TeakChromeDefaults +import com.aritxonly.teak.common.settings.TeakDropdownPanel import kotlinx.coroutines.launch import kotlin.math.max import top.yukonga.miuix.kmp.basic.Icon import top.yukonga.miuix.kmp.icon.MiuixIcons import top.yukonga.miuix.kmp.icon.extended.Close +import top.yukonga.miuix.kmp.theme.MiuixTheme /** * 图片层 + 黑底 + 上方 chrome。 @@ -46,6 +56,27 @@ internal fun PhotoViewerOverlay( // 底色随进度淡入;拖动时同步变透明,松手退出会自然回到页面。 val backgroundAlpha = (view.progress.value * (1f - view.dismissProgress)).coerceIn(0f, 1f) val scope = rememberCoroutineScope() + val context = androidx.compose.ui.platform.LocalContext.current + // 关闭时只把 visible 置 false,锚点保留:否则退场动画会瞬移到 (0,0), + // 看起来就是"点空白后菜单乱跳一下"。 + var menuVisible by remember { mutableStateOf(false) } + var menuAnchor by remember { mutableStateOf(Rect.Zero) } + var menuUrl by remember { mutableStateOf("") } + val menuScope = rememberCoroutineScope() + + fun save(urls: List) { + if (urls.isEmpty()) return + menuScope.launch { + val (saved, total) = ImageSaver.save(context, urls) + context.showToast( + when { + saved == total && total == 1 -> "已保存到相册" + saved == total -> "已保存 $saved 张图片" + else -> "已保存 $saved/$total 张,部分失败" + }, + ) + } + } // 返回键在这里拦:覆盖层在内容之后组合,BackHandler 后注册才优先, // 否则会被页面自己的返回处理器抢走,直接退出 Activity。 @@ -53,11 +84,61 @@ internal fun PhotoViewerOverlay( Box(Modifier.fillMaxSize().photoViewerDragGestures(view, scope, onDismiss)) { Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = backgroundAlpha))) - PhotoViewerPager(request, view, onDismiss) + PhotoViewerPager(request, view, onDismiss) { url, offset -> + // 和长按评论完全同一套:菜单锚在手指按下的那个点上,位置交给共用菜单自己收边。 + menuUrl = url + menuAnchor = Rect(offset.x, offset.y, offset.x, offset.y) + menuVisible = true + } PhotoViewerChrome(request, view, onDismiss) } + + val images = request.images + PhotoImageMenu( + visible = menuVisible, + anchor = menuAnchor, + onDismiss = { menuVisible = false }, + onSaveCurrent = { save(listOf(menuUrl)) }, + onSaveAll = { save(images) }, + // 图片举报接口还没接,先如实提示,不做假成功。 + onReport = { context.showToast("举报功能稍后接入") }, + ) +} + +private val PhotoDangerRed = Color(0xFFE5484D) + +/** 长按图片弹出的菜单:和长按评论同一个玻璃菜单(TeakGlassMenu),不是设置页那种 Dropdown。 */ +@Composable +private fun PhotoImageMenu( + visible: Boolean, + anchor: Rect, + onDismiss: () -> Unit, + onSaveCurrent: () -> Unit, + onSaveAll: () -> Unit, + onReport: () -> Unit, +) { + val colors = MiuixTheme.colorScheme + TeakGlassMenu( + visible = visible, + onDismiss = onDismiss, + anchorInWindow = anchor, + minWidth = 168.dp, + maxWidth = 216.dp, + maxHeight = 420.dp, + ) { + CommentMenuRow("保存该图片", MenuGlyphKind.Copy, colors.onBackground) { + onDismiss(); onSaveCurrent() + } + CommentMenuRow("保存全部图片", MenuGlyphKind.Copy, colors.onBackground) { + onDismiss(); onSaveAll() + } + CommentMenuRow("举报", MenuGlyphKind.Report, PhotoDangerRed) { + onDismiss(); onReport() + } + } } + /** Telegram 的顶部只有一个关闭按钮,底部是发起方提供的操作区,两者一起淡入淡出。 */ @Composable private fun PhotoViewerChrome( @@ -109,6 +190,7 @@ private fun PhotoViewerPager( request: PhotoViewerRequest, view: PhotoViewerTransitionState, onDismiss: () -> Unit, + onLongPress: (String, androidx.compose.ui.geometry.Offset) -> Unit, ) { val pager = rememberPagerState( initialPage = request.index.coerceIn(0, request.images.lastIndex), @@ -124,6 +206,7 @@ private fun PhotoViewerPager( origin = origin, originRadiusPx = if (origin != null) request.originRadiusPx else 0f, onDismiss = onDismiss, + onLongPress = onLongPress, ) } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPage.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPage.kt index b04959e7..239b12dc 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPage.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPage.kt @@ -1,5 +1,7 @@ package com.aritxonly.bdfun.ui +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.calculatePan @@ -11,7 +13,10 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset @@ -26,6 +31,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntSize import coil.compose.AsyncImage +import com.aritxonly.bdfun.core.common.biliImageThumbUrl import kotlin.math.abs import kotlin.math.roundToInt @@ -36,6 +42,9 @@ import kotlin.math.roundToInt * 倍率是刚好铺满它的裁切倍率,所以第一帧和列表里的缩略图完全重合;终点倍率为 1。 * 注意裁剪窗口不能用 size 去约束图片——那样图片会被父约束压小,再乘裁切倍率就成了一个点。 * 这里图片始终按完整尺寸布局,只在绘制阶段用路径裁剪。 + * + * 两层图:底层是列表里同一张 16:9 裁切缩略图(缓存命中,入场/退场第一帧就贴合), + * 上层是原图,加载完成后淡入。这样既不会出现"切到原图后闪一下",也能保证放大后是完整图。 */ @Composable internal fun PhotoViewerPage( @@ -46,10 +55,20 @@ internal fun PhotoViewerPage( origin: androidx.compose.ui.geometry.Rect?, originRadiusPx: Float, onDismiss: () -> Unit, + /** 长按图片:弹出保存菜单,位置用长按点(窗口坐标)。 */ + onLongPress: (String, Offset) -> Unit = { _, _ -> }, ) { val page = remember(url) { PhotoViewerPageState() } LaunchedEffect(page.zoom) { view.zoom = page.zoom } + // 原图就绪后淡入,避免入场动画期间"空一下再跳出来"。 + var fullReady by remember(url) { mutableStateOf(false) } + val fullAlpha by animateFloatAsState( + targetValue = if (fullReady) 1f else 0f, + animationSpec = tween(durationMillis = 160), + label = "photo-full-fade", + ) + val density = LocalDensity.current val screenWidth = view.size.width.toFloat() val screenHeight = view.size.height.toFloat() @@ -68,31 +87,26 @@ internal fun PhotoViewerPage( ) // 拖动位移随进度衰减:进度归零时正好落在缩略图上,不会带着偏移落地再跳回去。 val dragY = view.dragY * view.progress.value + // 缩放/平移也跟着进度收敛:放大过再退出时,画面会平滑回到缩略图而不是"啪"一下跳回去。 + val settle = view.progress.value + val zoomNow = 1f + (page.zoom - 1f) * settle + val panNow = page.pan * settle // 完全展开后不再裁剪:否则放大后的图片会被自身矩形(比屏幕小)切掉。 val inTransition = view.progress.value < 0.999f val imageWidthDp = with(density) { morph.imageWidth.toDp() } val imageHeightDp = with(density) { morph.imageHeight.toDp() } + // 和列表缩略图完全同一张(同样只缩宽度、不裁切),Coil 直接命中缓存。 + val thumbModel = remember(url) { biliImageThumbUrl(url, PhotoThumbWidth) } Box( modifier = Modifier .fillMaxSize() .pointerInput(morph.clipLeft, morph.clipTop, morph.clipWidth, morph.clipHeight) { detectTapGestures( - // 点图片本体还是原来的「显隐 chrome」;点图片外的空白等同返回键, - // 不再落给 Pager 或拖动判定。 - onTap = { tap -> - val insideImage = view.progress.value > 0.99f && - tap.x >= morph.clipLeft && - tap.x <= morph.clipLeft + morph.clipWidth && - tap.y >= morph.clipTop && - tap.y <= morph.clipTop + morph.clipHeight - if (insideImage) { - view.chromeVisible = !view.chromeVisible - } else { - onDismiss() - } - }, + // 点画面任意位置都是关闭(不再要求必须点到图片外的空白)。 + onTap = { onDismiss() }, onDoubleTap = { tap -> page.zoom = toggleZoom(page.zoom, tap, view.size) }, + onLongPress = { tap -> onLongPress(url, tap) }, ) } .photoZoomGestures( @@ -105,41 +119,69 @@ internal fun PhotoViewerPage( .graphicsLayer { translationY = dragY }, ) { Box(Modifier.fillMaxSize().morphClip(morph, skipClip = !inTransition)) { + // 原图还没就绪时只画缩略图:它和卡片是同一张源图、同样的中心裁切, + // 所以入场第一帧、退场最后一帧都和列表里的缩略图完全重合。 + if (fullAlpha < 0.999f) { + MorphImage( + model = thumbModel, + morph = morph, + width = imageWidthDp, + height = imageHeightDp, + zoom = zoomNow, + pan = panNow, + // Crop:宽高比还没算出来时也保证铺满框,不会出现黑边。 + contentScale = ContentScale.Crop, + // 缩略图也报一次宽高比:它命中缓存后立刻就能开始入场动画。 + onLoaded = { width, height -> + if (width > 0 && height > 0) page.aspect = width.toFloat() / height + view.contentReady = true + }, + ) + } MorphImage( - url = url, + model = url, morph = morph, width = imageWidthDp, height = imageHeightDp, - zoom = page.zoom, - pan = page.pan, - onAspect = { page.aspect = it }, + zoom = zoomNow, + pan = panNow, + alpha = fullAlpha, + onLoaded = { width, height -> + if (width > 0 && height > 0) page.aspect = width.toFloat() / height + fullReady = true + // 告诉宿主:宽高比已经确定,可以放心跑入场动画了。 + view.contentReady = true + }, ) } } } +/** 和列表一致的缩略图宽度;宽度决定裁切尺寸,必须和 FeedImageGrid 保持同步。 */ +private const val PhotoThumbWidth = 720 + /** 图片按完整尺寸布局,用 offset 定位中心、graphicsLayer 做裁切倍率,布局约束永远不参与裁剪。 */ @Composable private fun MorphImage( - url: String, + model: Any?, morph: PhotoMorphFrame, width: Dp, height: Dp, zoom: Float, pan: Offset, - onAspect: (Float) -> Unit, + alpha: Float = 1f, + contentScale: ContentScale = ContentScale.Fit, + onLoaded: ((Int, Int) -> Unit)? = null, ) { AsyncImage( - model = rememberImageRequest(url, crossfade = false), + model = rememberImageRequest(model, crossfade = false), contentDescription = null, - contentScale = ContentScale.Fit, + contentScale = contentScale, onSuccess = { state -> // 用 drawable 的固有尺寸而不是 painter.intrinsicSize:Coil 2.x 里前者一定有, // 后者在部分请求类型下会是 Unspecified。 val drawable = state.result.drawable - val imageWidth = drawable.intrinsicWidth - val imageHeight = drawable.intrinsicHeight - if (imageWidth > 0 && imageHeight > 0) onAspect(imageWidth.toFloat() / imageHeight) + onLoaded?.invoke(drawable.intrinsicWidth, drawable.intrinsicHeight) }, modifier = Modifier .offset { @@ -155,6 +197,7 @@ private fun MorphImage( scaleY = scale translationX = pan.x translationY = pan.y + this.alpha = alpha }, ) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPageState.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPageState.kt index e35e16b6..f196fd9a 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPageState.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPageState.kt @@ -13,7 +13,11 @@ import androidx.compose.ui.geometry.Offset * 是项目约定(mutableStateOf 只允许出现在 StateHolder 内部)。 */ internal class PhotoViewerPageState { - var aspect by mutableFloatStateOf(0f) + /** + * 初始值用缩略图的 16:9 —— 列表里的图都是 `@w_h_1c` 的 16:9 裁切, + * 这样入场第一帧的几何和卡片上的缩略图完全一致;原图加载好后再换成真实宽高比。 + */ + var aspect by mutableFloatStateOf(16f / 9f) var zoom by mutableFloatStateOf(1f) var pan by mutableStateOf(Offset.Zero) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerRoot.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerRoot.kt index d35a295b..9ce1bdd3 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerRoot.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerRoot.kt @@ -15,10 +15,16 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.graphics.Color +import com.aritxonly.teak.common.material.LocalTeakBackdrop +import kotlinx.coroutines.flow.first +import top.yukonga.miuix.kmp.blur.layerBackdrop +import top.yukonga.miuix.kmp.blur.rememberLayerBackdrop /** * 窗口级图片查看器宿主。 @@ -39,6 +45,11 @@ internal fun PhotoViewerRoot( val view = remember { PhotoViewerTransitionState() } val incoming = state.request val spec: AnimationSpec = rememberPhotoAnimationSpec(animationsEnabled) + // 只在查看器可见时录制;黑底先铺一层,玻璃采样到的就是"黑底 + 大图"。 + val viewerBackdrop = rememberLayerBackdrop { + drawRect(Color.Black) + drawContent() + } LaunchedEffect(incoming) { when { @@ -46,6 +57,7 @@ internal fun PhotoViewerRoot( view.displayed != null -> view.hide(spec) } } + Box(Modifier.fillMaxSize().onSizeChanged { view.size = it }) { // 底层页面:打开时随进度放大一点点,关闭时按同样倍数缩回; // 跟手拖动时再缩到 0.9。返回键的拦截放在覆盖层里(后注册才优先)。 @@ -59,7 +71,13 @@ internal fun PhotoViewerRoot( CompositionLocalProvider(LocalPhotoViewer provides state) { content() } } view.displayed?.let { request -> - PhotoViewerOverlay(request = request, view = view, onDismiss = state::close) + // 查看器是窗口级覆盖层,外面没有主壳的 backdrop;这里自己录一份, + // 长按菜单才能像别处一样采样到大图、做出真正的玻璃模糊。 + CompositionLocalProvider(LocalTeakBackdrop provides viewerBackdrop) { + Box(Modifier.fillMaxSize().layerBackdrop(viewerBackdrop)) { + PhotoViewerOverlay(request = request, view = view, onDismiss = state::close) + } + } } } } @@ -89,6 +107,13 @@ internal class PhotoViewerTransitionState { var zoom by mutableFloatStateOf(1f) var chromeVisible by mutableStateOf(true) var size by mutableStateOf(IntSize.Zero) + /** + * 首页那一页是否已经知道原图真实宽高比。 + * + * 入场动画必须等它:缩略图是 16:9 裁切、原图可能是 3:4, + * 如果动画先按 16:9 跑、加载完再改比例,几何会中途重算——看起来就是"从中间弹出来"。 + */ + var contentReady by mutableStateOf(false) val dismissProgress: Float get() = photoDismissProgress(dragY, size.height.toFloat()) @@ -97,13 +122,19 @@ internal class PhotoViewerTransitionState { // 已经打开时只换内容,不重播入场——Telegram 里左右翻页就是这个行为。 if (displayed != null && progress.value > 0.99f) { displayed = request + contentReady = true return } displayed = request + contentReady = false progress.snapTo(0f) dragY = 0f zoom = 1f chromeVisible = true + // 等原图报出真实宽高比再开始动画;最多等 320ms,慢网也不会卡住页面。 + kotlinx.coroutines.withTimeoutOrNull(320) { + snapshotFlow { contentReady }.first { it } + } progress.animateTo(1f, spec) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedAuthorSidebar.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedAuthorSidebar.kt index 206f55e9..e3daeda2 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedAuthorSidebar.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedAuthorSidebar.kt @@ -45,16 +45,7 @@ internal fun FeedAuthorSidebar(state: FeedState, modifier: Modifier = Modifier) scope.launch { state.clearFilter() } } } - if (state.liveCount > 0) { - item(key = "live-toggle") { - Text("正在直播 · ${state.liveCount} ${if (state.showLive) "收起" else "展开"}", - fontSize = 12.sp, color = colors.onBackgroundVariant, - modifier = Modifier.fillMaxWidth().clickable { state.showLive = !state.showLive }.padding(10.dp)) - } - if (state.showLive) items(state.liveUsers, key = { "live-${it.roomId}" }) { live -> - FeedAuthorRow(live.name, live.face, false, false, live = true) { openFeedLive(context, live.roomId) } - } - } + // 正在直播一行已按产品要求整体移除,侧栏只保留全部动态 / 我 / 关注的 UP。 AppGraph.session.value?.let { me -> item(key = "me") { FeedAuthorRow("我", me.face, state.filterMid == me.mid, false) { diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedAvatarCard.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedAvatarCard.kt index df2c536d..f5d88a14 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedAvatarCard.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedAvatarCard.kt @@ -49,24 +49,7 @@ fun FeedAvatarCard( } } Column(modifier.fillMaxWidth().clip(ContinuousCornerShape(18.dp)).background(colors.surfaceContainer)) { - if (state.liveCount > 0) { - Row(Modifier.fillMaxWidth().clickable { state.showLive = !state.showLive } - .padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically) { - Text("正在直播 · ${state.liveCount}", color = colors.onSurfaceContainer, fontSize = 14.sp, - modifier = Modifier.weight(1f)) - Text(if (state.showLive) "收起" else "展开", color = colors.onSurfaceVariantSummary, fontSize = 12.sp) - } - if (state.showLive) LazyRow( - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - items(state.liveUsers, key = { it.roomId }) { live -> - AuthorAvatar(name = live.name, face = live.face, selected = false, badge = "直播") { - openFeedLive(context, live.roomId) - } - } - } - } + // 这里只保留「全部动态 / 我 / 关注的 UP」;正在直播那一行已经整体去掉。 val entries: LazyListScope.() -> Unit = { item(key = "all-dynamics") { AllFeedAvatar(selected = state.type == "all") { diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedImageGrid.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedImageGrid.kt index 1f87d68c..534b3903 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedImageGrid.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedImageGrid.kt @@ -36,7 +36,8 @@ internal fun FeedImageGrid( if (images.size == 1) { val url = images.first() AsyncImage( - model = rememberImageRequest(url), + // 列表里只要一张裁好的缩略图;交给查看器的是原始 url(不裁切)。 + model = rememberImageRequest(biliImageGridUrl(url)), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -58,7 +59,7 @@ internal fun FeedImageGrid( ) { row.forEach { url -> AsyncImage( - model = rememberImageRequest(url), + model = rememberImageRequest(biliImageGridUrl(url)), contentDescription = null, contentScale = ContentScale.Crop, modifier = Modifier @@ -78,3 +79,13 @@ internal fun FeedImageGrid( } /** 主动态和转发动态共用同一套大卡视频布局。 */ + +/** + * 缩略图请求:按显示宽度缩小的**未裁切**原图。 + * + * 不能再用 `_1c`(服务端居中裁 16:9):列表再裁一次、查看器按原图裁一次, + * 两次结果只有 16:9 的图才一致,其他比例收放都会错位。 + * 现在两边都从同一张原图做中心裁切,共用元素动画才对得上。 + */ +private fun biliImageGridUrl(url: String): String = + com.aritxonly.bdfun.core.common.biliImageThumbUrl(url, 720) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedLiveNavigation.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedLiveNavigation.kt index 65a23c57..bff0f5e6 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedLiveNavigation.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/feed/FeedLiveNavigation.kt @@ -1,10 +1,11 @@ package com.aritxonly.bdfun.ui +/** + * 动态页里的直播入口(头像卡 / 侧栏)统一进站内直播间。 + * 以前这里直接 ACTION_VIEW 扔给浏览器,点一下就跳出 App,和直播页的处理不一致。 + */ internal fun openFeedLive(context: android.content.Context, roomId: Long) { if (roomId <= 0) return - runCatching { - context.startActivity(android.content.Intent(android.content.Intent.ACTION_VIEW, - android.net.Uri.parse("https://live.bilibili.com/$roomId"))) - }.onFailure { context.showToast("无法打开直播间") } + com.aritxonly.bdfun.navigateToLiveRoom(context, roomId) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt index 2e208814..b2b87ccc 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt @@ -19,7 +19,6 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyGridScope import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -29,40 +28,49 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage -import com.aritxonly.bdfun.core.model.feed.FeedLiveRecommendation import com.aritxonly.bdfun.core.model.live.LiveArea import com.aritxonly.bdfun.core.model.live.LiveCard import com.aritxonly.bdfun.feature.live.application.LivePageStateHolder import com.aritxonly.bdfun.presentation.common.discover.DiscoverErrorState import com.aritxonly.bdfun.presentation.common.discover.DiscoverSectionTitle import com.aritxonly.bdfun.navigateToLogin +import com.aritxonly.bdfun.ui.AnchorBadge +import com.aritxonly.bdfun.ui.ContinuousCornerShape import com.aritxonly.bdfun.ui.FeedCardShape -import com.aritxonly.bdfun.ui.FeedLiveCard +import com.aritxonly.bdfun.ui.LiveAvatar import com.aritxonly.bdfun.ui.TeakLoading import com.aritxonly.bdfun.ui.rememberImageRequest -import com.aritxonly.teak.common.chrome.TeakGlass import com.kyant.shapes.Capsule import kotlinx.coroutines.launch import top.yukonga.miuix.kmp.theme.MiuixTheme -/** 第一行:关注的正在直播,直接复用动态页那个圆角直播卡片。 */ -internal fun LazyGridScope.liveFollowSection(state: LivePageStateHolder) { - if (state.following.isEmpty()) return +/** + * 第一行:我的关注里正在直播的 UP。 + * 小标题在卡片外面(和番剧页「最近追番」同款大小/位置),圆角卡片只包住头像行。 + */ +internal fun LazyGridScope.liveFollowSection(state: LivePageStateHolder, onOpenRoom: (Long) -> Unit) { + if (state.followers.isEmpty()) return item(key = "live-follow", span = { GridItemSpan(maxLineSpan) }) { - Column { - DiscoverSectionTitle("关注的直播") - LazyRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = PaddingValues(bottom = 2.dp), + Column(Modifier.fillMaxWidth()) { + // 和番剧页小节标题同一份实现(DiscoverSectionTitle:20sp Bold)。 + DiscoverSectionTitle("正在直播") + Column( + modifier = Modifier + .fillMaxWidth() + .clip(ContinuousCornerShape(18.dp)) + .background(MiuixTheme.colorScheme.surfaceContainer) + .padding(horizontal = 10.dp, vertical = 8.dp), ) { - items(state.following, key = { it.roomId }) { live -> - Box(modifier = Modifier.width(250.dp)) { - FeedLiveCard(live.toFeedLive()) + LazyRow( + horizontalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = PaddingValues(vertical = 2.dp), + ) { + items(state.followers, key = { it.roomId }) { user -> + LiveAvatar(name = user.name, face = user.face) { onOpenRoom(user.roomId) } } } } @@ -70,7 +78,7 @@ internal fun LazyGridScope.liveFollowSection(state: LivePageStateHolder) { } } -/** 第二行:分区胶囊,收藏夹列表同款(选中没有高光描边)。 */ +/** 第二行:分区胶囊,形状/尺寸和顶栏分段指示器一致。 */ internal fun LazyGridScope.liveAreaSection( state: LivePageStateHolder, scope: kotlinx.coroutines.CoroutineScope, @@ -78,10 +86,10 @@ internal fun LazyGridScope.liveAreaSection( if (state.areas.isEmpty()) return item(key = "live-areas", span = { GridItemSpan(maxLineSpan) }) { Column { - Spacer(Modifier.height(if (state.following.isEmpty()) 0.dp else 12.dp)) + Spacer(Modifier.height(if (state.followers.isEmpty()) 0.dp else 4.dp)) LazyRow( horizontalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(vertical = 6.dp), + contentPadding = PaddingValues(vertical = 4.dp), ) { item(key = "area-all") { LiveAreaChip( @@ -90,7 +98,7 @@ internal fun LazyGridScope.liveAreaSection( onClick = { scope.launch { state.selectArea(LiveArea(0L, "推荐")) } }, ) } - items(state.areas, key = { "${it.parentId}-${it.id}" }) { area -> + items(state.areas, key = { "${it.parentAreaId}-${it.id}" }) { area -> LiveAreaChip( text = area.name, selected = state.selectedKey == area.id, @@ -124,7 +132,7 @@ internal fun LazyGridScope.livePageStateItems( } } } - if (!loggedIn && state.following.isEmpty()) { + if (!loggedIn && state.followers.isEmpty()) { item(key = "live-login", span = { GridItemSpan(maxLineSpan) }) { val context = LocalContext.current Box( @@ -150,17 +158,22 @@ internal fun LazyGridScope.livePageStateItems( @Composable private fun LiveAreaChip(text: String, selected: Boolean, onClick: () -> Unit) { val colors = MiuixTheme.colorScheme + // 形状/尺寸和顶栏分段指示器保持一致:32dp 高、全圆角、选中粉色填充 + 白字。 Box( modifier = Modifier - .clip(RoundedCornerShape(50)) - .background(if (selected) colors.primary else colors.surfaceContainer) + .height(32.dp) + .clip(Capsule()) + .background( + if (selected) colors.primary.copy(alpha = 0.92f) else colors.surfaceContainer, + ) .clickable(interactionSource = null, indication = null, onClick = onClick) - .padding(horizontal = 14.dp, vertical = 6.dp), + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center, ) { Text( text = text, - color = if (selected) Color.White else colors.onBackground, - fontSize = 12.sp, + color = if (selected) Color.White else colors.onBackgroundVariant, + fontSize = 13.sp, maxLines = 1, ) } @@ -217,22 +230,18 @@ internal fun LiveGridCard(live: LiveCard, onClick: () -> Unit) { modifier = Modifier.heightIn(min = 36.dp), ) Spacer(Modifier.height(5.dp)) - Text( - text = live.uname, - color = colors.onBackgroundVariant, - fontSize = 11.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + // 主播名前缀「主播」:和首页卡片的「UP」标签同一个组件,只换文字。 + Row(verticalAlignment = Alignment.CenterVertically) { + AnchorBadge() + Spacer(Modifier.width(4.dp)) + Text( + text = live.uname, + color = colors.onBackgroundVariant, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } - -private fun LiveCard.toFeedLive() = FeedLiveRecommendation( - roomId = roomId, - title = title, - cover = cover, - live = true, - areaName = areaName, - watched = watchedText, -) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageScreen.kt index b6cdb81e..35ce812f 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageScreen.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageScreen.kt @@ -27,6 +27,7 @@ import com.aritxonly.bdfun.navigateToLiveRoom import com.aritxonly.bdfun.ui.HomeTopBarHeight import com.aritxonly.bdfun.presentation.common.discover.DiscoverPullSession import com.aritxonly.bdfun.presentation.common.discover.DiscoverPullUi +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -61,7 +62,7 @@ internal fun LivePage(state: LivePageStateHolder, loggedIn: Boolean) { horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.CenterHorizontally), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - liveFollowSection(state) + liveFollowSection(state) { navigateToLiveRoom(context, it) } liveAreaSection(state, scope) items(state.cards, key = { it.roomId }) { live -> LiveGridCard(live) { navigateToLiveRoom(context, live.roomId) } @@ -89,7 +90,10 @@ private fun rememberLivePullSession( state.pullFraction = (it / threshold).coerceIn(0f, 1f) }, onPullDp = { state.pullOffsetDp = it }, - canScrollBackward = { !state.gridState.canScrollBackward }, + // 这里传的是「网格自己能不能往回滚」,不是取反后的值: + // DiscoverPullSession 内部已经写了 !canScrollBackward(), + // 以前多取反一次,导致只要不在顶部、往回一拖就开始刷新。 + canScrollBackward = { state.gridState.canScrollBackward }, isRefreshing = { state.refreshing || state.loading }, onRefresh = { // 与推荐页一致:松手立即把指示器顶到阈值,再开始刷新。 @@ -111,12 +115,12 @@ private fun LivePageEffects(state: LivePageStateHolder, pull: DiscoverPullSessio pull.animatePullTo(0f, 260f) } } - // 滚到底部预取下一页 + // 滚到底部预取下一页:与推荐页同一套(drop(1) 忽略首次布局,按卡片数比较) LaunchedEffect(state) { snapshotFlow { state.gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } + .drop(1) .collect { last -> - val total = state.gridState.layoutInfo.totalItemsCount - if (state.cards.isNotEmpty() && total > 0 && last >= total - 2) state.loadMore() + if (state.cards.isNotEmpty() && last >= state.cards.size - 2) state.loadMore() } } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/PortraitLiveLayout.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/PortraitLiveLayout.kt index 69cf85ad..ef978421 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/PortraitLiveLayout.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/PortraitLiveLayout.kt @@ -144,9 +144,16 @@ internal fun LiveRoomRender.PortraitLivePlayer() { @Composable private fun LiveRoomRender.PortraitLiveHeader() { Box( + // 顶栏也铺页面背景(BackdropGlow),只叠一层自上而下的深色渐变保证文字可读。 + // 原来这里是纯黑色块,和下半部分有背景的画面之间会切出一条硬接缝。 Modifier .fillMaxWidth() - .background(Color(0xFF0B0C0F)), + .background( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.55f), + 1f to Color.Transparent, + ), + ), ) { LiveTopBar( room = room, diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageItems.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageItems.kt index 731956ba..75f1f124 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageItems.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageItems.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -43,22 +44,25 @@ import com.aritxonly.bdfun.ui.rememberImageRequest import kotlinx.coroutines.launch import top.yukonga.miuix.kmp.theme.MiuixTheme -/** 第一行:最近追番,横向长封面。 */ +/** 第一项固定是「最近追番」:即使列表为空/还在加载也保留这一节, + * 这样网格的第 0 项永远不变,进页面就锚在顶部,不会因为数据晚到被带到榜单上。 */ internal fun LazyGridScope.bangumiFollowSection( state: BangumiPageStateHolder, context: android.content.Context, ) { - if (state.following.isEmpty()) return item(key = "pgc-follow", span = { GridItemSpan(maxLineSpan) }) { + val openScope = rememberCoroutineScope() Column { - DiscoverSectionTitle("最近追番") - LazyRow( - horizontalArrangement = Arrangement.spacedBy(10.dp), - contentPadding = PaddingValues(bottom = 2.dp), - ) { - items(state.following, key = { it.seasonId }) { item -> - Box(Modifier.width(104.dp)) { - BangumiCardItem(item) { openBangumi(context, item.seasonId) } + BangumiSectionTitle("最近追番") + if (state.following.isNotEmpty()) { + LazyRow( + horizontalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(bottom = 2.dp), + ) { + items(state.following, key = { it.seasonId }) { item -> + Box(Modifier.width(104.dp)) { + BangumiCardItem(item) { openBangumi(context, item.seasonId, openScope) } + } } } } @@ -115,8 +119,8 @@ internal fun BangumiCardItem(item: BangumiCard, onClick: () -> Unit) { Modifier .align(Alignment.BottomCenter) .fillMaxWidth() - .height(42.dp) - .background(Brush.verticalGradient(listOf(Color.Transparent, Color(0x4D000000)))), + .height(56.dp) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color(0x99000000)))), ) if (item.followText.isNotBlank()) { Text( @@ -141,12 +145,13 @@ internal fun BangumiCardItem(item: BangumiCard, onClick: () -> Unit) { ) Spacer(Modifier.height(2.dp)) // 固定高度:没有「全x话」的卡片也留出同样空间,保证一排卡片底边齐平。 - Box(Modifier.height(15.dp), contentAlignment = Alignment.CenterStart) { + Box(Modifier.height(18.dp), contentAlignment = Alignment.CenterStart) { if (item.indexShow.isNotBlank()) { Text( text = item.indexShow, color = colors.onBackgroundVariant, fontSize = 10.5.sp, + lineHeight = 14.sp, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -171,16 +176,40 @@ private fun androidx.compose.foundation.layout.BoxScope.BangumiVipBadge() { } } -internal fun openBangumi(context: android.content.Context, seasonId: Long) { +/** 解析首集后进视频详情页(复用它的播放/评论/交互),失败才退回网页。 */ +internal fun openBangumi(context: android.content.Context, seasonId: Long, scope: kotlinx.coroutines.CoroutineScope) { if (seasonId <= 0L) return - runCatching { - context.startActivity( - android.content.Intent( - android.content.Intent.ACTION_VIEW, - android.net.Uri.parse("https://www.bilibili.com/bangumi/play/ss$seasonId"), - ), - ) - }.onFailure { - android.widget.Toast.makeText(context, "无法打开番剧", android.widget.Toast.LENGTH_SHORT).show() + scope.launch { + val bvid = runCatching { com.aritxonly.bdfun.AppGraph.bangumiFirstEpisode(seasonId) } + .getOrNull()?.bvid.orEmpty() + if (bvid.isNotBlank()) { + val opened = runCatching { + context.startActivity( + android.content.Intent(context, com.aritxonly.bdfun.VideoActivity::class.java) + .putExtra("bvid", bvid), + ) + }.isSuccess + if (opened) return@launch + } + runCatching { + context.startActivity( + android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse("https://www.bilibili.com/bangumi/play/ss$seasonId"), + ), + ) + }.onFailure { + android.widget.Toast.makeText(context, "无法打开番剧", android.widget.Toast.LENGTH_SHORT).show() + } + } +} + + +/** 番剧打开动作:解析首集要发请求,所以绑定调用方的协程作用域。 */ +@androidx.compose.runtime.Composable +internal fun rememberBangumiOpener(context: android.content.Context): (Long) -> Unit { + val scope = androidx.compose.runtime.rememberCoroutineScope() + return androidx.compose.runtime.remember(context, scope) { + { seasonId: Long -> openBangumi(context, seasonId, scope) } } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageScreen.kt index f844a62a..2b1ce616 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageScreen.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageScreen.kt @@ -13,6 +13,8 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.snapshotFlow @@ -28,6 +30,7 @@ import com.aritxonly.bdfun.presentation.common.discover.DiscoverSectionTitle import com.aritxonly.bdfun.ui.HomeTopBarHeight import com.aritxonly.bdfun.presentation.common.discover.DiscoverPullSession import com.aritxonly.bdfun.presentation.common.discover.DiscoverPullUi +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.launch import kotlin.math.roundToInt @@ -42,7 +45,20 @@ internal fun BangumiPage(state: BangumiPageStateHolder) { val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val gridState = state.gridState val pullUi = remember { DiscoverPullUi() } + // 排行榜内嵌在追番下面;数据在 AppContainer,页面只做投影。 + val rankState = com.aritxonly.bdfun.AppGraph.bangumiRankState + val rankUi by rankState.state.collectAsState() val pull = rememberBangumiPullSession(state, pullUi, scope) + LaunchedEffect(rankState) { rankState.ensureLoaded() } + // 进页面回到顶部。网格状态是单例持久的,切走再回来会留着上次的位置; + // 更关键的是「最近追番」比榜单晚返回,它插在榜单上面时 LazyGrid 会按锚点不动, + // 于是看起来一进来就停在热门动画榜。所以数据落定(loading 结束)后再回顶一次。 + LaunchedEffect(state, state.loading) { + if (!state.loading) gridState.scrollToItem(0) + } + LaunchedEffect(rankUi.sections.size) { + if (rankUi.sections.isNotEmpty()) gridState.scrollToItem(0) + } BangumiPageEffects(state, pull, pullUi) LazyVerticalGrid( @@ -62,13 +78,23 @@ internal fun BangumiPage(state: BangumiPageStateHolder) { verticalArrangement = Arrangement.spacedBy(10.dp), ) { bangumiFollowSection(state, context) + bangumiRankSections( + sections = rankUi.sections, + holder = rankState, + onOpenSeason = { seasonId -> openBangumi(context, seasonId, scope) }, + onOpenBoard = { section -> + section.tabs.firstOrNull()?.let { tab -> + com.aritxonly.bdfun.BangumiRankBoardActivity.open(context, tab, section.title) + } + }, + ) if (state.cards.isNotEmpty()) { item(key = "pgc-recommend-title", span = { GridItemSpan(maxLineSpan) }) { - DiscoverSectionTitle("推荐") + BangumiSectionTitle("推荐") } } items(state.cards, key = { it.seasonId }) { item -> - BangumiCardItem(item) { openBangumi(context, item.seasonId) } + BangumiCardItem(item) { openBangumi(context, item.seasonId, scope) } } bangumiPageStateItems(state, scope) } @@ -93,7 +119,9 @@ private fun rememberBangumiPullSession( state.pullFraction = (it / threshold).coerceIn(0f, 1f) }, onPullDp = { state.pullOffsetDp = it }, - canScrollBackward = { !state.gridState.canScrollBackward }, + // 与直播/推荐页一致:传原始 canScrollBackward,别在调用处再取反 + // (取反会变成「不在顶部也能下拉刷新」)。 + canScrollBackward = { state.gridState.canScrollBackward }, isRefreshing = { state.refreshing || state.loading }, onRefresh = { // 与推荐页一致:松手立即把指示器顶到阈值,再开始刷新。 @@ -114,11 +142,14 @@ private fun BangumiPageEffects(state: BangumiPageStateHolder, pull: DiscoverPull pull.animatePullTo(0f, 260f) } } + // 与推荐页同一套:首次布局会重发一次当前索引(进入页面/从别的页回来), + // 不能当成用户滑到底,所以 drop(1);比较对象是卡片数而不是 totalItemsCount + // (后者把小标题、榜单分节、加载/错误项也算进去,触发点会漂)。 LaunchedEffect(state) { snapshotFlow { state.gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 } + .drop(1) .collect { last -> - val total = state.gridState.layoutInfo.totalItemsCount - if (state.cards.isNotEmpty() && total > 0 && last >= total - 2) state.loadMore() + if (state.cards.isNotEmpty() && last >= state.cards.size - 2) state.loadMore() } } } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiRankBoardScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiRankBoardScreen.kt new file mode 100644 index 00000000..8315d265 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiRankBoardScreen.kt @@ -0,0 +1,156 @@ +package com.aritxonly.bdfun.presentation.pgc + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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 +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.aritxonly.bdfun.feature.pgc.application.BangumiRankBoardStateHolder +import com.aritxonly.bdfun.ui.FeedCardShape +import com.aritxonly.bdfun.ui.TeakLoading +import com.aritxonly.bdfun.ui.rememberImageRequest +import kotlinx.coroutines.launch +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * 单榜全量页:官方是进独立页面(RankFragment),这里用独立 Activity 对齐。 + * 接口一次给整榜,所以只有「加载中 / 空 / 出错 + 重试」三态,没有分页。 + */ +@Composable +internal fun BangumiRankBoardScreen( + holder: BangumiRankBoardStateHolder, + onOpenSeason: (Long) -> Unit, +) { + val ui by holder.state.collectAsState() + val scope = rememberCoroutineScope() + LaunchedEffect(holder) { holder.ensureLoaded() } + val colors = MiuixTheme.colorScheme + + when { + ui.entries.isEmpty() && ui.loading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + TeakLoading(size = 40.dp) + } + + ui.entries.isEmpty() -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(ui.error ?: "暂无内容", color = colors.onBackgroundVariant, fontSize = 13.sp) + Text( + text = "点击重试", + color = colors.primary, + fontSize = 13.sp, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable { scope.launch { holder.retry() } } + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + } + } + + else -> LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 32.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + if (ui.note.isNotBlank()) { + item(key = "note") { + Text( + text = ui.note, + color = colors.onBackgroundVariant, + fontSize = 11.sp, + modifier = Modifier.padding(bottom = 2.dp), + ) + } + } + items(ui.entries, key = { it.seasonId }) { entry -> + RankBoardRow(entry) { onOpenSeason(entry.seasonId) } + } + } + } +} + +@Composable +private fun RankBoardRow(entry: com.aritxonly.bdfun.core.model.pgc.BangumiRankEntry, onClick: () -> Unit) { + val colors = MiuixTheme.colorScheme + Row( + modifier = Modifier + .fillMaxWidth() + .clip(FeedCardShape) + .background(colors.surfaceContainer) + .clickable(onClick = onClick) + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = entry.rank.takeIf { it > 0 }?.toString().orEmpty(), + color = if (entry.rank in 1..3) Color(0xFFFF7A45) else colors.onBackgroundVariant, + fontSize = 15.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier.width(28.dp), + ) + AsyncImage( + model = rememberImageRequest(entry.cover), + contentDescription = entry.title, + contentScale = ContentScale.Crop, + modifier = Modifier + .width(64.dp) + .aspectRatio(3f / 4f) + .clip(RoundedCornerShape(6.dp)) + .background(colors.surfaceContainerHigh), + ) + Spacer(Modifier.width(10.dp)) + Column(Modifier.weight(1f)) { + Text( + text = entry.title, + color = colors.onBackground, + fontSize = 14.sp, + lineHeight = 18.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(Modifier.height(2.dp)) + Text( + text = listOf(entry.subtitle, entry.indexShow) + .filter { it.isNotBlank() } + .joinToString(" · "), + color = colors.onBackgroundVariant, + fontSize = 11.sp, + lineHeight = 15.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (entry.rating.isNotBlank()) { + Spacer(Modifier.width(8.dp)) + Text(entry.rating, color = Color(0xFFFF7A45), fontSize = 12.sp, maxLines = 1) + } + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiRankPage.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiRankPage.kt new file mode 100644 index 00000000..042a85ea --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiRankPage.kt @@ -0,0 +1,381 @@ +package com.aritxonly.bdfun.presentation.pgc + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyGridScope +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.aritxonly.bdfun.BrandPink +import com.aritxonly.bdfun.core.model.pgc.BangumiRankEntry +import com.aritxonly.bdfun.feature.pgc.application.BangumiRankSection +import com.aritxonly.bdfun.feature.pgc.application.BangumiRankStateHolder +import com.aritxonly.bdfun.ui.FeedCardShape +import com.aritxonly.bdfun.ui.TeakLoading +import com.aritxonly.bdfun.ui.rememberImageRequest +import top.yukonga.miuix.kmp.icon.MiuixIcons +import top.yukonga.miuix.kmp.icon.basic.ArrowRight +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** 番剧页小节标题:与榜单分节标题同一套字号/字重。 */ +@Composable +internal fun BangumiSectionTitle(text: String) { + Text( + text = text, + color = MiuixTheme.colorScheme.onBackground, + fontSize = 20.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(start = 2.dp, bottom = 8.dp), + ) +} + +/** 排行榜:每榜一条整行 item,滚到才请求;「查看更多」进独立页面。 */ +internal fun LazyGridScope.bangumiRankSections( + sections: List, + holder: BangumiRankStateHolder, + onOpenSeason: (Long) -> Unit, + onOpenBoard: (BangumiRankSection) -> Unit, +) { + sections.forEachIndexed { index, section -> + item(key = "pgc-rank-${section.key}", span = { GridItemSpan(maxLineSpan) }) { + RankSectionBlock(holder, index, section, onOpenSeason, onOpenBoard) + } + } +} + +@Composable +private fun RankSectionBlock( + holder: BangumiRankStateHolder, + index: Int, + fallback: BangumiRankSection, + onOpenSeason: (Long) -> Unit, + onOpenBoard: (BangumiRankSection) -> Unit, +) { + // 必须在这里 collect:直接读 state.value 只拿到一次性快照,加载完成不会重组。 + val ui by holder.state.collectAsState() + val section = ui.sections.getOrNull(index) ?: fallback + LaunchedEffect(index) { holder.ensureSection(index) } + Column(Modifier.fillMaxWidth()) { + RankSectionHeader(section.title) { onOpenBoard(section) } + Spacer(Modifier.height(8.dp)) + when { + section.entries.isEmpty() && section.loading -> Box( + Modifier.fillMaxWidth().height(120.dp), + contentAlignment = Alignment.Center, + ) { TeakLoading(size = 28.dp) } + + section.entries.isEmpty() -> RankErrorText(section.error ?: "暂无内容") { + holder.retrySection(index) + } + + else -> RankCardRow(section.entries.take(PREVIEW_COUNT), onOpenSeason) + } + Spacer(Modifier.height(6.dp)) + } +} + +@Composable +private fun RankSectionHeader(title: String, onMore: () -> Unit) { + val colors = MiuixTheme.colorScheme + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + Box( + Modifier + .width(3.dp) + .height(16.dp) + .clip(RoundedCornerShape(2.dp)) + .background(rankAccent(title)), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = title, + color = colors.onBackground, + fontSize = 20.sp, + lineHeight = 24.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 220.dp), + ) + Spacer(Modifier.weight(1f)) + // 网格左右各有 10dp padding,往右偏 10dp 才能贴到屏幕右边。 + Row( + modifier = Modifier + .offset(x = 10.dp) + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onMore) + .padding(start = 8.dp, end = 10.dp, top = 6.dp, bottom = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text("查看更多", color = colors.onBackgroundVariant, fontSize = 13.sp) + Spacer(Modifier.width(2.dp)) + androidx.compose.material3.Icon( + imageVector = MiuixIcons.Basic.ArrowRight, + contentDescription = null, + tint = colors.onBackgroundVariant, + modifier = Modifier.size(14.dp), + ) + } + } +} + +/** 榜单横滑到两端时把剩余横向 delta 吃掉,别传给外层翻页手势。 */ +private object HorizontalOverscrollConsumer : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset = + if (available.x == 0f) Offset.Zero else Offset(available.x, 0f) +} + +@Composable +private fun RankCardRow(entries: List, onOpenSeason: (Long) -> Unit) { + BoxWithConstraints(Modifier.fillMaxWidth()) { + val cardWidth: Dp = (maxWidth - 16.dp) / 3.35f + LazyRow( + modifier = Modifier.nestedScroll(HorizontalOverscrollConsumer), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(entries, key = { it.seasonId }) { entry -> + RankCard(entry, cardWidth) { onOpenSeason(entry.seasonId) } + } + } + } +} + +@Composable +private fun RankCard(entry: BangumiRankEntry, width: Dp, onClick: () -> Unit) { + val colors = MiuixTheme.colorScheme + Column( + modifier = Modifier + .width(width) + .clip(FeedCardShape) + .background(colors.surfaceContainer) + .clickable(interactionSource = null, indication = null, onClick = onClick), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(3f / 4f) + .background(colors.surfaceContainerHigh), + ) { + AsyncImage( + model = rememberImageRequest(entry.cover), + contentDescription = entry.title, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize(), + ) + // 底部渐变:压住浅色海报,保证「全X话」白字可读。 + Box( + Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(56.dp) + .background(Brush.verticalGradient(listOf(Color.Transparent, Color(0x99000000)))), + ) + RankBadge(entry.rank, entry.cover, Modifier.align(Alignment.TopStart).padding(6.dp)) + if (entry.vip) RankVipBadge(Modifier.align(Alignment.TopEnd).padding(6.dp)) + if (entry.indexShow.isNotBlank()) { + Text( + text = entry.indexShow, + color = Color.White, + fontSize = 11.sp, + lineHeight = 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(start = 6.dp, end = 6.dp, bottom = 6.dp), + ) + } + } + Column(Modifier.padding(start = 6.dp, end = 6.dp, top = 5.dp, bottom = 6.dp)) { + Text( + text = entry.title, + color = colors.onBackground, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 13.sp, + lineHeight = 17.sp, + ) + // 高度要容得下一整行(含中文字形下伸部分),否则底部会被裁。 + Box(Modifier.height(18.dp), contentAlignment = Alignment.CenterStart) { + if (entry.subtitle.isNotBlank()) { + Text( + text = entry.subtitle, + color = colors.onBackgroundVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + fontSize = 11.sp, + lineHeight = 15.sp, + ) + } + } + } + } +} + +/** 大会员角标:与「最近追番」卡片同款。 */ +@Composable +private fun RankVipBadge(modifier: Modifier = Modifier) { + Box( + modifier = modifier + .clip(CircleShape) + .background(BrandPink) + .padding(horizontal = 5.dp, vertical = 1.dp), + ) { + Text("大会员", color = Color.White, fontSize = 8.5.sp, lineHeight = 11.sp, maxLines = 1) + } +} + +/** 名次角标:只标前三名。大号斜体数字,50% 往下渐隐到透明,不画底色;颜色随封面深浅自动反色。 */ +@Composable +private fun RankBadge(rank: Int, coverUrl: String, modifier: Modifier = Modifier) { + if (rank !in 1..3) return + val ink = rememberRankInk(coverUrl) + Text( + text = rank.toString(), + modifier = modifier.graphicsLayer { rotationZ = 2.5f }, + style = androidx.compose.ui.text.TextStyle( + fontSize = 30.sp, + lineHeight = 30.sp, + fontWeight = FontWeight.Black, + fontStyle = FontStyle.Italic, + brush = Brush.verticalGradient(0f to ink, 0.5f to ink, 1f to ink.copy(alpha = 0f)), + shadow = androidx.compose.ui.graphics.Shadow( + color = if (ink.luminance() > 0.6f) Color(0x22FFFFFF) else Color(0x66000000), + offset = Offset(1f, 1f), + blurRadius = 4f, + ), + ), + maxLines = 1, + ) +} + +/** 取封面左上角亮度决定字色:浅底深灰、深底白灰。48px 缩略图采样,命中 Coil 缓存。 */ +@Composable +private fun rememberRankInk(coverUrl: String): Color { + val context = LocalContext.current + var lightCover by remember(coverUrl) { mutableStateOf(false) } + LaunchedEffect(coverUrl) { + if (coverUrl.isBlank()) return@LaunchedEffect + val request = coil.request.ImageRequest.Builder(context) + .data(coverUrl) + .size(48) + .allowHardware(false) + .crossfade(false) + .build() + val result = runCatching { coil.Coil.imageLoader(context).execute(request) }.getOrNull() + val bitmap = ((result as? coil.request.SuccessResult)?.drawable + as? android.graphics.drawable.BitmapDrawable)?.bitmap ?: return@LaunchedEffect + lightCover = topLeftIsLight(bitmap) + } + return if (lightCover) Color(0xFF3C3C3C) else Color(0xFFF5F5F5) +} + +private fun topLeftIsLight(bitmap: android.graphics.Bitmap): Boolean = runCatching { + val readable = if (bitmap.config == android.graphics.Bitmap.Config.HARDWARE) { + bitmap.copy(android.graphics.Bitmap.Config.ARGB_8888, false) ?: return false + } else { + bitmap + } + val w = (readable.width / 3).coerceAtLeast(1) + val h = (readable.height / 3).coerceAtLeast(1) + val pixels = IntArray(w * h) + readable.getPixels(pixels, 0, w, 0, 0, w, h) + var sum = 0.0 + pixels.forEach { pixel -> + val r = (pixel shr 16) and 0xFF + val g = (pixel shr 8) and 0xFF + val b = pixel and 0xFF + sum += 0.299 * r + 0.587 * g + 0.114 * b + } + if (readable !== bitmap) readable.recycle() + (sum / pixels.size.coerceAtLeast(1)) > 165.0 +}.getOrDefault(false) + +@Composable +private fun RankErrorText(message: String, onRetry: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text(message, color = MiuixTheme.colorScheme.onBackgroundVariant, fontSize = 13.sp) + Text( + text = "点击重试", + color = MiuixTheme.colorScheme.primary, + fontSize = 13.sp, + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .clickable(onClick = onRetry) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) + } +} + +/** 每个榜单先露出的卡片数。 */ +private const val PREVIEW_COUNT = 6 + +/** 榜单主题色:按榜名匹配,改榜名也不会掉色。 */ +internal fun rankAccent(title: String): Color = when { + title.contains("热门动画") -> Color(0xFFB3261E) + title.contains("搞笑") -> Color(0xFFFFC107) + title.contains("热血") -> Color(0xFFFF3B30) + title.contains("奇幻") -> Color(0xFF34C759) + title.contains("励志") -> Color(0xFFD4A017) + title.contains("萌系") -> Color(0xFFFF8FAB) + title.contains("运动") -> Color(0xFF2F80ED) + title.contains("机战") -> Color(0xFF8E44AD) + title.contains("小说改编") -> Color(0xFF8D6E63) + title.contains("漫画改编") -> Color(0xFF26A69A) + title.contains("原创") -> Color(0xFFFF8A00) + title.contains("恋爱") -> Color(0xFFFF4F9A) + title.contains("战斗") -> Color(0xFFE64A19) + title.contains("泡面") -> Color(0xFFF2C94C) + title.contains("动态漫") -> Color(0xFF4DB6AC) + else -> Color(0xFF9E9E9E) +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/player/danmaku/DanmakuEngineConfig.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/player/danmaku/DanmakuEngineConfig.kt index bcfc5156..8be62920 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/player/danmaku/DanmakuEngineConfig.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/player/danmaku/DanmakuEngineConfig.kt @@ -24,9 +24,14 @@ internal fun danmakuEngineConfig(drawing: DanmakuLayerDrawing, viewSize: IntSize val typeface = remember { Typeface.create("sans-serif-medium", Typeface.NORMAL) } val lineHeightPx = (baseTextPx * 1.9f).coerceAtLeast(1f) val heightPx = viewSize.height.toFloat() + // 顶部让给「弹幕计数」的高度(已开启计数时非 0):行数要按剩余高度算, + // 否则最下面几行会被顶出画面。 + val topGuardPx = with(density) { drawing.topGuardDp.dp.toPx() } val lineCount = if (heightPx > 0f) { // 一页最多 9 行;行高加大后上下弹幕不会挤在一起。 - (((heightPx * area) / lineHeightPx).toInt()).coerceIn(1, 9) + ((((heightPx * area) - topGuardPx).coerceAtLeast(lineHeightPx)) / lineHeightPx) + .toInt() + .coerceIn(1, 9) } else { 1 } @@ -46,7 +51,8 @@ internal fun danmakuEngineConfig(drawing: DanmakuLayerDrawing, viewSize: IntSize lineHeightPx = lineHeightPx, lineMarginPx = 0f, lineCount = lineCount, - topMarginPx = 0f, + // 弹幕从计数下面开始排,不和顶部的计数叠在一起。 + topMarginPx = topGuardPx, bottomMarginPx = bottomMarginPx, pinnedDurationMs = FIXED_SHOW_MS, playSpeedPercent = 100, diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/player/danmaku/DanmakuLayerOptions.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/player/danmaku/DanmakuLayerOptions.kt index 343cac1e..802a8538 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/player/danmaku/DanmakuLayerOptions.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/player/danmaku/DanmakuLayerOptions.kt @@ -9,7 +9,9 @@ import androidx.compose.ui.unit.IntSize import com.aritxonly.bdfun.core.domain.DanmakuFilterOptions internal data class DanmakuLayerDrawing(val opacity: Float, val area: Float, val speed: Float, - val fontScale: Float, val stroke: Float, val portraitGuard: Boolean, val subtitleGuard: Boolean, val lowRefresh: Boolean) + val fontScale: Float, val stroke: Float, val portraitGuard: Boolean, val subtitleGuard: Boolean, val lowRefresh: Boolean, + /** 顶部给「弹幕计数」让出的高度(dp);0 表示不挡。 */ + val topGuardDp: Float = 0f) internal data class DanmakuLayerOptions(val filters: DanmakuFilterOptions, val drawing: DanmakuLayerDrawing) @Composable internal fun currentDanmakuLayerOptions(fullscreen: Boolean): DanmakuLayerOptions { @@ -20,7 +22,10 @@ internal fun currentDanmakuLayerOptions(fullscreen: Boolean): DanmakuLayerOption DanmakuLayerDrawing(settings.danmakuOpacity, settings.danmakuArea.coerceIn(0.15f, 1f), settings.danmakuSpeedPref.coerceAtLeast(0.1f), if (fullscreen) settings.danmakuScaleFullscreen else settings.danmakuScaleInline, - settings.danmakuStroke, false, settings.danmakuSubtitleGuard, settings.danmakuLowRefresh)) + settings.danmakuStroke, false, settings.danmakuSubtitleGuard, settings.danmakuLowRefresh, + // 弹幕计数固定在画面顶部(约 26dp 文字 + 上下留白),开启时整块让出来, + // 滚动弹幕就不会从计数上压过去。 + topGuardDp = if (settings.hotDanmakuEnabled) 42f else 0f)) // 人像防挡在旧入口固定关闭,不恢复读取历史存储值。 } internal class DanmakuLayerPresentationStateHolder { var viewSize by mutableStateOf(IntSize.Zero) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsFeatureCards.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsFeatureCards.kt index 807924be..2c9fe272 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsFeatureCards.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsFeatureCards.kt @@ -9,6 +9,56 @@ import com.aritxonly.teak.common.settings.TeakSettingsCard import com.aritxonly.teak.common.settings.TeakSettingsDropdownRow import com.aritxonly.teak.common.settings.TeakSettingsSwitchRow +@Composable +internal fun SettingsHomeCard(runtime: SettingsPageRuntime) { + TeakSettingsCard(title = "首页") { + SettingsHomeCardRows1(runtime) + } +} + +@Composable +private fun SettingsHomeCardRows1(runtime: SettingsPageRuntime) { + val settings = runtime.settings + TeakSettingsSwitchRow( + title = "退出视频后继续播放", + checked = settings.showDiscAfterVideo, + onCheckedChange = { settings.showDiscAfterVideo = it }, + ) + if (settings.showDiscAfterVideo) { + TeakSettingsDropdownRow( + title = "播放控件", + options = listOf("画中画", "唱片"), + selected = settings.exitVideoControl.coerceIn(0, 1), + onSelected = { settings.exitVideoControl = it }, + ) + } + TeakSettingsSwitchRow( + title = "唱片呼吸灯", + description = "唱片周围出现一圈缓慢呼吸的光效", + checked = settings.discBreathEnabled, + onCheckedChange = { settings.discBreathEnabled = it }, + ) + if (settings.discBreathEnabled) { + TeakSettingsSwitchRow( + title = "呼吸灯跟随封面取色", + description = "关闭时为白色", + checked = settings.discBreathCoverColor, + onCheckedChange = { settings.discBreathCoverColor = it }, + ) + } + TeakSettingsDropdownRow( + title = "卡片样式", + options = listOf("双排", "大卡"), + selected = settings.homeCardStyle.coerceIn(0, 1), + onSelected = { settings.homeCardStyle = it }, + ) + TeakSettingsSwitchRow( + title = "首页使用 Web 端推荐", + checked = settings.recommendWeb, + onCheckedChange = { settings.recommendWeb = it }, + ) +} + @Composable internal fun SettingsVideoDetailCard(runtime: SettingsPageRuntime) { TeakSettingsCard(title = "视频详情页") { diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsOptions.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsOptions.kt index 82df545d..40740398 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsOptions.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsOptions.kt @@ -1,8 +1,9 @@ package com.aritxonly.bdfun.ui -internal val QUALITY_LABELS = listOf("自动", "HDR", "4K", "1080P+", "1080P", "720P") -internal val QUALITY_VALUES = listOf(0, 125, 120, 112, 80, 64) +// 顺序按 B 站 qn 从高到低:127 8K / 126 杜比 / 125 HDR / 120 4K / 116 1080P60 / 112 1080P+ / 80 1080P / 64 720P +internal val QUALITY_LABELS = listOf("自动", "HDR", "4K", "1080P 60FPS", "1080P+", "1080P", "720P") +internal val QUALITY_VALUES = listOf(0, 125, 120, 116, 112, 80, 64) internal val LATENCY_VALUES = listOf(50, 100, 150, 200, 250, 300) internal val PLAYBACK_SPEED_VALUES = listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 2f, 2.5f, 3f) internal val PLAYBACK_SPEED_LABELS = PLAYBACK_SPEED_VALUES.map { diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsScreen.kt index c753c653..c43f63c8 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsScreen.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/settings/page/SettingsScreen.kt @@ -27,6 +27,7 @@ internal fun SettingsScreen(settings: SettingsBindings, navigation: SettingsNavi TeakSettingsPage(title = "设置") { item(key = "appearance") { SettingsAppearanceCard(runtime) } item(key = "player") { SettingsPlayerCard(runtime) } + item(key = "home") { SettingsHomeCard(runtime) } item(key = "video_detail") { SettingsVideoDetailCard(runtime) } item(key = "danmaku") { SettingsDanmakuCard(runtime) } item(key = "dynamics") { SettingsFeedCard(runtime) } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainFeedChrome.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainFeedChrome.kt index 0e0e0692..c3a254a7 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainFeedChrome.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainFeedChrome.kt @@ -1,62 +1,33 @@ package com.aritxonly.bdfun -import com.aritxonly.teak.common.chrome.LocalTopBarScrollProgress -import com.aritxonly.teak.common.chrome.ProgressiveTopBarMaterial - import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.foundation.layout.size import com.aritxonly.bdfun.ui.FeedTopBar -import com.aritxonly.teak.common.search.TeakSearchTransitionDefaults +/** + * 动态页固定顶栏:只有「全部 / 视频 / 番剧」玻璃分段栏。 + * 头像卡片已经放进动态列表里,不再固定在顶栏。 + * + * 搜索过渡与首页三段共用 [MainTopBarChrome],不再单独维护一份参数。 + */ @Composable -internal fun androidx.compose.foundation.layout.BoxScope.MainFeedChrome(shell: MainShellState, pageFloat: Float) { - val ui = shell.ui - val session = shell.feeds.session +internal fun androidx.compose.foundation.layout.BoxScope.MainFeedChrome( + shell: MainShellState, + pageFloat: Float, +) { + val session = shell.feeds.session ?: return val dynamicFeed = shell.feeds.dynamic - val dynamicChrome = shell.chrome.dynamic - // 同 MainHomeChrome:一镜到底期间把位移和模糊补偿给顶栏采样。 - val bottomProgress = shell.bottom.bottomSearchProgress.value - val underlayLiftPx = with(androidx.compose.ui.platform.LocalDensity.current) { - TeakSearchTransitionDefaults.ContentLift.toPx() - } * bottomProgress - val underlayBlurPx = TeakSearchTransitionDefaults.UnderlayBlurPx * shell.bottom.bottomBlurProgress.value - // 动态页固定顶栏:只有「全部 / 视频」玻璃分段栏。 - // 头像卡片已经放进动态列表里,不再固定在顶栏。 - if (pageFloat > 0.01f && pageFloat < 1.99f && - session != null && !ui.showSearch + MainTopBarChrome( + shell = shell, + pageIndex = 1, + pageFloat = pageFloat, + surface = shell.chrome.dynamic, + // 动态页顶栏没有搜索框,搜索页盖上来时跟着搜索进度淡掉,不要硬切。 + fadesWhenSearchOpens = true, ) { - CompositionLocalProvider(LocalTopBarScrollProgress provides dynamicChrome.materialProgress) { - ProgressiveTopBarMaterial( - underlayLiftPx = underlayLiftPx, - underlayBlurPx = underlayBlurPx, - // 与首页同一条规则:材质层只做淡出,缩放/横移放到内容层, - // 避免渐进模糊的采样矩形被缩小后在搜索动画里切出硬边。 - modifier = Modifier.align(Alignment.TopCenter) - .graphicsLayer { - alpha = (1f - kotlin.math.abs(pageFloat - 1f)).coerceIn(0f, 1f) * - (1f - shell.bottom.bottomTopBarAlpha.value) - }, - ) { - androidx.compose.foundation.layout.Box( - modifier = Modifier.graphicsLayer { - val searchScale = 1f - 0.1f * shell.bottom.bottomSearchProgress.value - scaleX = searchScale - scaleY = searchScale - translationX = (1f - pageFloat) * size.width * 0.92f - }, - ) { - FeedTopBar( - state = dynamicFeed, - loggedIn = session != null, - modifier = Modifier, - ) - } - } - } + FeedTopBar( + state = dynamicFeed, + loggedIn = session != null, + modifier = androidx.compose.ui.Modifier, + ) } - } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainHomeChrome.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainHomeChrome.kt index 3bf63e94..5f13814a 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainHomeChrome.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainHomeChrome.kt @@ -1,92 +1,45 @@ package com.aritxonly.bdfun -import com.aritxonly.teak.common.chrome.LocalTopBarScrollProgress -import com.aritxonly.teak.common.chrome.ProgressiveTopBarMaterial - import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp -import androidx.compose.foundation.layout.size -import com.aritxonly.teak.common.material.LocalTeakBackdrop import com.aritxonly.bdfun.feature.settings.presentation.SettingsBindings import com.aritxonly.bdfun.ui.HomePullIndicator import com.aritxonly.bdfun.ui.HomeTopBarHeight -import com.aritxonly.teak.common.search.TeakSearchTransitionDefaults @Composable -internal fun androidx.compose.foundation.layout.BoxScope.MainHomeChrome(settings: SettingsBindings, shell: MainShellState, pageFloat: Float) { +internal fun androidx.compose.foundation.layout.BoxScope.MainHomeChrome( + settings: SettingsBindings, + shell: MainShellState, + pageFloat: Float, +) { val ui = shell.ui val homeFeed = shell.feeds.home - val homeChrome = shell.chrome.home + // 推荐 / 直播 / 番剧 三段各自一份滚动进度,但顶栏和搜索过渡是同一份实现。 val activeChrome = when (ui.homeSection) { 1 -> shell.chrome.live 2 -> shell.chrome.bangumi - else -> homeChrome + else -> shell.chrome.home } - // 下拉刷新指示器:三段各自一份状态,但都画在 App 层同一个位置。 + // 下拉刷新指示器:三段各一份状态,都画在 App 层同一个位置。 val liveState = if (ui.homeSection == 1) AppGraph.livePageState else null val bangumiState = if (ui.homeSection == 2) AppGraph.bangumiPageState else null val pullRefreshing = liveState?.refreshing ?: bangumiState?.refreshing ?: homeFeed.refreshing val pullOffsetDp = liveState?.pullOffsetDp ?: bangumiState?.pullOffsetDp ?: homeFeed.pullOffsetDp - val searchChrome = shell.chrome.search - val searchBackdrop = shell.backdrops.search - // 底栏搜索的一镜到底会把内容整体上移并模糊;顶栏画在变换之外, - // 要把这两个量交给它,采样才不会和画面对不上。 - val bottomProgress = shell.bottom.bottomSearchProgress.value - val underlayLiftPx = with(androidx.compose.ui.platform.LocalDensity.current) { - TeakSearchTransitionDefaults.ContentLift.toPx() - } * bottomProgress - val underlayBlurPx = TeakSearchTransitionDefaults.UnderlayBlurPx * shell.bottom.bottomBlurProgress.value - // 首页顶栏:挂在采集层之外,所以能直接用底栏同款玻璃(真模糊)+ 渐变式模糊 - // 顶栏跟首页一起走:位置随 pager 偏移平移,透明度同步降低 - if (pageFloat < 0.99f) { - // 搜索态顶栏改采样搜索页自己的 backdrop: - // 结果列表从下面滚过时,顶部就是首页同款渐进式模糊。 - CompositionLocalProvider( - LocalTeakBackdrop provides if (ui.showSearch) { - searchBackdrop - } else { - LocalTeakBackdrop.current - }, - ) { - CompositionLocalProvider( - LocalTopBarScrollProgress provides if (ui.showSearch) { - searchChrome.materialProgress - } else { - activeChrome.materialProgress - }, - ) { - ProgressiveTopBarMaterial( - underlayLiftPx = underlayLiftPx, - underlayBlurPx = underlayBlurPx, - // 只把淡出交给材质层。缩放/横移放到内容层:渐进模糊的采样矩形如果跟着 - // 缩到 0.9,边缘会在搜索动画里切出一条清晰/模糊的硬边。 - modifier = Modifier.align(Alignment.TopCenter) - .graphicsLayer { - alpha = (1f - pageFloat).coerceIn(0f, 1f) * (1f - shell.bottom.bottomTopBarAlpha.value) - }, - ) { - androidx.compose.foundation.layout.Box( - modifier = Modifier.graphicsLayer { - val searchScale = 1f - 0.1f * shell.bottom.bottomSearchProgress.value - scaleX = searchScale - scaleY = searchScale - translationX = -pageFloat * size.width * 0.92f - }, - ) { - MainHomeBarContent(settings, shell) - } - } - } - } - // 使用须知改由独立的 TermsActivity 在启动时展示。 + MainTopBarChrome( + shell = shell, + pageIndex = 0, + pageFloat = pageFloat, + surface = activeChrome, + ) { + MainHomeBarContent(settings, shell) + } + if (pageFloat < 0.99f) { // 下拉刷新指示器:独立覆盖层,挂在 App 层,探出顶栏也不会被裁掉。 // 透明度直接由下拉位移决定:位移 58dp 开始淡入、84dp 全显; // 卡片在位移 <46dp 时才会撞到小电视,因此永远追不上。 @@ -103,5 +56,4 @@ internal fun androidx.compose.foundation.layout.BoxScope.MainHomeChrome(settings .padding(top = HomeTopBarHeight + 4.dp), ) } - } diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTopBarChrome.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTopBarChrome.kt new file mode 100644 index 00000000..61d34a99 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTopBarChrome.kt @@ -0,0 +1,86 @@ +package com.aritxonly.bdfun + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalDensity +import com.aritxonly.teak.common.chrome.LocalTopBarScrollProgress +import com.aritxonly.teak.common.chrome.ProgressiveTopBarMaterial +import com.aritxonly.teak.common.chrome.TeakPageScrollState +import com.aritxonly.teak.common.material.LocalTeakBackdrop +import com.aritxonly.teak.common.search.TeakSearchTransitionDefaults +import kotlin.math.abs + +/** + * 主壳顶栏的搜索过渡:首页三段(推荐 / 直播 / 番剧)和动态页共用这一份实现。 + * + * 以前首页和动态页各写一份,参数慢慢就漂了——「推荐修好了、直播/番剧/动态还是撕裂」 + * 这类问题基本都是两份实现不同步造成的。这里只保留一条规则: + * + * 1. 材质层(渐进模糊 + 玻璃)只做**淡出**,不缩放; + * 2. 缩放 / 横移交给内容层,否则模糊采样矩形跟着缩到 0.9,动画里会切出一条清晰/模糊的硬边; + * 3. 一镜到底时内容整体上移并被模糊,顶栏采样要按同样的位移和模糊量补偿,接缝才不会露出来; + * 4. 搜索页打开时采样源切到搜索页自己的 backdrop,进度切到搜索页的滚动进度。 + */ +@Composable +internal fun BoxScope.MainTopBarChrome( + shell: MainShellState, + /** 这个顶栏属于 pager 的第几页:首页三段都是 0,动态页是 1。 */ + pageIndex: Int, + pageFloat: Float, + /** 该页自己的滚动进度(直播/番剧用各自的 chrome)。 */ + surface: TeakPageScrollState, + /** 本页没有搜索框时(动态页),搜索页盖上来要把顶栏一起淡掉,而不是硬切。 */ + fadesWhenSearchOpens: Boolean = false, + content: @Composable BoxScope.() -> Unit, +) { + val ui = shell.ui + val bottom = shell.bottom + val pageAlpha = (1f - abs(pageFloat - pageIndex)).coerceIn(0f, 1f) + if (pageAlpha <= 0.01f) return + + // 一镜到底:内容被整体上移并模糊,顶栏画在变换之外,采样必须补偿同样的量。 + val liftPx = with(LocalDensity.current) { TeakSearchTransitionDefaults.ContentLift.toPx() } * + bottom.bottomSearchProgress.value + val underlayBlurPx = TeakSearchTransitionDefaults.UnderlayBlurPx * bottom.bottomBlurProgress.value + val searchFade = if (fadesWhenSearchOpens && ui.showSearch) { + (1f - shell.searchReveal).coerceIn(0f, 1f) + } else { + 1f + } + + CompositionLocalProvider( + // 搜索页打开后顶栏改采样搜索页自己的 backdrop,否则边缘会露出底下的首页。 + LocalTeakBackdrop provides if (ui.showSearch) shell.backdrops.search else LocalTeakBackdrop.current, + LocalTopBarScrollProgress provides if (ui.showSearch) { + shell.chrome.search.materialProgress + } else { + surface.materialProgress + }, + ) { + ProgressiveTopBarMaterial( + underlayLiftPx = liftPx, + underlayBlurPx = underlayBlurPx, + modifier = Modifier + .align(Alignment.TopCenter) + .graphicsLayer { + alpha = pageAlpha * (1f - bottom.bottomTopBarAlpha.value) * searchFade + }, + ) { + Box( + modifier = Modifier.graphicsLayer { + val searchScale = 1f - 0.1f * bottom.bottomSearchProgress.value + scaleX = searchScale + scaleY = searchScale + translationX = -(pageFloat - pageIndex) * size.width * 0.92f + }, + ) { + content() + } + } + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/ui/ImageSaver.kt b/app/src/main/kotlin/com/aritxonly/bdfun/ui/ImageSaver.kt new file mode 100644 index 00000000..901fc67b --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/ui/ImageSaver.kt @@ -0,0 +1,79 @@ +package com.aritxonly.bdfun.ui + +import android.content.ContentValues +import android.content.Context +import android.os.Environment +import android.provider.MediaStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL + +/** + * 图片保存到系统相册(Pictures/bdFun)。 + * + * 不走 MediaStore 之外的私有目录:用户要的是能在相册里看到的图。 + * 下载用 `HttpURLConnection` 直接拉,因为图片 CDN 需要 Referer, + * 而且这里不需要带 @Throws 之类额外依赖。 + */ +internal object ImageSaver { + private const val UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" + + /** 返回 (成功数, 总数)。逐张独立失败,不因为一张挂掉就整批放弃。 */ + suspend fun save(context: Context, urls: List): Pair = withContext(Dispatchers.IO) { + val targets = urls.filter { it.isNotBlank() }.distinct() + var saved = 0 + targets.forEach { url -> + if (runCatching { saveOne(context, url) }.isSuccess) saved++ + } + saved to targets.size + } + + private fun saveOne(context: Context, url: String) { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = 15_000 + readTimeout = 20_000 + requestMethod = "GET" + setRequestProperty("User-Agent", UA) + setRequestProperty("Referer", "https://www.bilibili.com/") + } + try { + connection.inputStream.use { input -> + val resolver = context.contentResolver + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, fileName(url)) + put(MediaStore.Images.Media.MIME_TYPE, mimeType(url)) + put( + MediaStore.Images.Media.RELATIVE_PATH, + "${Environment.DIRECTORY_PICTURES}/bdFun", + ) + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) + ?: error("无法创建相册条目") + resolver.openOutputStream(uri)?.use { output -> input.copyTo(output) } + ?: error("无法写入相册") + values.clear() + values.put(MediaStore.Images.Media.IS_PENDING, 0) + resolver.update(uri, values, null, null) + } + } finally { + connection.disconnect() + } + } + + private fun fileName(url: String): String { + val suffix = url.substringAfterLast('.', "").substringBefore('?').lowercase() + val ext = suffix.takeIf { it in setOf("jpg", "jpeg", "png", "webp", "gif") } ?: "jpg" + return "bdFun_${System.currentTimeMillis()}_${kotlin.math.abs(url.hashCode())}.$ext" + } + + private fun mimeType(url: String): String = when { + url.contains(".png", ignoreCase = true) -> "image/png" + url.contains(".webp", ignoreCase = true) -> "image/webp" + url.contains(".gif", ignoreCase = true) -> "image/gif" + else -> "image/jpeg" + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/ui/LiveAvatar.kt b/app/src/main/kotlin/com/aritxonly/bdfun/ui/LiveAvatar.kt new file mode 100644 index 00000000..1387dbca --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/ui/LiveAvatar.kt @@ -0,0 +1,136 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.kyant.shapes.Capsule +import kotlinx.coroutines.delay +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * 「正在直播」的 UP 头像。 + * + * 和普通 UP 头像的区别:图片底部压一个「直播中」小胶囊(3dp 白描边,压在粉圈之上), + * 外面套一圈和动态页"选中 UP"同款的粉圈,粉圈再定时复制一份向外扩散淡出(雷达波)。 + * + * 扩散用 `delay` 手动推进:本项目基于 frame clock 的动画不会跑。 + */ +@Composable +internal fun LiveAvatar( + name: String, + face: String, + onClick: () -> Unit, +) { + val colors = MiuixTheme.colorScheme + val pulse = rememberLivePulse() + Column( + horizontalAlignment = Alignment.CenterHorizontally, + // 宽度 = 头像 46dp + 扩散圈余量:扩散到最大时左右两侧不会被列表裁掉。 + modifier = Modifier + .width(72.dp) + .clickable(interactionSource = null, indication = null, onClick = onClick), + ) { + Box(Modifier.fillMaxWidth().height(72.dp), contentAlignment = Alignment.Center) { + // 扩散粉圈:比头像大一圈,越大越淡。 + Box( + modifier = Modifier + .size(46.dp) + .graphicsLayer { + val scale = 1f + 0.55f * pulse + scaleX = scale + scaleY = scale + alpha = (1f - pulse).coerceIn(0f, 1f) * 0.7f + } + .border(1.dp, colors.primary, CircleShape), + ) + AsyncImage( + model = rememberImageRequest(face), + contentDescription = name, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(46.dp) + .clip(CircleShape) + .background(colors.surfaceContainerHigh), + ) + // 静态粉圈:和动态页选中 UP 头像同一套,1dp。 + Box( + modifier = Modifier + .size(46.dp) + .border(1.dp, colors.primary, CircleShape), + ) + // 「直播中」胶囊压在头像底边、粉圈之上。 + // 白边是往外加的一圈:外层白色胶囊 + 内缩 2dp 的粉色胶囊,而不是往胶囊里描边。 + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 5.dp) + .clip(Capsule()) + .background(Color.White) + .padding(1.5.dp) + .clip(Capsule()) + .background(colors.primary), + ) { + Box(Modifier.padding(horizontal = 5.dp)) { + Text( + text = "直播中", + color = Color.White, + fontSize = 8.5.sp, + lineHeight = 10.sp, + maxLines = 1, + ) + } + } + } + Text( + text = name, + color = colors.onBackgroundVariant, + fontSize = 9.5.sp, + lineHeight = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 1.dp), + ) + } +} + +/** 0 → 1 循环推进,一个周期 1.8s;`delay` 驱动,不依赖 frame clock。 */ +@Composable +private fun rememberLivePulse(): Float { + var pulse by remember { mutableFloatStateOf(0f) } + LaunchedEffect(Unit) { + val periodMs = 1800f + val startNanos = System.nanoTime() + while (true) { + pulse = ((System.nanoTime() - startNanos) / 1_000_000f % periodMs) / periodMs + delay(16) + } + } + return pulse +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/ui/TeakChips.kt b/app/src/main/kotlin/com/aritxonly/bdfun/ui/TeakChips.kt index ae337eef..a0f65c3e 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/ui/TeakChips.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/ui/TeakChips.kt @@ -122,16 +122,27 @@ internal fun MenuGlyphSpacer() { /** UP 标识:首页卡片与评论区共用同一套样式。 */ @Composable -fun UpBadge() { +fun UpBadge() = RoleBadge("UP") + +/** + * 「主播」标识:和 UP 标识同一个组件、同一套颜色/字号,只换文字。 + * 直播卡片上的主播名前缀用它,保证两处视觉一致。 + */ +@Composable +fun AnchorBadge() = RoleBadge("主播") + +/** 身份小胶囊的统一实现:UP / 主播 都用它,避免两处样式漂移。 */ +@Composable +fun RoleBadge(text: String, color: Color = BrandPink) { Box( modifier = Modifier .clip(Capsule()) - .background(BrandPink.copy(alpha = 0.14f)) + .background(color.copy(alpha = 0.14f)) .padding(horizontal = 4.dp, vertical = 1.dp), ) { Text( - text = "UP", - color = BrandPink, + text = text, + color = color, fontSize = 9.sp, lineHeight = 10.sp, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold, diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/ui/comment/CommentCopySheet.kt b/app/src/main/kotlin/com/aritxonly/bdfun/ui/comment/CommentCopySheet.kt new file mode 100644 index 00000000..074d4e01 --- /dev/null +++ b/app/src/main/kotlin/com/aritxonly/bdfun/ui/comment/CommentCopySheet.kt @@ -0,0 +1,116 @@ +package com.aritxonly.bdfun.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +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.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.aritxonly.bdfun.core.model.comment.CommentItem +import top.yukonga.miuix.kmp.theme.MiuixTheme + +/** + * 「自由复制」弹层:把整条评论原文放进可选择的容器里, + * 用户能按住任意一段自由拖选,而不是只能整条复制。 + */ +@Composable +internal fun CommentCopySheet(item: CommentItem?, onDismiss: () -> Unit) { + if (item == null) return + val colors = MiuixTheme.colorScheme + val context = androidx.compose.ui.platform.LocalContext.current + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false, dismissOnClickOutside = true), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 20.dp, vertical = 80.dp), + contentAlignment = Alignment.Center, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(24.dp)) + .background(colors.surface) + .padding(horizontal = 18.dp, vertical = 16.dp), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "自由复制", + color = colors.onBackground, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f), + ) + Text( + text = "完成", + color = colors.primary, + fontSize = 14.sp, + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .clickable(interactionSource = null, indication = null, onClick = onDismiss) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + Spacer(Modifier.height(10.dp)) + Text( + text = item.uname, + color = colors.onBackgroundVariant, + fontSize = 12.sp, + ) + Spacer(Modifier.height(6.dp)) + SelectionContainer( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = item.message, + color = colors.onBackground, + fontSize = 14.5.sp, + lineHeight = 21.sp, + ) + } + Spacer(Modifier.height(14.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(14.dp)) + .background(colors.primary) + .clickable(interactionSource = null, indication = null) { + context.copyToClipboard("评论", item.message) + context.showToast("已复制") + onDismiss() + } + .padding(vertical = 11.dp), + contentAlignment = Alignment.Center, + ) { + Text("复制全部", color = Color.White, fontSize = 14.sp) + } + } + } + } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/ui/comment/CommentMenu.kt b/app/src/main/kotlin/com/aritxonly/bdfun/ui/comment/CommentMenu.kt index 527a79dd..5274304a 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/ui/comment/CommentMenu.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/ui/comment/CommentMenu.kt @@ -22,6 +22,9 @@ internal class CommentMenuStateHolder( private set var anchor by mutableStateOf(Rect.Zero) private set + /** 自由复制弹层当前展示的评论。 */ + var copySheet by mutableStateOf(null) + private set fun open(item: CommentItem, bounds: Rect) { if (!actions.isActive) return anchor = bounds @@ -34,6 +37,7 @@ internal class CommentMenuStateHolder( hide() scope.launch { delay(360); if (!visible) target = null } } + fun closeCopySheet() { copySheet = null } fun reply(item: CommentItem) { if (!actions.isActive) return hide() @@ -46,6 +50,34 @@ internal class CommentMenuStateHolder( context.showToast("已复制") scope.launch { delay(360); target = null } } + /** 自由复制:弹层里展示原文,文本可以按住自由拖选。 */ + fun freeCopy(item: CommentItem) { + if (!actions.isActive) return + hide() + scope.launch { + delay(320) + target = null + copySheet = item + } + } + /** 评论附图整批存相册。 */ + fun savePictures(item: CommentItem) { + if (!actions.isActive || item.pictures.isEmpty()) return + hide() + scope.launch { + val (saved, total) = ImageSaver.save(context, item.pictures) + if (!actions.isActive) return@launch + context.showToast( + when { + saved == total && total > 0 -> "已保存 $saved 张图片" + saved > 0 -> "已保存 $saved/$total 张,部分失败" + else -> "保存失败" + }, + ) + delay(360) + target = null + } + } fun share(item: CommentItem) { if (!actions.isActive) return hide() @@ -101,8 +133,13 @@ internal fun CommentMenu(menu: CommentMenuStateHolder, myMid: Long) { if (target != null) { CommentMenuRow("回复", MenuGlyphKind.Reply, colors.onBackground) { menu.reply(target) } CommentMenuRow("复制", MenuGlyphKind.Copy, colors.onBackground) { menu.copy(target, false) } - CommentMenuRow("自由复制", MenuGlyphKind.Copy, colors.onBackground) { menu.copy(target, true) } + CommentMenuRow("自由复制", MenuGlyphKind.Copy, colors.onBackground) { menu.freeCopy(target) } CommentMenuRow("分享", null, colors.onBackground) { menu.share(target) } + if (target.pictures.isNotEmpty()) { + CommentMenuRow("保存全部图片", MenuGlyphKind.Copy, colors.onBackground) { + menu.savePictures(target) + } + } CommentMenuRow("举报", MenuGlyphKind.Report, Color(0xFFE5484D)) { menu.report(target) } CommentMenuRow("加入黑名单", MenuGlyphKind.Block, Color(0xFFE5484D)) { menu.block(target) } if (myMid > 0 && target.mid == myMid) { @@ -110,4 +147,7 @@ internal fun CommentMenu(menu: CommentMenuStateHolder, myMid: Long) { } } } + // 自由复制弹层:独立的可选择文本窗口。 + CommentCopySheet(menu.copySheet, menu::closeCopySheet) } + diff --git a/core/common/src/main/kotlin/com/aritxonly/bdfun/core/common/BiliImageUrl.kt b/core/common/src/main/kotlin/com/aritxonly/bdfun/core/common/BiliImageUrl.kt index e6413116..109fa2a8 100644 --- a/core/common/src/main/kotlin/com/aritxonly/bdfun/core/common/BiliImageUrl.kt +++ b/core/common/src/main/kotlin/com/aritxonly/bdfun/core/common/BiliImageUrl.kt @@ -13,3 +13,25 @@ fun biliImageUrl(url: String, width: Int = 0): String { } return result } + +/** + * 只按宽度缩放的缩略图地址(`@720w.webp`),**不裁切**。 + * + * 列表缩略图和图片查看器必须来自同一张"未裁切"的源图: + * 如果列表先用 `_1c` 让服务端裁成 16:9、再由单元格裁一次, + * 查看器按原图只裁一次,两边看到的画面就不一致—— + * 只有本来就是 16:9 的图才碰巧对得上,其他比例收放都会错位。 + * + * 这里保留原始宽高比,两边的"中心裁切"结果就完全一致, + * 入场/退场的共用元素动画才能严丝合缝。 + */ +fun biliImageThumbUrl(url: String, width: Int): String { + if (url.isBlank()) return "" + val normalized = when { + url.startsWith("//") -> "https:$url" + url.startsWith("http://") -> "https://${url.removePrefix("http://")}" + else -> url + } + if (width <= 0 || normalized.contains("@")) return normalized + return "$normalized@${width}w.webp" +} diff --git a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/DiscoverRepository.kt b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/DiscoverRepository.kt index 4353ca5e..9c351d4c 100644 --- a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/DiscoverRepository.kt +++ b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/DiscoverRepository.kt @@ -2,6 +2,7 @@ package com.aritxonly.bdfun.core.domain import com.aritxonly.bdfun.core.common.Outcome import com.aritxonly.bdfun.core.model.live.LiveCard +import com.aritxonly.bdfun.core.model.live.LiveFollower import com.aritxonly.bdfun.core.model.live.LiveHome import com.aritxonly.bdfun.core.model.pgc.BangumiCard @@ -12,6 +13,9 @@ interface LiveDiscoverRepository { suspend fun home(page: Int = 1): Outcome suspend fun list(page: Int = 1, parentAreaId: Long = 0L, areaId: Long = 0L): Outcome> + + /** 我的关注里正在直播的 UP(动态 portal 的 live_users,和动态页头像行同源)。 */ + suspend fun followers(): Outcome> } /** diff --git a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/PgcPlaybackRepository.kt b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/PgcPlaybackRepository.kt new file mode 100644 index 00000000..4b09fa0d --- /dev/null +++ b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/PgcPlaybackRepository.kt @@ -0,0 +1,18 @@ +package com.aritxonly.bdfun.core.domain + +import com.aritxonly.bdfun.core.common.Outcome +import com.aritxonly.bdfun.core.model.pgc.BangumiEpisode +import com.aritxonly.bdfun.core.model.pgc.BangumiSeason +import com.aritxonly.bdfun.core.model.pgc.BangumiStream + +/** + * 番剧播放所需的只读接口:剧集详情 + 播放地址。 + * + * 播放地址走 PGC 专用接口(`/pgc/player/web/playurl`),普通视频的 + * `/x/player/playurl` 对番剧只能拿到低清,所以不复用视频那条链路。 + */ +interface PgcPlaybackRepository { + suspend fun season(seasonId: Long): Outcome + + suspend fun stream(episode: BangumiEpisode): Outcome +} diff --git a/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/PgcRankRepository.kt b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/PgcRankRepository.kt new file mode 100644 index 00000000..14de63c9 --- /dev/null +++ b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/PgcRankRepository.kt @@ -0,0 +1,25 @@ +package com.aritxonly.bdfun.core.domain + +import com.aritxonly.bdfun.core.common.Outcome +import com.aritxonly.bdfun.core.model.pgc.BangumiRankBoard +import com.aritxonly.bdfun.core.model.pgc.BangumiRankTab + +/** + * 番剧页「排行榜」的读取接口。 + * + * 榜单目录与榜单内容是两次请求:先 [tabs] 拿榜名/rank_id/oid,再按榜取 [board]。 + * 接口匿名可用,不需要登录态,也不做分页。 + */ +interface PgcRankRepository { + suspend fun tabs(indexId: Int): Outcome> + + suspend fun board(tab: BangumiRankTab): Outcome + + /** + * 需要大会员的 season_id 集合。 + * + * 榜单接口(`/pgc/season/rank/v2/list`)不返回会员信息,只能拿网页版榜单的 + * `badge` 做对照;覆盖的是该 season_type 的热门榜前 99 个,够卡片角标用。 + */ + suspend fun vipSeasons(seasonType: Int): Outcome> +} diff --git a/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt index 049b135f..99152e1f 100644 --- a/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt +++ b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt @@ -8,20 +8,44 @@ data class LiveCard( val cover: String, /** 直播内容 / 分区名,例如「原神」「王者荣耀」。 */ val areaName: String, - /** 接口给的「x.x万人看过」原文;为空时界面按 online 兜底。 */ + /** 接口给的「x人看过」原文(watched_show.text_large);为空时界面按 online 兜底。 */ val watchedText: String = "", ) /** 直播首页一屏:关注直播 + 分区入口 + 推荐卡片(对应 PiliPlus 的 card_list)。 */ data class LiveHome( - val following: List = emptyList(), + val following: List = emptyList(), val areas: List = emptyList(), val cards: List = emptyList(), + /** 接口的 has_more;1 表示还能继续翻页。 */ + val hasMore: Boolean = false, ) -/** 直播分区(第二行横向列表)。parentId 为 0 表示这是一级分区本身。 */ +/** + * 「我的关注正在直播」的 UP 主。 + * 和动态页顶部头像行同一个数据源(动态 portal 的 live_users),两边头像/文案才一致。 + */ +data class LiveFollower( + val roomId: Long, + val mid: Long, + val name: String, + val face: String, + val title: String = "", +) + +/** + * 直播分区(第二行横向列表)。 + * + * `area_entrance_v3` 的每一项同时带 `area_v2_id` 和 `area_v2_parent_id`, + * 而且「虚拟主播」这类入口的 area_v2_id 就是 0——所以两个请求参数必须分开存, + * 不能像以前那样合成一个 id,否则点进去会带错分区。 + */ data class LiveArea( + /** 选中态与列表 key(area_v2_id 为 0 时退回 parent id,保证唯一)。 */ val id: Long, val name: String, - val parentId: Long = 0L, + /** 请求用的 parent_area_id。 */ + val parentAreaId: Long = 0L, + /** 请求用的 area_id,允许为 0。 */ + val areaId: Long = 0L, ) diff --git a/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/pgc/BangumiPlayback.kt b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/pgc/BangumiPlayback.kt new file mode 100644 index 00000000..5796ef20 --- /dev/null +++ b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/pgc/BangumiPlayback.kt @@ -0,0 +1,45 @@ +package com.aritxonly.bdfun.core.model.pgc + +/** 番剧的一集。字段来自 `/pgc/view/web/season` 的 episodes / sections。 */ +data class BangumiEpisode( + val epId: Long, + val aid: Long, + val cid: Long, + /** 这一集对应的稿件 BV 号:站内播放复用它进视频详情页。 */ + val bvid: String = "", + val title: String, + val longTitle: String, + val cover: String, + /** 集号文本,如「1」「12」。 */ + val index: String, + val durationMs: Long, + /** 分组标题:正片 / 预告 / 花絮…,来自 sections.title。 */ + val sectionTitle: String = "正片", +) { + /** 选集按钮上显示的短名:有长标题用集号+长标题,没有就用 title。 */ + val displayName: String get() = if (longTitle.isBlank()) title else longTitle +} + +/** 一部番剧的基本信息 + 全部剧集。 */ +data class BangumiSeason( + val seasonId: Long, + val title: String, + val cover: String, + /** 评分文本,如 9.5;无评分是空串。 */ + val rating: String, + val evaluate: String, + val subtitle: String, + val episodes: List, +) { + val sections: List>> + get() = episodes.groupBy { it.sectionTitle }.map { (title, list) -> title to list } +} + +/** 播放地址:DASH 时音视频分开,durl 时只有一路。 */ +data class BangumiStream( + val videoUrl: String, + val audioUrl: String?, + val qualityId: Int, + val qualityLabel: String, + val durationMs: Long, +) diff --git a/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/pgc/BangumiRank.kt b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/pgc/BangumiRank.kt new file mode 100644 index 00000000..09bdc4e8 --- /dev/null +++ b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/pgc/BangumiRank.kt @@ -0,0 +1,39 @@ +package com.aritxonly.bdfun.core.model.pgc + +/** + * 番剧排行榜的一个榜单分类,来自 /pgc/season/rank/tabs。 + * + * [oid] 是接口原样下发的 `{season_type:1,style_id:70}` 这种串,**不是合法 JSON**; + * 请求榜单内容时必须原样回传,不要解析、不要重新拼。 + */ +data class BangumiRankTab( + val rankId: Long, + val title: String, + val oid: String, +) + +/** 榜单里的一条番剧。 */ +data class BangumiRankEntry( + val rank: Int, + val seasonId: Long, + val title: String, + val cover: String, + /** 卡片标题下面那行推荐语,接口字段 subtitle。 */ + val subtitle: String = "", + /** 「更新至第X话 / 全X话」,接口字段 new_ep.index_show。 */ + val indexShow: String = "", + /** 评分文本,空串表示暂无评分。 */ + val rating: String = "", + /** 样式标签,如「番剧·漫画改·智斗」。 */ + val style: String = "", + /** 冲榜文案,如「荣登榜首」,常为空。 */ + val tag: String = "", + /** 需要大会员。榜单接口不返回,由网页榜单的 badge 对照补上。 */ + val vip: Boolean = false, +) + +/** 一个榜单的完整内容;接口一次性返回整榜,不分页。 */ +data class BangumiRankBoard( + val note: String = "", + val entries: List = emptyList(), +) diff --git a/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/player/PlayerPictureOverlays.kt b/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/player/PlayerPictureOverlays.kt index 0a17454d..0f05fbc1 100644 --- a/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/player/PlayerPictureOverlays.kt +++ b/feature/video/src/main/kotlin/com/aritxonly/bdfun/feature/video/presentation/player/PlayerPictureOverlays.kt @@ -75,7 +75,9 @@ fun BoxScope.PlayerHotDanmakuOverlay(ui: VideoPlayerUiStateHolder, fullscreen: B modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() - .padding(top = if (fullscreen) 44.dp else 8.dp) + // 计数贴到画面最顶部(控件隐藏时这里本来就是空的), + // 下面的弹幕行会按 topGuard 让开这块区域。 + .padding(top = if (fullscreen) 10.dp else 4.dp) .padding(horizontal = 4.dp), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically, diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliBackend.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliBackend.kt index 1195f4f1..5a1d8ce1 100644 --- a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliBackend.kt +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliBackend.kt @@ -28,6 +28,8 @@ open class BiliBackend(context: Context, debug: Boolean, log: (String, String) - internal val api = BiliApiContext(context, debug, log, cdnResolver) val webSocketClient get() = api.webSocketClient val cookieJar get() = api.cookieJar + /** 播放器拉流用的 Cookie 头(直播 CDN 需要)。 */ + fun cookieHeader(): String = api.cookieJar.header() val isLoggedIn get() = api.isLoggedIn val mid get() = api.mid val csrf get() = api.csrf @@ -44,6 +46,7 @@ open class BiliBackend(context: Context, debug: Boolean, log: (String, String) - suspend fun liveRecommend(page: Int = 1, parentAreaId: Long = 0L, areaId: Long = 0L) = api.liveRecommend(page, parentAreaId, areaId) suspend fun liveAreaList(parentId: Long = 0L) = api.liveAreaList(parentId) + suspend fun liveFollowers() = api.liveFollowers() suspend fun pgcFollowing(page: Int = 1) = api.pgcFollowing(page) suspend fun pgcIndex(page: Int = 1, pageSize: Int = 20) = api.pgcIndex(page, pageSize) suspend fun nav() = api.nav() diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliCookieJar.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliCookieJar.kt index abba8ad4..185b3f36 100644 --- a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliCookieJar.kt +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliCookieJar.kt @@ -64,6 +64,9 @@ class BiliCookieJar(context: Context) : CookieJar { fun value(name: String): String = cookies[name]?.value.orEmpty() + /** 拼成 Cookie 请求头;播放器直接拉流时也要带,否则部分 CDN 会 403。 */ + fun header(): String = cookies.values.joinToString("; ") { "${it.name}=${it.value}" } + val csrf: String get() = value("bili_jct") val hasSession: Boolean get() = value("SESSDATA").isNotBlank() diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliDiscoverRepositories.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliDiscoverRepositories.kt index fb302dc8..fe4ac82b 100644 --- a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliDiscoverRepositories.kt +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliDiscoverRepositories.kt @@ -13,6 +13,9 @@ class BiliLiveDiscoverRepository(private val api: BiliBackend) : LiveDiscoverRep override suspend fun list(page: Int, parentAreaId: Long, areaId: Long): com.aritxonly.bdfun.core.common.Outcome> = biliOutcome { api.liveRecommend(page, parentAreaId, areaId) } + + override suspend fun followers(): com.aritxonly.bdfun.core.common.Outcome> = + biliOutcome { api.liveFollowers() } } /** 首页番剧段:最近追番 + 推荐。 */ diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliPgcPlaybackRepository.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliPgcPlaybackRepository.kt new file mode 100644 index 00000000..8c58f3bf --- /dev/null +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliPgcPlaybackRepository.kt @@ -0,0 +1,11 @@ +package com.aritxonly.bdfun.infra.bili + +import com.aritxonly.bdfun.core.domain.PgcPlaybackRepository +import com.aritxonly.bdfun.core.model.pgc.BangumiEpisode + +/** 番剧播放仓储:只做错误映射,请求与解析在 PgcPlaybackApi / mapper。 */ +class BiliPgcPlaybackRepository(private val backend: BiliBackend) : PgcPlaybackRepository { + override suspend fun season(seasonId: Long) = biliOutcome { backend.api.pgcSeason(seasonId) } + + override suspend fun stream(episode: BangumiEpisode) = biliOutcome { backend.api.pgcStream(episode) } +} diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliPgcRankRepository.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliPgcRankRepository.kt new file mode 100644 index 00000000..a251721c --- /dev/null +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliPgcRankRepository.kt @@ -0,0 +1,13 @@ +package com.aritxonly.bdfun.infra.bili + +import com.aritxonly.bdfun.core.domain.PgcRankRepository +import com.aritxonly.bdfun.core.model.pgc.BangumiRankTab + +/** 番剧页排行榜的仓储实现:只加错误映射,不复制请求逻辑。 */ +class BiliPgcRankRepository(private val backend: BiliBackend) : PgcRankRepository { + override suspend fun tabs(indexId: Int) = biliOutcome { backend.api.pgcRankTabs(indexId) } + + override suspend fun board(tab: BangumiRankTab) = biliOutcome { backend.api.pgcRankBoard(tab) } + + override suspend fun vipSeasons(seasonType: Int) = biliOutcome { backend.api.pgcRankVipSeasons(seasonType) } +} diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt index 120f948f..6402e363 100644 --- a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt @@ -2,6 +2,7 @@ package com.aritxonly.bdfun.infra.bili import com.aritxonly.bdfun.core.model.live.LiveArea import com.aritxonly.bdfun.core.model.live.LiveCard +import com.aritxonly.bdfun.core.model.live.LiveFollower import com.aritxonly.bdfun.core.model.live.LiveHome import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -14,94 +15,84 @@ private const val liveReferer = "https://live.bilibili.com/" /** * 直播首页,照搬 PiliPlus 的 `LiveHttp.liveFeedIndex`: * app 端 `/xlive/app-interface/v2/index/feed`,必须 AppSign,web 的 WBI 在这条接口上无效。 + * + * 参数少了会被风控:实测只带 page 时接口返回 -400(请求错误); + * 补上 fnval / network / scale 后同样的 appkey+签名就能拿到 22 张卡片。 */ internal suspend fun BiliApiContext.liveHome(page: Int = 1): LiveHome = withContext(Dispatchers.IO) { val result = cancellableCatching { - val params = liveAppParams(mapOf("page" to page.toString())) val data = dataRequest( "/xlive/app-interface/v2/index/feed", - params, + liveAppParams(mapOf("page" to page.toString())), base = livePageBase, userAgent = BILI_APP_UA, headers = BILI_APP_HEADERS, ) val list = data.optJSONArray("card_list") ?: return@cancellableCatching LiveHome() - var following = emptyList() var areas = emptyList() val cards = mutableListOf() for (i in 0 until list.length()) { val card = list.optJSONObject(i) ?: continue val cardData = card.optJSONObject("card_data") when (card.optString("card_type")) { - "my_idol_v1" -> following = cardData.parseLiveCardList("my_idol_v1") - "area_entrance_v3" -> areas = cardData.parseLiveAreas("area_entrance_v3") - "small_card_v1" -> cards += cardData.parseLiveCardList("small_card_v1") + // 分区入口的 card_data 里是 { list: [...] };关注直播走 portal(见 liveFollowers)。 + "area_entrance_v3" -> areas = cardData.cardDataList("area_entrance_v3").toLiveAreas() + // small_card_v1 的 card_data 是「单个房间对象」,不是 list。 + // 以前按 list 解析,22 张卡全部被丢掉,页面就只剩空白。 + "small_card_v1" -> cardData?.optJSONObject("small_card_v1")?.toLiveCard()?.let(cards::add) } } - LiveHome(following, areas, cards) + LiveHome(emptyList(), areas, cards, hasMore = data.optInt("has_more", 0) == 1) }.getOrDefault(LiveHome()) - // app 端整段拿不到时(签名被拒/接口调整),退回 web 分区列表,至少页面不是空的。 - if (result.cards.isNotEmpty() || result.areas.isNotEmpty()) { - result - } else { - LiveHome( - following = result.following, - areas = liveAreaList(0L), - cards = webLiveList(1, 0L, 0L), - ) - } + // 登录态的首页接口不一定下发 area_entrance_v3(实测 card_list 里只有 banner 和 + // small_card_v1),这时按 PiliPlus 的做法补一份分区入口,否则第二行的胶囊永远是空的。 + if (result.areas.isEmpty()) result.copy(areas = liveAreaList(0L)) else result } -/** 分区直播流,照搬 PiliPlus 的 `LiveHttp.liveSecondList`(app 端 second/getList)。 */ +/** + * 分区直播流。 + * + * 原来用 app 端 `second/getList`,实测(带登录 Cookie、完整 app 参数与签名、甚至补 WBI) + * 一直返回 -352,点了分区就是空的。BiliPai 用的 `/room/v3/area/getRoomList` 不需要签名, + * 实测 parent_area_id=0/1/2 都能拿到 20 条,所以改走这条。 + */ internal suspend fun BiliApiContext.liveRecommend( page: Int = 1, parentAreaId: Long = 0L, areaId: Long = 0L, ): List = withContext(Dispatchers.IO) { cancellableCatching { - val params = liveAppParams( + val data = dataRequest( + "/room/v3/area/getRoomList", mapOf( - "area_id" to areaId.toString(), "parent_area_id" to parentAreaId.toString(), + "area_id" to areaId.toString(), "page" to page.toString(), "page_size" to "20", - "tag_version" to "1", - "qn" to "0", "sort_type" to "online", ), - ) - val data = dataRequest( - "/xlive/app-interface/v2/second/getList", - params, base = livePageBase, - userAgent = BILI_APP_UA, - headers = BILI_APP_HEADERS, + referer = liveReferer, ) data.optJSONArray("list").toLiveCards() - }.getOrDefault(emptyList()).ifEmpty { webLiveList(page, parentAreaId, areaId) } + }.getOrDefault(emptyList()) } -/** App 端接口异常时的兜底:老的 web 分区列表(有 buvid 时会话通常能过)。 */ -private suspend fun BiliApiContext.webLiveList( - page: Int, parentAreaId: Long, areaId: Long, -): List { - val params = mapOf( - "platform" to "web", - "parent_area_id" to parentAreaId.toString(), - "area_id" to areaId.toString(), - "sort_type" to "online", - "page" to page.toString(), - "page_size" to "20", - ) - suspend fun once(signed: Boolean): List = cancellableCatching { - val data = dataRequest( - "/xlive/web-interface/v1/second/getList", - params, - signed = signed, - base = livePageBase, - referer = liveReferer, - ) - data.optJSONArray("list").toLiveCards() +/** + * 我的关注里正在直播的 UP。 + * 和动态页顶部头像行用同一个 portal(`/x/polymer/web-dynamic/v1/portal` 的 live_users), + * 头像、名字、房间号天然一致;未登录时接口失败,返回空列表,页面就不显示这一行。 + */ +internal suspend fun BiliApiContext.liveFollowers(): List = withContext(Dispatchers.IO) { + cancellableCatching { + dynamicPortal().liveUsers.map { user -> + LiveFollower( + roomId = user.roomId, + mid = user.mid, + name = user.name, + face = user.face, + title = user.title, + ) + } }.getOrDefault(emptyList()) - return once(true).ifEmpty { once(false) } } /** 一级分区兜底列表(卡片里没有 area_entrance_v3 时用)。 */ @@ -118,11 +109,17 @@ internal suspend fun BiliApiContext.liveAreaList(parentId: Long = 0L): List): MutableMap { val params = mutableMapOf( "channel" to "master", @@ -135,9 +132,12 @@ private fun BiliApiContext.liveAppParams(extra: Map): MutableMap "device_name" to "android", "device_type" to "0", "disable_rcmd" to "0", + "fnval" to "912", "https_url_req" to "1", "mobi_app" to "android", + "network" to "wifi", "platform" to "android", + "scale" to "2", "statistics" to "{\"appId\":1,\"platform\":3,\"version\":\"8.43.0\",\"abtest\":\"\"}", ) params.putAll(extra) @@ -146,52 +146,60 @@ private fun BiliApiContext.liveAppParams(extra: Map): MutableMap return params } -private fun JSONObject?.parseLiveCardList(key: String): List = - this?.optJSONObject(key)?.optJSONArray("list").toLiveCards() +/** card_data 里形如 { "": { "list": [...] } } 的节点。 */ +private fun JSONObject?.cardDataList(key: String): JSONArray? = + this?.optJSONObject(key)?.optJSONArray("list") + +private fun JSONArray?.toLiveCards(): List { + if (this == null) return emptyList() + return (0 until length()).mapNotNull { i -> optJSONObject(i)?.toLiveCard() } +} -private fun JSONObject?.parseLiveAreas(key: String): List { - val list = this?.optJSONObject(key)?.optJSONArray("list") ?: return emptyList() - val areas = ArrayList(list.length()) - for (i in 0 until list.length()) { - val item = list.optJSONObject(i) ?: continue +private fun JSONArray?.toLiveAreas(): List { + if (this == null) return emptyList() + val areas = ArrayList(length()) + for (i in 0 until length()) { + val item = optJSONObject(i) ?: continue val parent = item.optLong("area_v2_parent_id", 0L) val area = item.optLong("area_v2_id", 0L) - val title = item.optString("title").orEmpty() - // 分区入口以 parent 为主键;像「颜值」这种带 area_id 的用 area 做 key。 - val id = if (area > 0L) area else parent - if (id > 0L && title.isNotBlank()) areas += LiveArea(id, title, parent) + val name = item.optString("title").orEmpty().ifBlank { item.optString("name").orEmpty() } + // 选中态需要一个唯一 key:虚拟主播这类 area_v2_id 就是 0,只能退回 parent。 + val key = if (area > 0L) area else parent + if (key > 0L && name.isNotBlank()) { + areas += LiveArea(id = key, name = name, parentAreaId = parent, areaId = area) + } } return areas } -private fun JSONArray?.toLiveCards(): List { - if (this == null) return emptyList() - return (0 until length()).mapNotNull { i -> optJSONObject(i)?.toLiveCard() } -} - private fun JSONObject.toLiveCard(): LiveCard? { val roomId = optLong("roomid", 0L).takeIf { it > 0L } ?: optLong("id", 0L).takeIf { it > 0L } ?: return null - val watched = optJSONObject("watched_show")?.optString("text_small").orEmpty() - .ifBlank { optString("text_small").orEmpty() } - .ifBlank { - val online = optLong("online", 0L) - if (online > 0L) "${formatLiveCount(online)}人看过" else "" - } - val cover = optString("cover").orEmpty() - .ifBlank { optString("system_cover").orEmpty() } - .ifBlank { optString("room_cover").orEmpty() } return LiveCard( roomId = roomId, title = optString("title").orEmpty().ifBlank { optString("uname").orEmpty() }, uname = optString("uname").orEmpty(), - cover = biliImage(cover), - areaName = optString("area_name").orEmpty(), - watchedText = watched, + cover = biliImage( + optString("cover").orEmpty() + .ifBlank { optString("system_cover").orEmpty() } + .ifBlank { optString("room_cover").orEmpty() }, + ), + areaName = optString("area_name").orEmpty().ifBlank { optString("parent_area_name").orEmpty() }, + watchedText = watchedText(), ) } +/** watched_show.text_large 才是「2163人看过」;text_small 是「2163」,只在缺字段时兜底。 */ +private fun JSONObject.watchedText(): String { + val watched = optJSONObject("watched_show") + watched?.optString("text_large").orEmpty().takeIf { it.isNotBlank() }?.let { return it } + watched?.optString("text_small").orEmpty().takeIf { it.isNotBlank() }?.let { return it } + optString("text_large").takeIf { it.isNotBlank() }?.let { return it } + val online = optLong("online", 0L) + return if (online > 0L) "${formatLiveCount(online)}人看过" else "" +} + private fun formatLiveCount(value: Long): String = when { value >= 10_000L -> String.format("%.1f万", value / 10_000f) else -> value.toString() diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcPlaybackApi.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcPlaybackApi.kt new file mode 100644 index 00000000..e607546d --- /dev/null +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcPlaybackApi.kt @@ -0,0 +1,51 @@ +package com.aritxonly.bdfun.infra.bili + +import com.aritxonly.bdfun.core.model.pgc.BangumiEpisode +import com.aritxonly.bdfun.core.model.pgc.BangumiSeason +import com.aritxonly.bdfun.core.model.pgc.BangumiStream +import com.aritxonly.bdfun.infra.bili.mapper.mapBangumiSeason +import com.aritxonly.bdfun.infra.bili.mapper.mapBangumiStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +private const val pgcPlayReferer = "https://www.bilibili.com/bangumi/play/" + +/** + * 番剧详情。信封是 `result` 不是 `data`,所以走 rawRequest 拿整包。 + */ +internal suspend fun BiliApiContext.pgcSeason(seasonId: Long): BangumiSeason = + withContext(Dispatchers.IO) { + val root = rawRequest( + "/pgc/view/web/season", + mapOf("season_id" to seasonId.toString()), + referer = pgcPlayReferer, + ) + val code = root.optInt("code", 0) + if (code != 0) throw BiliException(code, apiMessage(code, root.optString("message"))) + val result = root.optJSONObject("result") + ?: throw BiliException(code, "番剧信息缺失") + mapBangumiSeason(result) + } + +/** + * 播放地址(PGC 专用)。`fnval=4048` 要 DASH;返回空就当作没拿到地址。 + */ +internal suspend fun BiliApiContext.pgcStream(episode: BangumiEpisode): BangumiStream = + withContext(Dispatchers.IO) { + val root = rawRequest( + "/pgc/player/web/playurl", + mapOf( + "ep_id" to episode.epId.toString(), + "cid" to episode.cid.toString(), + "avid" to episode.aid.toString(), + "fnval" to "4048", + "fourk" to "1", + ), + referer = pgcPlayReferer, + ) + val code = root.optInt("code", 0) + if (code != 0) throw BiliException(code, apiMessage(code, root.optString("message"))) + val result = root.optJSONObject("result") + ?: throw BiliException(code, "播放地址缺失") + mapBangumiStream(result) ?: throw BiliException(code, "没有可用的播放地址") + } diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcRankApi.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcRankApi.kt new file mode 100644 index 00000000..7f126794 --- /dev/null +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcRankApi.kt @@ -0,0 +1,62 @@ +package com.aritxonly.bdfun.infra.bili + +import com.aritxonly.bdfun.core.model.pgc.BangumiRankBoard +import com.aritxonly.bdfun.core.model.pgc.BangumiRankTab +import com.aritxonly.bdfun.infra.bili.mapper.mapRankBoard +import com.aritxonly.bdfun.infra.bili.mapper.mapRankTabs +import com.aritxonly.bdfun.infra.bili.mapper.mapRankVipSeasons +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** 番剧页排行榜:番剧(1) / 国创(4) 两个 index。电影等其它 index 本页不接。 */ +internal const val PGC_RANK_INDEX_BANGUMI = 1 +internal const val PGC_RANK_INDEX_GUOCHUANG = 4 + +private const val pgcRankReferer = "https://www.bilibili.com/anime/" + +/** + * 榜单目录。`data` 是数组,走 rawRequest 拿整包再自己判 code—— + * dataRequest 只会返回 `data` 对象,数组会被吞掉。 + */ +internal suspend fun BiliApiContext.pgcRankTabs(indexId: Int): List = + withContext(Dispatchers.IO) { + val root = rawRequest( + "/pgc/season/rank/tabs", + mapOf("index_id" to indexId.toString()), + referer = pgcRankReferer, + ) + val code = root.optInt("code", 0) + if (code != 0) throw BiliException(code, apiMessage(code, root.optString("message"))) + mapRankTabs(root.optJSONArray("data")) + } + +/** 单个榜单内容;一次返回整榜,不分页。 */ +internal suspend fun BiliApiContext.pgcRankBoard(tab: BangumiRankTab): BangumiRankBoard = + withContext(Dispatchers.IO) { + val data = dataRequest( + "/pgc/season/rank/v2/list", + mapOf("oid" to tab.oid, "rank_id" to tab.rankId.toString()), + referer = pgcRankReferer, + ) + mapRankBoard(data) + } + +/** + * 需要大会员的 season_id 集合。 + * + * `/pgc/season/rank/v2/list` 不带会员字段,这里用网页版榜单(有 `badge`/`badge_info`)做对照。 + * 拉取失败就返回空集合:角标少显示,不影响榜单本身。 + */ +internal suspend fun BiliApiContext.pgcRankVipSeasons(seasonType: Int): Set = + withContext(Dispatchers.IO) { + // 这个接口的信封是 result 不是 data,所以走 rawRequest 自己取。 + val root = rawRequest( + "/pgc/web/rank/list", + mapOf("season_type" to seasonType.toString(), "day" to "3"), + referer = pgcRankReferer, + ) + val code = root.optInt("code", 0) + if (code != 0) throw BiliException(code, apiMessage(code, root.optString("message"))) + val payload = root.optJSONObject("result") ?: root.optJSONObject("data") ?: return@withContext emptySet() + mapRankVipSeasons(payload) + } diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/CommentItemMapper.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/CommentItemMapper.kt index be6ec5a6..9e56cf89 100644 --- a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/CommentItemMapper.kt +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/CommentItemMapper.kt @@ -25,7 +25,7 @@ internal fun parseComment( } val pictures = content?.optJSONArray("pictures")?.objects()?.mapNotNull { pic -> val url = pic.s("img_src").ifBlank { pic.s("url") } - if (url.isBlank()) null else image(url, 720) + if (url.isBlank()) null else image(url, 0) }.orEmpty() // 正文里 @ 的人;官方接口会在 content.members 里给出 mid。 val mentions = content?.optJSONArray("members")?.objects()?.mapNotNull { m -> diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/FeedContentMapper.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/FeedContentMapper.kt index 7f76eb8a..b29d62c0 100644 --- a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/FeedContentMapper.kt +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/FeedContentMapper.kt @@ -40,10 +40,10 @@ internal fun parseFeedContent(major: FeedMajor, desc: String): FeedContent { .ifBlank { music?.s("desc").orEmpty() } val images = buildList { major.value?.optJSONObject("draw")?.optJSONArray("items")?.objects()?.forEach { - it.s("src").takeIf(String::isNotBlank)?.let { src -> add(image(src, 720)) } + it.s("src").takeIf(String::isNotBlank)?.let { src -> add(image(src, 0)) } } opus?.optJSONArray("pics")?.objects()?.forEach { - it.s("url").takeIf(String::isNotBlank)?.let { url -> add(image(url, 720)) } + it.s("url").takeIf(String::isNotBlank)?.let { url -> add(image(url, 0)) } } article?.optJSONArray("covers")?.let { covers -> for (i in 0 until covers.length()) { @@ -53,14 +53,14 @@ internal fun parseFeedContent(major: FeedMajor, desc: String): FeedContent { is JSONObject -> value.s("url") else -> "" } - if (url.isNotBlank()) add(image(url, 720)) + if (url.isNotBlank()) add(image(url, 0)) } } - common?.s("cover").orEmpty().takeIf { it.isNotBlank() }?.let { add(image(it, 720)) } - live?.s("cover").orEmpty().takeIf { it.isNotBlank() }?.let { add(image(it, 720)) } - medialist?.s("cover").orEmpty().takeIf { it.isNotBlank() }?.let { add(image(it, 720)) } - courses?.s("cover").orEmpty().takeIf { it.isNotBlank() }?.let { add(image(it, 720)) } - music?.s("cover").orEmpty().takeIf { it.isNotBlank() }?.let { add(image(it, 720)) } + common?.s("cover").orEmpty().takeIf { it.isNotBlank() }?.let { add(image(it, 0)) } + live?.s("cover").orEmpty().takeIf { it.isNotBlank() }?.let { add(image(it, 0)) } + medialist?.s("cover").orEmpty().takeIf { it.isNotBlank() }?.let { add(image(it, 0)) } + courses?.s("cover").orEmpty().takeIf { it.isNotBlank() }?.let { add(image(it, 0)) } + music?.s("cover").orEmpty().takeIf { it.isNotBlank() }?.let { add(image(it, 0)) } }.filter { it.isNotBlank() }.distinct() return FeedContent(title, text, images) } diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/PgcPlaybackMapper.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/PgcPlaybackMapper.kt new file mode 100644 index 00000000..df01b0b6 --- /dev/null +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/PgcPlaybackMapper.kt @@ -0,0 +1,110 @@ +package com.aritxonly.bdfun.infra.bili.mapper + +import com.aritxonly.bdfun.core.common.biliImageThumbUrl +import com.aritxonly.bdfun.core.model.pgc.BangumiEpisode +import com.aritxonly.bdfun.core.model.pgc.BangumiSeason +import com.aritxonly.bdfun.core.model.pgc.BangumiStream +import org.json.JSONObject + +/** + * `/pgc/view/web/season` 的 result → BangumiSeason。 + * + * 字段来自 2026-11-14 的真实响应:episodes[].ep_id/aid/cid/title/long_title/cover/duration/index, + * 预告花絮在 sections[],每项有 title + episodes。 + */ +internal fun mapBangumiSeason(result: JSONObject): BangumiSeason { + val main = result.optJSONArray("episodes").objects().mapNotNull { it.toEpisode("正片") } + val extra = ArrayList() + result.optJSONArray("sections").objects().forEach { section -> + val title = section.s("title").ifBlank { "其他" } + section.optJSONArray("episodes").objects().forEach { item -> + item.toEpisode(title)?.let(extra::add) + } + } + return BangumiSeason( + seasonId = result.l("season_id"), + title = result.s("title"), + cover = biliImageThumbUrl(result.s("cover"), 480), + rating = result.optJSONObject("rating")?.s("score").orEmpty(), + evaluate = result.s("evaluate"), + subtitle = result.s("subtitle"), + episodes = dedupeById(main + extra), + ) +} + +/** 同一集可能同时出现在 episodes 和 sections 里,按 ep_id 保留第一次出现。 */ +private fun dedupeById(items: List): List { + val seen = HashSet() + return items.filter { it.epId > 0L && seen.add(it.epId) } +} + +private fun JSONObject.toEpisode(sectionTitle: String): BangumiEpisode? { + val epId = l("ep_id").takeIf { it > 0L } ?: l("id").takeIf { it > 0L } ?: return null + val cid = l("cid") + if (cid <= 0L) return null + return BangumiEpisode( + epId = epId, + aid = l("aid"), + cid = cid, + bvid = s("bvid"), + title = s("title"), + longTitle = s("long_title"), + cover = biliImageThumbUrl(s("cover"), 320), + index = s("title"), + durationMs = l("duration"), + sectionTitle = sectionTitle, + ) +} + +/** + * `/pgc/player/web/playurl` 的 result → BangumiStream。 + * + * DASH:dash.video[] 与 dash.audio[] 分开,各自有 base_url。 + * 老接口(fnval=0)会给 durl[],这里一并兼容。 + */ +internal fun mapBangumiStream(result: JSONObject): BangumiStream? { + val durationMs = result.l("timelength") + val dash = result.optJSONObject("dash") + if (dash != null) { + val video = dash.optJSONArray("video").objects() + .filter { it.s("baseUrl").ifBlank { it.s("base_url") }.isNotBlank() } + if (video.isEmpty()) return null + // 同一 id 会按编码给多条(avc/hevc/av1),取第一条即可,编码兼容性交给播放器。 + val best = video.maxByOrNull { it.optInt("id") } ?: return null + val audio = dash.optJSONArray("audio").objects() + .filter { it.s("baseUrl").ifBlank { it.s("base_url") }.isNotBlank() } + .maxByOrNull { it.l("bandwidth") } + return BangumiStream( + videoUrl = best.s("baseUrl").ifBlank { best.s("base_url") }, + audioUrl = audio?.let { it.s("baseUrl").ifBlank { it.s("base_url") } }, + qualityId = best.optInt("id"), + qualityLabel = qualityLabel(best.optInt("id")), + durationMs = durationMs, + ) + } + val durl = result.optJSONArray("durl").objects().firstOrNull() ?: return null + val url = durl.s("url").ifBlank { durl.s("backup_url") } + if (url.isBlank()) return null + return BangumiStream( + videoUrl = url, + audioUrl = null, + qualityId = result.optInt("quality"), + qualityLabel = qualityLabel(result.optInt("quality")), + durationMs = durationMs, + ) +} + +private fun qualityLabel(id: Int): String = when (id) { + 127 -> "8K" + 126 -> "杜比视界" + 125 -> "HDR" + 120 -> "4K" + 116 -> "1080P60" + 112 -> "1080P+" + 80 -> "1080P" + 74 -> "720P60" + 64 -> "720P" + 32 -> "480P" + 16 -> "360P" + else -> "${id}P" +} diff --git a/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/PgcRankMapper.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/PgcRankMapper.kt new file mode 100644 index 00000000..37573664 --- /dev/null +++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/mapper/PgcRankMapper.kt @@ -0,0 +1,70 @@ +package com.aritxonly.bdfun.infra.bili.mapper + +import com.aritxonly.bdfun.core.common.biliImageThumbUrl +import com.aritxonly.bdfun.core.model.pgc.BangumiRankBoard +import com.aritxonly.bdfun.core.model.pgc.BangumiRankEntry +import com.aritxonly.bdfun.core.model.pgc.BangumiRankTab +import org.json.JSONArray +import org.json.JSONObject + +/** + * 榜单目录:`data` 是数组,每项 {oid, rank_id, rank_title}。 + * + * 真实样本见 D:\work\_apicollect\samples\rank_tabs_index1.json。 + */ +internal fun mapRankTabs(data: JSONArray?): List = + data.objects().mapNotNull { item -> + val rankId = item.l("rank_id") + if (rankId <= 0L) return@mapNotNull null + BangumiRankTab( + rankId = rankId, + title = item.s("rank_title"), + oid = item.s("oid"), + ) + } + +/** + * 榜单内容:`data` 是对象,条目在 season_vo_list。 + * + * rating / subtitle / season_tag 都可能是空串;new_ep 可能整个缺失。 + * 真实样本见 samples\rank_list_rank124_top3.json。 + */ +internal fun mapRankBoard(data: JSONObject): BangumiRankBoard = BangumiRankBoard( + note = data.s("note"), + entries = data.optJSONArray("season_vo_list").objects().mapNotNull { it.toRankEntry() }, +) + +/** + * 网页版榜单里的会员角标 → 需要大会员的 season_id 集合。 + * 榜单接口本身没有这个信息,只能靠它补 `vip`。 + */ +internal fun mapRankVipSeasons(payload: JSONObject): Set { + val list = payload.optJSONArray("list") ?: return emptySet() + val vip = HashSet() + list.objects().forEach { item -> + val badge = item.s("badge") + item.optJSONObject("badge_info")?.s("text").orEmpty() + if (badge.contains("会员")) { + val seasonId = item.l("season_id") + if (seasonId > 0L) vip += seasonId + } + } + return vip +} + +private fun JSONObject.toRankEntry(): BangumiRankEntry? { val seasonId = l("season_id") + if (seasonId <= 0L) return null + val cover = s("cover").ifBlank { s("ss_horizontal_cover") } + return BangumiRankEntry( + rank = optInt("rank", 0), + seasonId = seasonId, + title = s("title"), + // 必须用只缩宽、不裁切的地址:`@480w_270h_1c` 会让服务端先裁成 16:9, + // 卡片再裁到 3:4 就是裁两遍,人像会被切头。 + cover = biliImageThumbUrl(cover, 480), + subtitle = s("subtitle"), + indexShow = optJSONObject("new_ep")?.s("index_show").orEmpty(), + rating = s("rating"), + style = s("season_style").removePrefix("番剧·").removePrefix("国创·"), + tag = s("season_tag"), + ) +} diff --git a/infra/bili/src/test/kotlin/com/aritxonly/bdfun/infra/bili/mapper/CommentMapperTest.kt b/infra/bili/src/test/kotlin/com/aritxonly/bdfun/infra/bili/mapper/CommentMapperTest.kt index d3017092..4ba37199 100644 --- a/infra/bili/src/test/kotlin/com/aritxonly/bdfun/infra/bili/mapper/CommentMapperTest.kt +++ b/infra/bili/src/test/kotlin/com/aritxonly/bdfun/infra/bili/mapper/CommentMapperTest.kt @@ -30,7 +30,10 @@ class CommentMapperTest { assertFalse(page.items[1].isUp) assertEquals(listOf(323662867632L, 323664379808L), first.previewReplies.map { it.rpid }) assertEquals(1, page.items[1].pictures.size) - assertTrue(page.items[1].pictures.single().contains("@720w")) + // 评论配图同样保留原图:曾经拼过 @720w_405h_1c 的居中裁切, + // 大图查看器只能看到被裁掉的那一块,所以这里断言“没有裁切参数”。 + assertFalse(page.items[1].pictures.single().contains("_1c")) + assertTrue(page.items[1].pictures.single().startsWith("https://")) assertEquals(253, page.total) assertFalse(page.isEnd) } diff --git a/infra/bili/src/test/kotlin/com/aritxonly/bdfun/infra/bili/mapper/FeedItemMapperTest.kt b/infra/bili/src/test/kotlin/com/aritxonly/bdfun/infra/bili/mapper/FeedItemMapperTest.kt index 44bb1f49..d40743ab 100644 --- a/infra/bili/src/test/kotlin/com/aritxonly/bdfun/infra/bili/mapper/FeedItemMapperTest.kt +++ b/infra/bili/src/test/kotlin/com/aritxonly/bdfun/infra/bili/mapper/FeedItemMapperTest.kt @@ -60,7 +60,8 @@ class FeedItemMapperTest { .put("medialist", JSONObject().put("cover", "//img/f.jpg")).put("courses", JSONObject().put("cover", "//img/e.jpg")) .put("music", JSONObject().put("cover", "//img/g.jpg")).put("pgc", JSONObject().put("cover", "//img/pgc.jpg")) val result = parseFeedItem(item(major = major))!! - assertEquals(('a'..'g').map { "https://img/$it.jpg@720w_405h_1c.webp" }, result.images) + // 动态图片保留原图 URL:缩略图裁切由界面层按显示宽度加,查看器必须拿到完整图。 + assertEquals(('a'..'g').map { "https://img/$it.jpg" }, result.images) assertEquals("https://img/pgc.jpg@720w_405h_1c.webp", result.pgcCover) } @Test fun originalForwardingStillStopsAfterOneLevel() { diff --git a/infra/bili/src/test/kotlin/com/aritxonly/bdfun/infra/bili/mapper/PgcRankMapperTest.kt b/infra/bili/src/test/kotlin/com/aritxonly/bdfun/infra/bili/mapper/PgcRankMapperTest.kt new file mode 100644 index 00000000..439a3516 --- /dev/null +++ b/infra/bili/src/test/kotlin/com/aritxonly/bdfun/infra/bili/mapper/PgcRankMapperTest.kt @@ -0,0 +1,108 @@ +package com.aritxonly.bdfun.infra.bili.mapper + +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 样本来自真实响应(2026-11-14 匿名拉取): + * - tabs: /pgc/season/rank/tabs?index_id=1 + * - board: /pgc/season/rank/v2/list?oid={season_type:1,style_id:70}&rank_id=125 + * 原始完整样本见 D:\work\_apicollect\samples\。 + */ +class PgcRankMapperTest { + + @Test + fun `解析榜单目录并保留 oid 原样`() { + val tabs = mapRankTabs(JSONArray(TABS_JSON)) + + assertEquals(2, tabs.size) + assertEquals(124L, tabs[0].rankId) + assertEquals("热门榜", tabs[0].title) + // oid 不是合法 JSON,必须逐字保留,编码由请求层做。 + assertEquals("{season_type:1,style_id:0}", tabs[0].oid) + assertEquals(125L, tabs[1].rankId) + assertEquals("{season_type:1,style_id:70}", tabs[1].oid) + } + + @Test + fun `解析榜单内容并映射卡片字段`() { + val board = mapRankBoard(JSONObject(BOARD_JSON)) + + assertEquals("根据内容的热度排名,每小时更新一次", board.note) + assertEquals(2, board.entries.size) + + val first = board.entries[0] + assertEquals(1, first.rank) + assertEquals(348190L, first.seasonId) + assertEquals("海绵宝宝 第十二季 中文配音", first.title) + assertEquals("准备好了吗孩子们", first.subtitle) + assertEquals("更新至第18话", first.indexShow) + assertEquals("荣登榜首", first.tag) + assertEquals("", first.rating) // 无评分时是空串,不能当异常 + assertTrue(first.cover.contains("@480w")) + + val second = board.entries[1] + assertEquals(2, second.rank) + assertEquals("银魂", second.title) + assertEquals("全200话", second.indexShow) + assertEquals("9.9", second.rating) + } + + @Test + fun `缺 new_ep 与空 season_vo_list 都不抛异常`() { + val noEp = mapRankBoard(JSONObject("""{"note":"","season_vo_list":[{"rank":3,"season_id":1,"title":"x"}]}""")) + assertEquals("", noEp.entries.single().indexShow) + + val empty = mapRankBoard(JSONObject("""{"note":"","season_vo_list":[]}""")) + assertEquals(0, empty.entries.size) + + // season_id 缺失的项直接丢弃,不产生 seasonId=0 的脏数据。 + val dirty = mapRankBoard(JSONObject("""{"note":"","season_vo_list":[{"rank":1,"title":"bad"}]}""")) + assertEquals(0, dirty.entries.size) + } + + private companion object { + const val TABS_JSON = """ + [ + {"oid":"{season_type:1,style_id:0}","rank_id":124,"rank_title":"热门榜"}, + {"oid":"{season_type:1,style_id:70}","rank_id":125,"rank_title":"搞笑榜"} + ] + """ + + const val BOARD_JSON = """ + { + "color_control": 1, + "note": "根据内容的热度排名,每小时更新一次", + "season_vo_list": [ + { + "rank": 1, + "season_id": 348190, + "title": "海绵宝宝 第十二季 中文配音", + "cover": "https://i0.hdslb.com/bfs/bangumi/image/666e8d57768a22d3f7ea5d53ef7b501fdde4833f.png", + "subtitle": "准备好了吗孩子们", + "rating": "", + "season_style": "番剧·少儿·搞笑", + "season_tag": "荣登榜首", + "new_ep": {"cover": "", "ep_id": 7515973, "index_show": "更新至第18话"}, + "type": "PGC" + }, + { + "rank": 2, + "season_id": 38157, + "title": "银魂", + "cover": "http://i0.hdslb.com/bfs/bangumi/image/087b862b772ee4e644478a36c757a26db476193d.png", + "subtitle": "最无厘头的热血动画", + "rating": "9.9", + "season_style": "番剧·漫画改·热血", + "season_tag": "", + "new_ep": {"cover": "", "ep_id": 394945, "index_show": "全200话"}, + "type": "PGC" + } + ] + } + """ + } +} diff --git a/infra/playback/src/main/kotlin/com/aritxonly/bdfun/infra/playback/Media3LivePlayback.kt b/infra/playback/src/main/kotlin/com/aritxonly/bdfun/infra/playback/Media3LivePlayback.kt index ea18ebc2..c02243a8 100644 --- a/infra/playback/src/main/kotlin/com/aritxonly/bdfun/infra/playback/Media3LivePlayback.kt +++ b/infra/playback/src/main/kotlin/com/aritxonly/bdfun/infra/playback/Media3LivePlayback.kt @@ -4,21 +4,68 @@ import android.content.Context import android.view.LayoutInflater import android.view.View import androidx.media3.common.MediaItem +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.DefaultHttpDataSource import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.ui.PlayerView import com.aritxonly.bdfun.core.domain.LivePlayback -class Media3LivePlayback(context: Context, private val layoutId: Int) : LivePlayback { - private val player = ExoPlayer.Builder(context.applicationContext).build().apply { playWhenReady = true } - override fun prepare(url: String) { player.setMediaItem(MediaItem.fromUri(url)); player.prepare(); player.play() } +/** + * 直播播放器。 + * + * 拉流请求必须自带和网页一致的 Referer / UA / Cookie:B 站直播 CDN 会按 Referer 校验, + * ExoPlayer 默认的 `ExoPlayerLib/…` UA 也会被部分节点拒绝,表现就是有地址但一直黑屏。 + */ +class Media3LivePlayback( + context: Context, + private val layoutId: Int, + cookie: String = "", +) : LivePlayback { + private val httpDataSource = DefaultHttpDataSource.Factory() + .setUserAgent(LIVE_UA) + .setAllowCrossProtocolRedirects(true) + .setConnectTimeoutMs(15_000) + .setReadTimeoutMs(15_000) + .setDefaultRequestProperties( + buildMap { + put("Referer", "https://live.bilibili.com/") + put("Origin", "https://live.bilibili.com") + if (cookie.isNotBlank()) put("Cookie", cookie) + }, + ) + + private val player = ExoPlayer.Builder(context.applicationContext) + .setMediaSourceFactory( + DefaultMediaSourceFactory( + DefaultDataSource.Factory(context.applicationContext, httpDataSource), + ), + ) + .build() + .apply { playWhenReady = true } + + override fun prepare(url: String) { + player.setMediaItem(MediaItem.fromUri(url)) + player.prepare() + player.play() + } + override fun play() = player.play() override fun pause() = player.pause() override fun release() = player.release() + fun createView(context: Context): View = (LayoutInflater.from(context).inflate(layoutId, null) as PlayerView).apply { useController = false setShutterBackgroundColor(android.graphics.Color.BLACK) this.player = this@Media3LivePlayback.player } + fun bindView(view: View) { (view as PlayerView).player = player } fun releaseView(view: View) { (view as PlayerView).player = null } + + private companion object { + const val LIVE_UA = + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36" + } } From 5d72ff9e181fe031c64ec6afdbf287d5438d3211 Mon Sep 17 00:00:00 2001 From: QxTeak <351827445+QxTeak@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:18:30 +0800 Subject: [PATCH 7/8] =?UTF-8?q?refactor(photo):=20=E6=8B=86=E5=88=86=20Pho?= =?UTF-8?q?toViewerPage=EF=BC=8C=E6=BB=A1=E8=B6=B3=E5=8D=95=E5=87=BD?= =?UTF-8?q?=E6=95=B0=2080=20=E8=A1=8C=E5=B0=BA=E5=AF=B8=E5=AE=A1=E8=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI 的 :tools:kotlin-audit:checkKotlinSizes 报 PhotoViewerPage 函数体 99 行超限; 拆出 PhotoViewerStage / PhotoViewerLayers,并把一帧内的几何打包成 PhotoStageFrame。 行为不变。 --- .../common/photo/PhotoViewerPage.kt | 140 +++++++++++------- 1 file changed, 90 insertions(+), 50 deletions(-) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPage.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPage.kt index 239b12dc..1669fb9c 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPage.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerPage.kt @@ -43,7 +43,7 @@ import kotlin.math.roundToInt * 注意裁剪窗口不能用 size 去约束图片——那样图片会被父约束压小,再乘裁切倍率就成了一个点。 * 这里图片始终按完整尺寸布局,只在绘制阶段用路径裁剪。 * - * 两层图:底层是列表里同一张 16:9 裁切缩略图(缓存命中,入场/退场第一帧就贴合), + * 两层图:底层是列表里同一张缩略图(只缩宽度、不裁切,缓存命中,入场/退场第一帧就贴合), * 上层是原图,加载完成后淡入。这样既不会出现"切到原图后闪一下",也能保证放大后是完整图。 */ @Composable @@ -74,8 +74,9 @@ internal fun PhotoViewerPage( val screenHeight = view.size.height.toFloat() if (screenWidth <= 0f || screenHeight <= 0f) return + val progress = view.progress.value val morph = photoMorphFrame( - progress = view.progress.value, + progress = progress, originX = origin?.left ?: 0f, originY = origin?.top ?: 0f, originWidth = origin?.width ?: 0f, @@ -85,19 +86,52 @@ internal fun PhotoViewerPage( imageAspect = page.aspect, originRadius = originRadiusPx, ) - // 拖动位移随进度衰减:进度归零时正好落在缩略图上,不会带着偏移落地再跳回去。 - val dragY = view.dragY * view.progress.value - // 缩放/平移也跟着进度收敛:放大过再退出时,画面会平滑回到缩略图而不是"啪"一下跳回去。 - val settle = view.progress.value - val zoomNow = 1f + (page.zoom - 1f) * settle - val panNow = page.pan * settle - // 完全展开后不再裁剪:否则放大后的图片会被自身矩形(比屏幕小)切掉。 - val inTransition = view.progress.value < 0.999f - val imageWidthDp = with(density) { morph.imageWidth.toDp() } - val imageHeightDp = with(density) { morph.imageHeight.toDp() } - // 和列表缩略图完全同一张(同样只缩宽度、不裁切),Coil 直接命中缓存。 - val thumbModel = remember(url) { biliImageThumbUrl(url, PhotoThumbWidth) } + val frame = PhotoStageFrame( + morph = morph, + thumbModel = remember(url) { biliImageThumbUrl(url, PhotoThumbWidth) }, + fullAlpha = fullAlpha, + // 完全展开后不再裁剪:否则放大后的图片会被自身矩形(比屏幕小)切掉。 + inTransition = progress < 0.999f, + imageWidth = with(density) { morph.imageWidth.toDp() }, + imageHeight = with(density) { morph.imageHeight.toDp() }, + // 缩放/平移也跟着进度收敛:放大过再退出时,画面会平滑回到缩略图而不是"啪"一下跳回去。 + zoom = 1f + (page.zoom - 1f) * progress, + pan = page.pan * progress, + // 拖动位移随进度衰减:进度归零时正好落在缩略图上,不会带着偏移落地再跳回去。 + dragY = view.dragY * progress, + ) + PhotoViewerStage(url, page, view, frame, onDismiss, onLongPress) { width, height -> + if (width > 0 && height > 0) page.aspect = width.toFloat() / height + fullReady = true + // 告诉宿主:宽高比已经确定,可以放心跑入场动画了。 + view.contentReady = true + } +} +/** 一帧内所有会变的几何/透明度,打包传给各层,避免函数参数超过 12 个。 */ +private data class PhotoStageFrame( + val morph: PhotoMorphFrame, + val thumbModel: String, + val fullAlpha: Float, + val inTransition: Boolean, + val imageWidth: Dp, + val imageHeight: Dp, + val zoom: Float, + val pan: Offset, + val dragY: Float, +) + +@Composable +private fun PhotoViewerStage( + url: String, + page: PhotoViewerPageState, + view: PhotoViewerTransitionState, + frame: PhotoStageFrame, + onDismiss: () -> Unit, + onLongPress: (String, Offset) -> Unit, + onFullLoaded: (Int, Int) -> Unit, +) { + val morph = frame.morph Box( modifier = Modifier .fillMaxSize() @@ -116,47 +150,53 @@ internal fun PhotoViewerPage( currentPan = { page.pan }, size = { view.size }, ) - .graphicsLayer { translationY = dragY }, + .graphicsLayer { translationY = frame.dragY }, ) { - Box(Modifier.fillMaxSize().morphClip(morph, skipClip = !inTransition)) { - // 原图还没就绪时只画缩略图:它和卡片是同一张源图、同样的中心裁切, - // 所以入场第一帧、退场最后一帧都和列表里的缩略图完全重合。 - if (fullAlpha < 0.999f) { - MorphImage( - model = thumbModel, - morph = morph, - width = imageWidthDp, - height = imageHeightDp, - zoom = zoomNow, - pan = panNow, - // Crop:宽高比还没算出来时也保证铺满框,不会出现黑边。 - contentScale = ContentScale.Crop, - // 缩略图也报一次宽高比:它命中缓存后立刻就能开始入场动画。 - onLoaded = { width, height -> - if (width > 0 && height > 0) page.aspect = width.toFloat() / height - view.contentReady = true - }, - ) - } - MorphImage( - model = url, - morph = morph, - width = imageWidthDp, - height = imageHeightDp, - zoom = zoomNow, - pan = panNow, - alpha = fullAlpha, - onLoaded = { width, height -> - if (width > 0 && height > 0) page.aspect = width.toFloat() / height - fullReady = true - // 告诉宿主:宽高比已经确定,可以放心跑入场动画了。 - view.contentReady = true - }, - ) + Box(Modifier.fillMaxSize().morphClip(morph, skipClip = !frame.inTransition)) { + PhotoViewerLayers(url, page, view, frame, onFullLoaded) } } } +@Composable +private fun PhotoViewerLayers( + url: String, + page: PhotoViewerPageState, + view: PhotoViewerTransitionState, + frame: PhotoStageFrame, + onFullLoaded: (Int, Int) -> Unit, +) { + // 原图还没就绪时只画缩略图:它和卡片是同一张源图、同样的中心裁切, + // 所以入场第一帧、退场最后一帧都和列表里的缩略图完全重合。 + if (frame.fullAlpha < 0.999f) { + MorphImage( + model = frame.thumbModel, + morph = frame.morph, + width = frame.imageWidth, + height = frame.imageHeight, + zoom = frame.zoom, + pan = frame.pan, + // Crop:宽高比还没算出来时也保证铺满框,不会出现黑边。 + contentScale = ContentScale.Crop, + // 缩略图也报一次宽高比:它命中缓存后立刻就能开始入场动画。 + onLoaded = { width, height -> + if (width > 0 && height > 0) page.aspect = width.toFloat() / height + view.contentReady = true + }, + ) + } + MorphImage( + model = url, + morph = frame.morph, + width = frame.imageWidth, + height = frame.imageHeight, + zoom = frame.zoom, + pan = frame.pan, + alpha = frame.fullAlpha, + onLoaded = onFullLoaded, + ) +} + /** 和列表一致的缩略图宽度;宽度决定裁切尺寸,必须和 FeedImageGrid 保持同步。 */ private const val PhotoThumbWidth = 720 From 32891606f02be58bb026332bccfa706a046671dc Mon Sep 17 00:00:00 2001 From: QxTeak <351827445+QxTeak@users.noreply.github.com> Date: Sat, 14 Nov 2026 19:39:23 +0800 Subject: [PATCH 8/8] =?UTF-8?q?refactor(live/pgc):=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E7=A7=BB=E5=88=B0=20presentation=20=E5=B1=82?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=88=86=E5=B1=82=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-layering 报 feature/*/application 引用了 Compose,且 pgc 跨 feature 引用 live: - LivePageStateHolder / BangumiPageStateHolder 移到 presentation/live、presentation/pgc - 直播与番剧共用的 AppError.readable() 提到 presentation/common/discover - 同步更新 AppContainer 与四处引用 行为不变;本地 :tools:kotlin-audit:checkKotlinSizes / assembleDebug / assembleRelease 均通过。 --- .../kotlin/com/aritxonly/bdfun/di/AppContainer.kt | 4 ++-- .../common/discover/DiscoverCommon.kt | 15 +++++++++++++++ .../bdfun/presentation/live/LivePageItems.kt | 2 +- .../bdfun/presentation/live/LivePageScreen.kt | 2 +- .../live}/LivePageStateHolder.kt | 11 ++--------- .../bdfun/presentation/pgc/BangumiPageItems.kt | 2 +- .../bdfun/presentation/pgc/BangumiPageScreen.kt | 2 +- .../pgc}/BangumiPageStateHolder.kt | 4 ++-- 8 files changed, 25 insertions(+), 17 deletions(-) rename app/src/main/kotlin/com/aritxonly/bdfun/{feature/live/application => presentation/live}/LivePageStateHolder.kt (92%) rename app/src/main/kotlin/com/aritxonly/bdfun/{feature/pgc/application => presentation/pgc}/BangumiPageStateHolder.kt (96%) diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt b/app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt index f0710312..1c644b13 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt @@ -146,11 +146,11 @@ internal class AppContainer( val homeFeedState: HomeFeedState get() = homeSlot.get().view // 首页「直播」「番剧」两段:与推荐页同一套壳,状态在各自 feature 下,接口走 infra 仓储。 val livePageState by lazy { - com.aritxonly.bdfun.feature.live.application.LivePageStateHolder( + com.aritxonly.bdfun.presentation.live.LivePageStateHolder( com.aritxonly.bdfun.infra.bili.BiliLiveDiscoverRepository(api)) } val bangumiPageState by lazy { - com.aritxonly.bdfun.feature.pgc.application.BangumiPageStateHolder( + com.aritxonly.bdfun.presentation.pgc.BangumiPageStateHolder( com.aritxonly.bdfun.infra.bili.BiliPgcDiscoverRepository(api)) } val bangumiRankState by lazy { diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt index 50f9c3e2..27faca91 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.aritxonly.bdfun.core.common.AppError import com.aritxonly.teak.common.chrome.TeakGlass import com.kyant.shapes.Capsule import top.yukonga.miuix.kmp.theme.MiuixTheme @@ -53,3 +54,17 @@ internal fun DiscoverErrorState(message: String, onRetry: () -> Unit) { } } } + +/** + * 直播 / 番剧两段共用的错误文案。 + * + * 放在公共 discover 组件里,而不是某个 feature 的 application 层: + * 否则另一个 feature 引用它就成了「跨 feature 依赖」,分层检查会直接拦下。 + */ +internal fun AppError.readable(): String = when (this) { + is AppError.RiskControl -> "请求被风控,稍后重试" + AppError.LoginExpired -> "登录已失效" + is AppError.Network -> message.ifBlank { "网络错误" } + is AppError.Parsing -> "数据解析失败" + is AppError.Unknown -> message.ifBlank { "加载失败" } +} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt index b2b87ccc..74773628 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt @@ -34,7 +34,7 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.aritxonly.bdfun.core.model.live.LiveArea import com.aritxonly.bdfun.core.model.live.LiveCard -import com.aritxonly.bdfun.feature.live.application.LivePageStateHolder +import com.aritxonly.bdfun.presentation.live.LivePageStateHolder import com.aritxonly.bdfun.presentation.common.discover.DiscoverErrorState import com.aritxonly.bdfun.presentation.common.discover.DiscoverSectionTitle import com.aritxonly.bdfun.navigateToLogin diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageScreen.kt index 35ce812f..761f6329 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageScreen.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageScreen.kt @@ -22,7 +22,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import com.aritxonly.bdfun.feature.live.application.LivePageStateHolder +import com.aritxonly.bdfun.presentation.live.LivePageStateHolder import com.aritxonly.bdfun.navigateToLiveRoom import com.aritxonly.bdfun.ui.HomeTopBarHeight import com.aritxonly.bdfun.presentation.common.discover.DiscoverPullSession diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/live/application/LivePageStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageStateHolder.kt similarity index 92% rename from app/src/main/kotlin/com/aritxonly/bdfun/feature/live/application/LivePageStateHolder.kt rename to app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageStateHolder.kt index 55580bb0..c7ffeec0 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/feature/live/application/LivePageStateHolder.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageStateHolder.kt @@ -1,4 +1,4 @@ -package com.aritxonly.bdfun.feature.live.application +package com.aritxonly.bdfun.presentation.live import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.getValue @@ -11,6 +11,7 @@ import com.aritxonly.bdfun.core.domain.LiveDiscoverRepository import com.aritxonly.bdfun.core.model.live.LiveArea import com.aritxonly.bdfun.core.model.live.LiveCard import com.aritxonly.bdfun.core.model.live.LiveFollower +import com.aritxonly.bdfun.presentation.common.discover.readable /** * 首页「直播」段的状态:关注直播 + 分区 + 分区直播流。 @@ -137,11 +138,3 @@ internal class LivePageStateHolder(private val repository: LiveDiscoverRepositor } } } - -internal fun AppError.readable(): String = when (this) { - is AppError.RiskControl -> "请求被风控,稍后重试" - AppError.LoginExpired -> "登录已失效" - is AppError.Network -> message.ifBlank { "网络错误" } - is AppError.Parsing -> "数据解析失败" - is AppError.Unknown -> message.ifBlank { "加载失败" } -} diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageItems.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageItems.kt index 75f1f124..d8e16e11 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageItems.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageItems.kt @@ -35,7 +35,7 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.aritxonly.bdfun.BrandPink import com.aritxonly.bdfun.core.model.pgc.BangumiCard -import com.aritxonly.bdfun.feature.pgc.application.BangumiPageStateHolder +import com.aritxonly.bdfun.presentation.pgc.BangumiPageStateHolder import com.aritxonly.bdfun.presentation.common.discover.DiscoverErrorState import com.aritxonly.bdfun.presentation.common.discover.DiscoverSectionTitle import com.aritxonly.bdfun.ui.FeedCardShape diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageScreen.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageScreen.kt index 2b1ce616..af92c23e 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageScreen.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageScreen.kt @@ -25,7 +25,7 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import com.aritxonly.bdfun.feature.pgc.application.BangumiPageStateHolder +import com.aritxonly.bdfun.presentation.pgc.BangumiPageStateHolder import com.aritxonly.bdfun.presentation.common.discover.DiscoverSectionTitle import com.aritxonly.bdfun.ui.HomeTopBarHeight import com.aritxonly.bdfun.presentation.common.discover.DiscoverPullSession diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiPageStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageStateHolder.kt similarity index 96% rename from app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiPageStateHolder.kt rename to app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageStateHolder.kt index 9b6aefb4..fd1f50ef 100644 --- a/app/src/main/kotlin/com/aritxonly/bdfun/feature/pgc/application/BangumiPageStateHolder.kt +++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageStateHolder.kt @@ -1,4 +1,4 @@ -package com.aritxonly.bdfun.feature.pgc.application +package com.aritxonly.bdfun.presentation.pgc import androidx.compose.foundation.lazy.grid.LazyGridState import androidx.compose.runtime.getValue @@ -9,7 +9,7 @@ import com.aritxonly.bdfun.core.common.AppError import com.aritxonly.bdfun.core.common.Outcome import com.aritxonly.bdfun.core.domain.PgcDiscoverRepository import com.aritxonly.bdfun.core.model.pgc.BangumiCard -import com.aritxonly.bdfun.feature.live.application.readable +import com.aritxonly.bdfun.presentation.common.discover.readable /** * 首页「番剧」段的状态:最近追番 + 推荐。