Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)` + 按卡片数比较。

Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
android:windowSoftInputMode="adjustNothing"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|smallestScreenSize|density" />
<activity android:name=".RelationActivity" android:exported="false" />
<activity android:name=".BangumiRankBoardActivity" android:exported="false" />
<activity
android:name=".LiveRoomActivity"
android:exported="false"
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/kotlin/com/aritxonly/bdfun/AppGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ object AppGraph {
get() = checkNotNull(container?.sentHistory) { "AppGraph 尚未使用 Context 初始化" }

val dynamicFeedState get() = checkNotNull(container) { "AppGraph 尚未初始化" }.dynamicFeedState
internal val bangumiRankState get() = checkNotNull(container) { "AppGraph 尚未初始化" }.bangumiRankState
internal fun bangumiRankBoardState(rankId: Long, oid: String, title: String) =
checkNotNull(container) { "AppGraph 尚未初始化" }.bangumiRankBoardState(rankId, oid, title)
internal suspend fun bangumiFirstEpisode(seasonId: Long) =
checkNotNull(container) { "AppGraph 尚未初始化" }.bangumiFirstEpisode(seasonId)
internal fun onboardingStateHolder(scope: kotlinx.coroutines.CoroutineScope, step: Int) =
checkNotNull(container) { "AppGraph 尚未初始化" }.onboardingStateHolder(scope, step)
internal fun inlineVideoStateHolder(bvid: String) =
Expand Down Expand Up @@ -70,6 +75,8 @@ object AppGraph {
it.videoReadCache.scope(), it.automaticVideoQuality, it.settings, it.videoPageDependencies)
}
val homeFeedState get() = checkNotNull(container) { "AppGraph 尚未初始化" }.homeFeedState
internal val livePageState get() = checkNotNull(container) { "AppGraph 尚未初始化" }.livePageState
internal val bangumiPageState get() = checkNotNull(container) { "AppGraph 尚未初始化" }.bangumiPageState

/** 登录态:MainActivity 与 LoginActivity 共享,登录成功直接写这里。 */
val session get() = checkNotNull(container) { "AppGraph 尚未初始化" }.session
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.aritxonly.bdfun

import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import com.aritxonly.bdfun.core.model.pgc.BangumiRankTab
import com.aritxonly.bdfun.presentation.pgc.BangumiRankBoardScreen
import com.aritxonly.bdfun.presentation.pgc.rememberBangumiOpener
import com.aritxonly.bdfun.ui.ActivityPageOptions
import com.aritxonly.bdfun.ui.TeakActivityScaffold

/**
* 单榜全量页(「查看更多」的目标)。对齐官方:排行榜是独立页面,参数是 rank_id + oid。
* 入参走 Intent 显式传,不写 AppGraph.pendingXxx。
*/
class BangumiRankBoardActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
AppGraph.ensure(applicationContext)
val rankId = intent.getLongExtra(EXTRA_RANK_ID, 0L)
val oid = intent.getStringExtra(EXTRA_OID).orEmpty()
val title = intent.getStringExtra(EXTRA_TITLE).orEmpty().ifBlank { "排行榜" }
if (rankId <= 0L || oid.isBlank()) {
finish()
return
}
val settings = AppGraph.settings
val holder = AppGraph.bangumiRankBoardState(rankId, oid, title)
setContent {
TeakActivityScaffold(
settings = settings,
onBack = { finish() },
options = ActivityPageOptions(title = title),
) {
BangumiRankBoardScreen(holder, onOpenSeason = rememberBangumiOpener(this))
}
}
}

companion object {
private const val EXTRA_RANK_ID = "rank_id"
private const val EXTRA_OID = "oid"
private const val EXTRA_TITLE = "title"

fun open(context: Context, tab: BangumiRankTab, title: String = tab.title) {
context.startActivity(
Intent(context, BangumiRankBoardActivity::class.java)
.putExtra(EXTRA_RANK_ID, tab.rankId)
.putExtra(EXTRA_OID, tab.oid)
.putExtra(EXTRA_TITLE, title),
)
}
}
}
34 changes: 33 additions & 1 deletion app/src/main/kotlin/com/aritxonly/bdfun/di/AppContainer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ internal class AppContainer(
com.aritxonly.bdfun.infra.bili.live.LiveDanmakuClient(scope, webSocketFactory = api.webSocketClient))
fun createLivePlayback(context: Context): com.aritxonly.bdfun.feature.live.presentation.LivePlaybackRuntime =
com.aritxonly.bdfun.playback.AndroidLivePlaybackRuntime(
com.aritxonly.bdfun.infra.playback.Media3LivePlayback(context.applicationContext, com.aritxonly.bdfun.R.layout.teak_player_view))
com.aritxonly.bdfun.infra.playback.Media3LivePlayback(
context.applicationContext,
com.aritxonly.bdfun.R.layout.teak_player_view,
// 直播 CDN 要 Cookie/Referer,否则有地址也黑屏。
api.cookieHeader(),
))
fun liveRoomState(id: Long, scope: CoroutineScope, connection: LiveDanmakuConnection, playback: LivePlayback) =
com.aritxonly.bdfun.feature.live.application.LiveRoomStateHolder(id, liveFallbackRoom(),
liveReads, connection, playback, scope,
Expand Down Expand Up @@ -139,6 +144,33 @@ internal class AppContainer(
HomeSlot(job, view)
}, { it.view.close(); it.job.cancel() })
val homeFeedState: HomeFeedState get() = homeSlot.get().view
// 首页「直播」「番剧」两段:与推荐页同一套壳,状态在各自 feature 下,接口走 infra 仓储。
val livePageState by lazy {
com.aritxonly.bdfun.presentation.live.LivePageStateHolder(
com.aritxonly.bdfun.infra.bili.BiliLiveDiscoverRepository(api))
}
val bangumiPageState by lazy {
com.aritxonly.bdfun.presentation.pgc.BangumiPageStateHolder(
com.aritxonly.bdfun.infra.bili.BiliPgcDiscoverRepository(api))
}
val bangumiRankState by lazy {
com.aritxonly.bdfun.feature.pgc.application.BangumiRankStateHolder(
com.aritxonly.bdfun.infra.bili.BiliPgcRankRepository(api))
}
/** 单榜全量页用:每次进页面建一个,退出即丢,不占进程缓存。 */
fun bangumiRankBoardState(rankId: Long, oid: String, title: String) =
com.aritxonly.bdfun.feature.pgc.application.BangumiRankBoardStateHolder(
com.aritxonly.bdfun.infra.bili.BiliPgcRankRepository(api),
com.aritxonly.bdfun.core.model.pgc.BangumiRankTab(rankId, title, oid))

/** 番剧播放走视频详情页:这里只负责把 season 解析成首集的 bvid。 */
suspend fun bangumiFirstEpisode(seasonId: Long): com.aritxonly.bdfun.core.model.pgc.BangumiEpisode? =
when (val result = com.aritxonly.bdfun.infra.bili.BiliPgcPlaybackRepository(api).season(seasonId)) {
is com.aritxonly.bdfun.core.common.Outcome.Success ->
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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<BangumiRankEntry> = 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<BangumiRankBoardUiState> = 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) }
}
}
}
Loading
Loading