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/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/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/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 92e5492b..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
@@ -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() }
@@ -33,7 +33,10 @@ internal fun CommentPictures(urls: List, onOpen: (Int, Rect, Float) -> U
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
@@ -42,7 +45,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.indices.map { bounds[it] })
},
)
}
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/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/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/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/comment/row/CommentTypography.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/comment/row/CommentTypography.kt
index e7e35a44..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
@@ -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.value - 1).sp
+ val previewLineHeight = (bodyLineHeight.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/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/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
new file mode 100644
index 00000000..27faca91
--- /dev/null
+++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverCommon.kt
@@ -0,0 +1,70 @@
+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.bdfun.core.common.AppError
+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 = 20.sp,
+ lineHeight = 24.sp,
+ fontWeight = FontWeight.Bold,
+ 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),
+ )
+ }
+ }
+}
+
+/**
+ * 直播 / 番剧两段共用的错误文案。
+ *
+ * 放在公共 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/common/discover/DiscoverPull.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverPull.kt
new file mode 100644
index 00000000..eb509027
--- /dev/null
+++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/discover/DiscoverPull.kt
@@ -0,0 +1,93 @@
+package com.aritxonly.bdfun.presentation.common.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/common/photo/PhotoViewerOverlay.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/common/photo/PhotoViewerOverlay.kt
index c4382bf2..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)
+ 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(
@@ -105,13 +186,28 @@ private fun PhotoViewerTopBar(onDismiss: () -> Unit) {
}
@Composable
-private fun PhotoViewerPager(request: PhotoViewerRequest, view: PhotoViewerTransitionState) {
+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),
pageCount = { request.images.size },
)
HorizontalPager(state = pager, modifier = Modifier.fillMaxSize()) { page ->
- PhotoViewerPage(url = request.images[page], request = request, view = view)
+ // 每一页用自己的缩略图矩形收拢:滑到第二张再退出,就落到第二张的位置。
+ val origin = request.originFor(page)
+ PhotoViewerPage(
+ url = request.images[page],
+ request = request,
+ view = view,
+ 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 473068af..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
@@ -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,24 +42,41 @@ import kotlin.math.roundToInt
* 倍率是刚好铺满它的裁切倍率,所以第一帧和列表里的缩略图完全重合;终点倍率为 1。
* 注意裁剪窗口不能用 size 去约束图片——那样图片会被父约束压小,再乘裁切倍率就成了一个点。
* 这里图片始终按完整尺寸布局,只在绘制阶段用路径裁剪。
+ *
+ * 两层图:底层是列表里同一张缩略图(只缩宽度、不裁切,缓存命中,入场/退场第一帧就贴合),
+ * 上层是原图,加载完成后淡入。这样既不会出现"切到原图后闪一下",也能保证放大后是完整图。
*/
@Composable
internal fun PhotoViewerPage(
url: String,
request: PhotoViewerRequest,
view: PhotoViewerTransitionState,
+ /** 只有从缩略图打开的那一页才有起点矩形;翻到别的图片后不再借用它的位置。 */
+ 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()
if (screenWidth <= 0f || screenHeight <= 0f) return
- val origin = request.origin
+ 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,
@@ -61,22 +84,63 @@ internal fun PhotoViewerPage(
screenWidth = screenWidth,
screenHeight = screenHeight,
imageAspect = page.aspect,
- originRadius = request.originRadiusPx,
+ originRadius = originRadiusPx,
)
- // 拖动位移随进度衰减:进度归零时正好落在缩略图上,不会带着偏移落地再跳回去。
- val dragY = view.dragY * view.progress.value
- // 完全展开后不再裁剪:否则放大后的图片会被自身矩形(比屏幕小)切掉。
- val inTransition = view.progress.value < 0.999f
- val imageWidthDp = with(density) { morph.imageWidth.toDp() }
- val imageHeightDp = with(density) { morph.imageHeight.toDp() }
+ 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()
- .pointerInput(Unit) {
+ .pointerInput(morph.clipLeft, morph.clipTop, morph.clipWidth, morph.clipHeight) {
detectTapGestures(
- onTap = { view.chromeVisible = !view.chromeVisible },
+ // 点画面任意位置都是关闭(不再要求必须点到图片外的空白)。
+ onTap = { onDismiss() },
onDoubleTap = { tap -> page.zoom = toggleZoom(page.zoom, tap, view.size) },
+ onLongPress = { tap -> onLongPress(url, tap) },
)
}
.photoZoomGestures(
@@ -86,44 +150,78 @@ internal fun PhotoViewerPage(
currentPan = { page.pan },
size = { view.size },
)
- .graphicsLayer { translationY = dragY },
+ .graphicsLayer { translationY = frame.dragY },
) {
- Box(Modifier.fillMaxSize().morphClip(morph, skipClip = !inTransition)) {
- MorphImage(
- url = url,
- morph = morph,
- width = imageWidthDp,
- height = imageHeightDp,
- zoom = page.zoom,
- pan = page.pan,
- onAspect = { page.aspect = it },
- )
+ 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
+
/** 图片按完整尺寸布局,用 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 {
@@ -139,6 +237,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/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/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/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..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
@@ -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
@@ -35,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
@@ -45,7 +47,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
@@ -57,7 +59,7 @@ internal fun FeedImageGrid(
) {
row.forEach { url ->
AsyncImage(
- model = rememberImageRequest(url),
+ model = rememberImageRequest(biliImageGridUrl(url)),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
@@ -67,7 +69,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] })
},
)
}
@@ -77,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/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/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/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)
},
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/live/LivePageItems.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt
new file mode 100644
index 00000000..74773628
--- /dev/null
+++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageItems.kt
@@ -0,0 +1,247 @@
+package com.aritxonly.bdfun.presentation.live
+
+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.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.core.model.live.LiveArea
+import com.aritxonly.bdfun.core.model.live.LiveCard
+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
+import com.aritxonly.bdfun.ui.AnchorBadge
+import com.aritxonly.bdfun.ui.ContinuousCornerShape
+import com.aritxonly.bdfun.ui.FeedCardShape
+import com.aritxonly.bdfun.ui.LiveAvatar
+import com.aritxonly.bdfun.ui.TeakLoading
+import com.aritxonly.bdfun.ui.rememberImageRequest
+import com.kyant.shapes.Capsule
+import kotlinx.coroutines.launch
+import top.yukonga.miuix.kmp.theme.MiuixTheme
+
+/**
+ * 第一行:我的关注里正在直播的 UP。
+ * 小标题在卡片外面(和番剧页「最近追番」同款大小/位置),圆角卡片只包住头像行。
+ */
+internal fun LazyGridScope.liveFollowSection(state: LivePageStateHolder, onOpenRoom: (Long) -> Unit) {
+ if (state.followers.isEmpty()) return
+ item(key = "live-follow", span = { GridItemSpan(maxLineSpan) }) {
+ 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),
+ ) {
+ 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) }
+ }
+ }
+ }
+ }
+ }
+}
+
+/** 第二行:分区胶囊,形状/尺寸和顶栏分段指示器一致。 */
+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.followers.isEmpty()) 0.dp else 4.dp))
+ LazyRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ contentPadding = PaddingValues(vertical = 4.dp),
+ ) {
+ item(key = "area-all") {
+ LiveAreaChip(
+ text = "推荐",
+ selected = state.selectedKey == 0L,
+ onClick = { scope.launch { state.selectArea(LiveArea(0L, "推荐")) } },
+ )
+ }
+ items(state.areas, key = { "${it.parentAreaId}-${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.followers.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
+ // 形状/尺寸和顶栏分段指示器保持一致:32dp 高、全圆角、选中粉色填充 + 白字。
+ Box(
+ modifier = Modifier
+ .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 = 16.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = text,
+ color = if (selected) Color.White else colors.onBackgroundVariant,
+ fontSize = 13.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))
+ // 主播名前缀「主播」:和首页卡片的「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,
+ )
+ }
+ }
+ }
+}
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
new file mode 100644
index 00000000..761f6329
--- /dev/null
+++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageScreen.kt
@@ -0,0 +1,126 @@
+package com.aritxonly.bdfun.presentation.live
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+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.LazyVerticalGrid
+import androidx.compose.foundation.lazy.grid.items
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+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.presentation.live.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.flow.drop
+import kotlinx.coroutines.launch
+import kotlin.math.roundToInt
+
+/**
+ * 首页第一段「直播」。
+ * 与推荐页共用同一套外壳:TrackTeakPageScroll 驱动顶栏渐进模糊/玻璃,
+ * 下拉刷新在 App 层画小电视,本页只负责内容与手势。
+ */
+@Composable
+internal fun LivePage(state: LivePageStateHolder, loggedIn: Boolean) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
+ 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) { navigateToLiveRoom(context, it) }
+ liveAreaSection(state, scope)
+ items(state.cards, key = { it.roomId }) { live ->
+ LiveGridCard(live) { navigateToLiveRoom(context, live.roomId) }
+ }
+ livePageStateItems(state, scope, loggedIn)
+ }
+}
+
+@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 = threshold,
+ maxPullPx = with(density) { 260.dp.toPx() },
+ currentOffset = { ui.offset },
+ onPull = {
+ ui.offset = it
+ state.pullFraction = (it / threshold).coerceIn(0f, 1f)
+ },
+ onPullDp = { state.pullOffsetDp = it },
+ // 这里传的是「网格自己能不能往回滚」,不是取反后的值:
+ // DiscoverPullSession 内部已经写了 !canScrollBackward(),
+ // 以前多取反一次,导致只要不在顶部、往回一拖就开始刷新。
+ canScrollBackward = { state.gridState.canScrollBackward },
+ isRefreshing = { state.refreshing || state.loading },
+ onRefresh = {
+ // 与推荐页一致:松手立即把指示器顶到阈值,再开始刷新。
+ state.pullFraction = 1f
+ scope.launch { state.refresh() }
+ },
+ )
+ }
+}
+
+@Composable
+private fun LivePageEffects(state: LivePageStateHolder, pull: DiscoverPullSession, ui: DiscoverPullUi) {
+ LaunchedEffect(state) { state.ensureLoaded() }
+ // 刷新结束:先收小电视,再让内容平滑回顶(与推荐页同一条时序)。
+ LaunchedEffect(state.refreshing) {
+ if (!state.refreshing && ui.offset > 0f) {
+ state.pullFraction = 0f
+ kotlinx.coroutines.delay(160)
+ pull.animatePullTo(0f, 260f)
+ }
+ }
+ // 滚到底部预取下一页:与推荐页同一套(drop(1) 忽略首次布局,按卡片数比较)
+ LaunchedEffect(state) {
+ snapshotFlow { state.gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
+ .drop(1)
+ .collect { last ->
+ if (state.cards.isNotEmpty() && last >= state.cards.size - 2) state.loadMore()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageStateHolder.kt
new file mode 100644
index 00000000..c7ffeec0
--- /dev/null
+++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/live/LivePageStateHolder.kt
@@ -0,0 +1,140 @@
+package com.aritxonly.bdfun.presentation.live
+
+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
+import com.aritxonly.bdfun.core.model.live.LiveFollower
+import com.aritxonly.bdfun.presentation.common.discover.readable
+
+/**
+ * 首页「直播」段的状态:关注直播 + 分区 + 分区直播流。
+ * 与推荐页一样由 shell 的 TrackTeakPageScroll 接管 gridState(顶栏模糊/玻璃),
+ * 下拉刷新状态供 App 层小电视读取。
+ *
+ * 翻页和首页一样走同一个接口:推荐 tab 用 `/index/feed` 的下一页(PiliPlus 同款),
+ * 切到分区后才换成 `second/getList`。
+ */
+internal class LivePageStateHolder(private val repository: LiveDiscoverRepository) {
+ val gridState = LazyGridState()
+ /** 我的关注里正在直播的 UP(和动态页头像行同一数据源)。 */
+ var followers 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 = area.parentAreaId
+ selectedSub = area.areaId
+ 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 {
+ val more = if (selectedKey == 0L) {
+ when (val result = repository.home(next)) {
+ is Outcome.Success -> result.value.cards
+ is Outcome.Failure -> return
+ }
+ } 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
+ }
+ }
+
+ fun retry() { error = null }
+
+ private suspend fun load() {
+ token++
+ val current = token
+ 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 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) {
+ if (home.areas.isNotEmpty()) areas = home.areas
+ }
+ val first = if (selectedKey == 0L) home?.cards else list
+ if (first == null) {
+ if (cards.isEmpty()) error = failure?.readable() ?: "加载失败"
+ return
+ }
+ cards = first
+ page = 1
+ // 推荐 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/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
new file mode 100644
index 00000000..d8e16e11
--- /dev/null
+++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageItems.kt
@@ -0,0 +1,215 @@
+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.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.runtime.rememberCoroutineScope
+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.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
+import com.aritxonly.bdfun.ui.TeakLoading
+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,
+) {
+ item(key = "pgc-follow", span = { GridItemSpan(maxLineSpan) }) {
+ val openScope = rememberCoroutineScope()
+ Column {
+ 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) }
+ }
+ }
+ }
+ }
+ 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(56.dp)
+ .background(Brush.verticalGradient(listOf(Color.Transparent, Color(0x99000000)))),
+ )
+ 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 = 6.dp, end = 6.dp, top = 6.dp, bottom = 6.dp)) {
+ Text(
+ text = item.title,
+ color = colors.onBackground,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ fontSize = 12.5.sp,
+ lineHeight = 16.5.sp,
+ modifier = Modifier.heightIn(min = 33.dp),
+ )
+ Spacer(Modifier.height(2.dp))
+ // 固定高度:没有「全x话」的卡片也留出同样空间,保证一排卡片底边齐平。
+ 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,
+ )
+ }
+ }
+ }
+ }
+}
+
+/** 与个人主页完全同款:品牌粉胶囊 + 白字。 */
+@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, scope: kotlinx.coroutines.CoroutineScope) {
+ if (seasonId <= 0L) return
+ 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
new file mode 100644
index 00000000..af92c23e
--- /dev/null
+++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageScreen.kt
@@ -0,0 +1,155 @@
+package com.aritxonly.bdfun.presentation.pgc
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.asPaddingValues
+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
+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
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+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.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
+import com.aritxonly.bdfun.presentation.common.discover.DiscoverPullUi
+import kotlinx.coroutines.flow.drop
+import kotlinx.coroutines.launch
+import kotlin.math.roundToInt
+
+/**
+ * 首页第三段「番剧」。外壳与推荐页一致(顶栏渐进模糊 + 下滑玻璃 + App 层下拉小电视),
+ * 内容为「最近追番」横排 + 竖长封面推荐网格。
+ */
+@Composable
+internal fun BangumiPage(state: BangumiPageStateHolder) {
+ val context = LocalContext.current
+ val scope = rememberCoroutineScope()
+ 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(
+ columns = GridCells.Fixed(3),
+ state = gridState,
+ modifier = Modifier
+ .fillMaxSize()
+ .offset { IntOffset(0, pullUi.offset.roundToInt()) }
+ .nestedScroll(pull.connection()),
+ contentPadding = PaddingValues(
+ start = 10.dp,
+ end = 10.dp,
+ top = HomeTopBarHeight + statusBarTop + 6.dp,
+ bottom = 104.dp,
+ ),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
+ 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) }) {
+ BangumiSectionTitle("推荐")
+ }
+ }
+ items(state.cards, key = { it.seasonId }) { item ->
+ BangumiCardItem(item) { openBangumi(context, item.seasonId, scope) }
+ }
+ bangumiPageStateItems(state, scope)
+ }
+}
+
+@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 = threshold,
+ maxPullPx = with(density) { 260.dp.toPx() },
+ currentOffset = { ui.offset },
+ onPull = {
+ ui.offset = it
+ state.pullFraction = (it / threshold).coerceIn(0f, 1f)
+ },
+ onPullDp = { state.pullOffsetDp = it },
+ // 与直播/推荐页一致:传原始 canScrollBackward,别在调用处再取反
+ // (取反会变成「不在顶部也能下拉刷新」)。
+ canScrollBackward = { state.gridState.canScrollBackward },
+ isRefreshing = { state.refreshing || state.loading },
+ onRefresh = {
+ // 与推荐页一致:松手立即把指示器顶到阈值,再开始刷新。
+ state.pullFraction = 1f
+ scope.launch { state.refresh() }
+ },
+ )
+ }
+}
+
+@Composable
+private fun BangumiPageEffects(state: BangumiPageStateHolder, pull: DiscoverPullSession, ui: DiscoverPullUi) {
+ LaunchedEffect(state) { state.ensureLoaded() }
+ LaunchedEffect(state.refreshing) {
+ if (!state.refreshing && ui.offset > 0f) {
+ state.pullFraction = 0f
+ kotlinx.coroutines.delay(160)
+ pull.animatePullTo(0f, 260f)
+ }
+ }
+ // 与推荐页同一套:首次布局会重发一次当前索引(进入页面/从别的页回来),
+ // 不能当成用户滑到底,所以 drop(1);比较对象是卡片数而不是 totalItemsCount
+ // (后者把小标题、榜单分节、加载/错误项也算进去,触发点会漂)。
+ LaunchedEffect(state) {
+ snapshotFlow { state.gridState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 }
+ .drop(1)
+ .collect { last ->
+ if (state.cards.isNotEmpty() && last >= state.cards.size - 2) state.loadMore()
+ }
+ }
+}
diff --git a/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageStateHolder.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageStateHolder.kt
new file mode 100644
index 00000000..fd1f50ef
--- /dev/null
+++ b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/pgc/BangumiPageStateHolder.kt
@@ -0,0 +1,98 @@
+package com.aritxonly.bdfun.presentation.pgc
+
+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.presentation.common.discover.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/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 eeb752b6..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,57 +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 {
- // 和首页顶栏对齐:底栏搜索时同样缩到 0.9 并淡出。
- // 之前这里少了这两句,两个页面的过渡观感不一致。
- 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 = 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 8a0cde64..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,93 +1,59 @@
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 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 {
- homeChrome.materialProgress
- },
- ) {
- ProgressiveTopBarMaterial(
- underlayLiftPx = underlayLiftPx,
- underlayBlurPx = underlayBlurPx,
- modifier = Modifier.align(Alignment.TopCenter)
- .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)
- }
- }
- }
+ // 推荐 / 直播 / 番剧 三段各自一份滚动进度,但顶栏和搜索过渡是同一份实现。
+ val activeChrome = when (ui.homeSection) {
+ 1 -> shell.chrome.live
+ 2 -> shell.chrome.bangumi
+ else -> shell.chrome.home
+ }
+ // 下拉刷新指示器:三段各一份状态,都画在 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
- // 使用须知改由独立的 TermsActivity 在启动时展示。
+ MainTopBarChrome(
+ shell = shell,
+ pageIndex = 0,
+ pageFloat = pageFloat,
+ surface = activeChrome,
+ ) {
+ MainHomeBarContent(settings, shell)
+ }
+ if (pageFloat < 0.99f) {
// 下拉刷新指示器:独立覆盖层,挂在 App 层,探出顶栏也不会被裁掉。
// 透明度直接由下拉位移决定:位移 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()
.padding(top = HomeTopBarHeight + 4.dp),
)
}
-
}
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/shell/MainTabPager.kt b/app/src/main/kotlin/com/aritxonly/bdfun/presentation/shell/MainTabPager.kt
index 61474043..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
@@ -50,10 +50,15 @@ internal fun MainTabPager(
onOpenVideo = { navigateToVideo(context, it) },
cardStyle = settings.homeCardStyle,
)
+ } else if (homeSection == 1) {
+ com.aritxonly.bdfun.presentation.live.LivePage(
+ state = AppGraph.livePageState,
+ loggedIn = session != null,
+ )
} else {
- // 直播 / 番剧:页面还没做,先留空。顶栏 tab 可以切换到这里,
- // 不显示任何占位文案,避免以后接入真实页面时还要删一遍。
- androidx.compose.foundation.layout.Box(Modifier.fillMaxSize())
+ com.aritxonly.bdfun.presentation.pgc.BangumiPage(
+ state = AppGraph.bangumiPageState,
+ )
}
1 -> FeedScreen(
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/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..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) {
@@ -22,8 +23,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 +33,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 +46,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/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/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/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
new file mode 100644
index 00000000..9c351d4c
--- /dev/null
+++ b/core/domain/src/main/kotlin/com/aritxonly/bdfun/core/domain/DiscoverRepository.kt
@@ -0,0 +1,28 @@
+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
+
+/**
+ * 首页「直播」段的读取接口。实现放在 infra:bili,界面只依赖这层。
+ */
+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>
+}
+
+/**
+ * 首页「番剧」段的读取接口:最近追番 + 推荐。
+ */
+interface PgcDiscoverRepository {
+ suspend fun following(page: Int = 1): Outcome>
+
+ suspend fun recommend(page: Int = 1): 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
new file mode 100644
index 00000000..99152e1f
--- /dev/null
+++ b/core/model/src/main/kotlin/com/aritxonly/bdfun/core/model/live/LiveCard.kt
@@ -0,0 +1,51 @@
+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人看过」原文(watched_show.text_large);为空时界面按 online 兜底。 */
+ val watchedText: String = "",
+)
+
+/** 直播首页一屏:关注直播 + 分区入口 + 推荐卡片(对应 PiliPlus 的 card_list)。 */
+data class LiveHome(
+ val following: List = emptyList(),
+ val areas: List = emptyList(),
+ val cards: List = emptyList(),
+ /** 接口的 has_more;1 表示还能继续翻页。 */
+ val hasMore: Boolean = false,
+)
+
+/**
+ * 「我的关注正在直播」的 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,
+ /** 请求用的 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/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/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/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/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 4cf5e79b..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
@@ -39,6 +41,14 @@ 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 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)
+ 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()
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/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
new file mode 100644
index 00000000..fe4ac82b
--- /dev/null
+++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/BiliDiscoverRepositories.kt
@@ -0,0 +1,28 @@
+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) }
+
+ override suspend fun followers(): com.aritxonly.bdfun.core.common.Outcome> =
+ biliOutcome { api.liveFollowers() }
+}
+
+/** 首页番剧段:最近追番 + 推荐。 */
+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/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
new file mode 100644
index 00000000..6402e363
--- /dev/null
+++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/LiveApi2.kt
@@ -0,0 +1,206 @@
+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
+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 的 `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 data = dataRequest(
+ "/xlive/app-interface/v2/index/feed",
+ 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 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")) {
+ // 分区入口的 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(emptyList(), areas, cards, hasMore = data.optInt("has_more", 0) == 1)
+ }.getOrDefault(LiveHome())
+ // 登录态的首页接口不一定下发 area_entrance_v3(实测 card_list 里只有 banner 和
+ // small_card_v1),这时按 PiliPlus 的做法补一份分区入口,否则第二行的胶囊永远是空的。
+ if (result.areas.isEmpty()) result.copy(areas = liveAreaList(0L)) else result
+}
+
+/**
+ * 分区直播流。
+ *
+ * 原来用 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 data = dataRequest(
+ "/room/v3/area/getRoomList",
+ mapOf(
+ "parent_area_id" to parentAreaId.toString(),
+ "area_id" to areaId.toString(),
+ "page" to page.toString(),
+ "page_size" to "20",
+ "sort_type" to "online",
+ ),
+ base = livePageBase,
+ referer = liveReferer,
+ )
+ data.optJSONArray("list").toLiveCards()
+ }.getOrDefault(emptyList())
+}
+
+/**
+ * 我的关注里正在直播的 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())
+}
+
+/** 一级分区兜底列表(卡片里没有 area_entrance_v3 时用)。 */
+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 {
+ // /room/v1/Area/getList 给的是「一级分区 id」,请求时当 parent_area_id 用。
+ LiveArea(id = id, name = name, parentAreaId = id, areaId = 0L)
+ }
+ }
+ }.getOrDefault(emptyList())
+}
+
+/** 与 PiliPlus 的 liveFeedIndex / liveSecondList 完全同一套公共参数。 */
+private fun BiliApiContext.liveAppParams(extra: Map): 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",
+ "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)
+ if (isLoggedIn) params["relation_page"] = "1"
+ signAppParams(params)
+ return params
+}
+
+/** 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 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 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 JSONObject.toLiveCard(): LiveCard? {
+ val roomId = optLong("roomid", 0L).takeIf { it > 0L }
+ ?: optLong("id", 0L).takeIf { it > 0L }
+ ?: return null
+ return LiveCard(
+ roomId = roomId,
+ title = optString("title").orEmpty().ifBlank { optString("uname").orEmpty() },
+ uname = optString("uname").orEmpty(),
+ 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/PgcApi.kt b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcApi.kt
new file mode 100644
index 00000000..665ef57a
--- /dev/null
+++ b/infra/bili/src/main/kotlin/com/aritxonly/bdfun/infra/bili/PgcApi.kt
@@ -0,0 +1,93 @@
+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) {
+ // 这个接口必须带自己的 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",
+ ),
+ 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()
+ },
+ // order 已经是「1770.2万追番」这种成品文案,别再拼一次后缀。
+ followText = optString("order").orEmpty().let { raw ->
+ when {
+ raw.isBlank() -> ""
+ raw.contains("追番") -> raw
+ else -> "${raw}追番"
+ }
+ },
+ vip = badge.contains("会员"),
+ )
+}
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"
+ }
}