diff --git a/app/src/main/java/com/github/tvbox/osc/player/controller/ComposeVideoController.kt b/app/src/main/java/com/github/tvbox/osc/player/controller/ComposeVideoController.kt
index 3dabbd2..09a3cef 100644
--- a/app/src/main/java/com/github/tvbox/osc/player/controller/ComposeVideoController.kt
+++ b/app/src/main/java/com/github/tvbox/osc/player/controller/ComposeVideoController.kt
@@ -55,19 +55,6 @@ import java.util.HashMap
import java.util.Locale
import kotlin.math.abs
-/**
- * 点播控制层 Compose 实现(Compose 化改造 §3.3 方案 C1,阶段 8 起为唯一控制层实现)。
- *
- * 结构:
- * - 继承 [BaseVideoController],`getLayoutId() = 0`(不 inflate XML);
- * - UI 由 [PlayerOverlay](Compose)渲染,颜色/字号跟随 MaterialTheme,顶部/底栏补 scrim;
- * - 原生字幕视图(§5.4)作为控制器直接子 View 保留,位于 Compose 层之下;
- * - 事件经控制器钩子(onPlayStateChanged/setProgress/onLockStateChanged/onVisibilityChanged)
- * 汇入 [PlayerUiState](替代旧 Handler msg 100/1000-1004 + myHandle 两套异步);
- * - 手势照抄 BaseController(§4.4 方案 A:GestureDetector 保留)+ VodController 扩展
- * (竖屏上下滑切集、长按 3.0x、单击显隐底栏、锁屏触摸守卫);
- * - 对外契约通过 [PlayerControlApi] 对 PlayContainer / DanmuLoadController 等价(§5.1/§5.2)。
- */
@Suppress("MemberVisibilityCanBePrivate")
class ComposeVideoController @JvmOverloads constructor(
context: Context,
@@ -77,12 +64,7 @@ class ComposeVideoController @JvmOverloads constructor(
GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener, View.OnTouchListener {
companion object {
- /**
- * 横滑进度灵敏度:全屏宽 = 240000ms(即 4 分钟)。
- * 原为 120000ms(照抄 GestureVideoController/BaseController),2026-09-13 用户要求
- * "调钝一点不要太灵敏" → 翻倍:同样时间跨度需要滑动两倍距离(约 1dp ≈ 0.58s)。
- * 手感仍嫌灵敏就继续调大此值,嫌迟钝就调回 120000f。
- */
+
private const val SLIDE_POSITION_FULL_WIDTH_MS = 240000f
/** 锁屏图标 3s 后隐藏 */
private const val LOCK_HIDE_DELAY_MS = 3000L
@@ -92,9 +74,6 @@ class ComposeVideoController @JvmOverloads constructor(
private const val SEEK_MAX = 1000
}
- // ============================================================
- // 状态与桥接(initView 内赋值:super 构造期间属性初始化器尚未执行)
- // ============================================================
private lateinit var state: PlayerUiState
@@ -398,12 +377,7 @@ class ComposeVideoController @JvmOverloads constructor(
!PlayerUtils.isEdge(context, event)
}
- /**
- * 是否允许"上下滑调亮度/音量"(2026-09-13「禁用手势控制」设置项)。
- *
- *
与 [canHandleGesture] 分开是本设置项的硬要求:第一版把设置并进 `canHandleGesture`,
- * 会连带把别的滑动手势一起禁掉。
- */
+
private fun canChangeBrightnessVolume(event: MotionEvent): Boolean {
return canHandleGesture(event) && !GestureHelper.isControlDisabled()
}
@@ -429,14 +403,13 @@ class ComposeVideoController @JvmOverloads constructor(
distanceY: Float,
): Boolean {
if (e1 == null) return true
- if (previewMode) return true
if (!canHandleGesture(e1)) return true
val deltaX = e1.x - e2.x
val deltaY = e1.y - e2.y
if (firstTouch) {
changePosition = abs(distanceX) >= abs(distanceY)
if (!changePosition) {
- // 禁用手势控制:竖向滑动既不进度也不亮度/音量 —— 静默忽略,不给出任何反馈
+ if (previewMode) return true
if (!canChangeBrightnessVolume(e1)) return true
val halfScreen = PlayerUtils.getScreenWidth(context, true) / 2
if (e2.x > halfScreen) changeVolume = true else changeBrightness = true
@@ -484,9 +457,6 @@ class ComposeVideoController @JvmOverloads constructor(
@SuppressLint("ClickableViewAccessibility")
override fun onTouch(v: View, event: MotionEvent): Boolean {
if (previewMode) {
- // 预览态(竖屏详情页):详情页透明点击层已移除,触摸直接落到控制器;
- // 放行单击显隐(onSingleTapConfirmed → toggleControls)与双击暂停/播放(onDoubleTap);
- // 滑动/长按等其余手势在预览态不响应(对齐旧版预览态行为)
return gestureDetector?.onTouchEvent(event) ?: false
}
if (isLocked()) {
diff --git a/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerBottomBar.kt b/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerBottomBar.kt
index 7723bca..7c37db1 100644
--- a/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerBottomBar.kt
+++ b/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerBottomBar.kt
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
@@ -82,12 +83,15 @@ fun PlayerBottomBar(
)
.padding(start = edge, end = edge, top = 10.dp, bottom = bottomPad)
) {
- // —— 时间胶囊(进度条左上角);预览态不显示(预览态时间仍在进度条两侧) ——
- if (!state.previewMode) {
- PlayerTimePill(
- state = state,
- modifier = Modifier.padding(bottom = 4.dp),
- )
+ Box(
+ Modifier
+ .fillMaxWidth()
+ .padding(bottom = 4.dp)
+ ) {
+ if (!state.previewMode) {
+ PlayerTimePill(state = state, modifier = Modifier.align(Alignment.CenterStart))
+ VideoSizePill(state = state, modifier = Modifier.align(Alignment.CenterEnd))
+ }
}
// —— 进度行(预览态 = 播放/暂停钮 + 时间 - 进度条 - 总时长;全屏态 = 进度条整行) ——
@@ -253,11 +257,8 @@ private fun PlayerPillDivider(iconBox: Dp) {
)
}
-/**
- * 进度条左上角的时间胶囊(`当前 / 总时长`):仅全屏态显示,与动作胶囊同色同圆角。
- */
@Composable
-private fun PlayerTimePill(state: PlayerUiState, modifier: Modifier = Modifier) {
+private fun PlayerInfoPill(modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit) {
Row(
modifier
.background(Color.Black.copy(alpha = OVERLAY_PILL_ALPHA), RoundedCornerShape(50))
@@ -266,8 +267,26 @@ private fun PlayerTimePill(state: PlayerUiState, modifier: Modifier = Modifier)
vertical = playerDim(R.dimen.vs_5),
),
verticalAlignment = Alignment.CenterVertically,
- ) {
- TimeRangeText(state)
+ content = content,
+ )
+}
+
+@Composable
+private fun PlayerTimePill(state: PlayerUiState, modifier: Modifier = Modifier) {
+ PlayerInfoPill(modifier) { TimeRangeText(state) }
+}
+
+@Composable
+private fun VideoSizePill(state: PlayerUiState, modifier: Modifier = Modifier) {
+ if (state.videoSize.isBlank()) return
+ PlayerInfoPill(modifier) {
+ Text(
+ text = state.videoSize,
+ color = Color.White,
+ fontSize = playerTextSize(R.dimen.ts_20),
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ )
}
}
diff --git a/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerLayers.kt b/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerLayers.kt
index ee8a3d1..1467666 100644
--- a/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerLayers.kt
+++ b/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerLayers.kt
@@ -18,7 +18,6 @@ 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ContainedLoadingIndicator
@@ -125,31 +124,15 @@ fun PlayerTipLayer(state: PlayerUiState) {
}
}
-/**
- * 暂停浮层:仅中央播放键(60dp 半透明圆底,点按即续播,免二次点击)。
- * 退后台触发的暂停不显示(pauseOverlayVisible 排除 lifecyclePaused)——避免被系统任务快照拍出"已暂停"假象。
- * 遮罩在屏时同样不显示:那时暂停的是上一次会话的残留内核,键压在遮罩上会误导(点它启停的不是即将播放的内容)。
- */
@Composable
fun PlayerPauseLayer(state: PlayerUiState, actions: PlayerActions) {
if (!state.pauseOverlayVisible || state.tipVisible) return
- Box(Modifier.fillMaxSize()) {
- Box(
- Modifier
- .align(Alignment.Center)
- .size(60.dp)
- .background(Color.Black.copy(alpha = 0.35f), CircleShape)
- .pointerInput(Unit) {
- detectTapGestures(onTap = { actions.onPlayPauseClicked() })
- },
- contentAlignment = Alignment.Center,
- ) {
- Image(
- painter = painterResource(R.drawable.player_ic_play),
- contentDescription = stringResource(R.string.common_play),
- modifier = Modifier.size(60.dp * 0.55f),
- )
- }
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
+ CenterControlIcon(
+ icon = painterResource(R.drawable.player_ic_play),
+ label = stringResource(R.string.common_play),
+ onClick = actions::onPlayPauseClicked,
+ )
}
}
diff --git a/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerOverlay.kt b/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerOverlay.kt
index a4d9c83..c876594 100644
--- a/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerOverlay.kt
+++ b/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerOverlay.kt
@@ -207,12 +207,6 @@ internal fun playerTextSize(@DimenRes id: Int): TextUnit {
internal fun playerEdgePadding(): Dp =
if (LocalConfiguration.current.screenWidthDp >= 600) 48.dp else 16.dp
-/**
- * 中央控制组(图二样式):显示底栏时屏幕中央出现三个半透明圆形按钮:
- * 左=上一集、中=播放/暂停(图标随播放态切换)、右=下一集。
- * 触摸点按;锁定时不显示。固定尺寸(等比例缩放已回退)。
- * 预览态(竖屏详情页)同样显示;加载/解析期隐藏。
- */
@Composable
private fun PlayerCenterControls(state: PlayerUiState, actions: PlayerActions, modifier: Modifier = Modifier) {
if (!state.centerControlsVisible) return
@@ -222,40 +216,39 @@ private fun PlayerCenterControls(state: PlayerUiState, actions: PlayerActions, m
horizontalArrangement = Arrangement.spacedBy(28.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- CenterControlCircle(
- diameter = 48.dp,
+ CenterControlIcon(
icon = painterResource(R.drawable.player_ic_prev),
label = stringResource(R.string.player_prev_episode),
onClick = actions::onPreClicked,
+ box = 44.dp,
shape = ScallopShape(),
)
- CenterControlCircle(
- diameter = 60.dp,
+ CenterControlIcon(
icon = painterResource(if (playing) R.drawable.player_ic_pause else R.drawable.player_ic_play),
label = stringResource(if (playing) R.string.common_pause else R.string.common_play),
onClick = actions::onPlayPauseClicked,
)
- CenterControlCircle(
- diameter = 48.dp,
+ CenterControlIcon(
icon = painterResource(R.drawable.player_ic_next),
label = stringResource(R.string.player_next_episode),
onClick = actions::onNextClicked,
+ box = 44.dp,
shape = ScallopShape(),
)
}
}
@Composable
-private fun CenterControlCircle(
- diameter: Dp,
+internal fun CenterControlIcon(
icon: Painter,
label: String,
onClick: () -> Unit,
+ box: Dp = 48.dp,
shape: Shape = CircleShape,
) {
Box(
Modifier
- .size(diameter)
+ .size(box)
.background(Color.Black.copy(alpha = 0.35f), shape)
.pointerInput(onClick) {
detectTapGestures(onTap = { onClick() })
@@ -265,7 +258,7 @@ private fun CenterControlCircle(
Image(
painter = icon,
contentDescription = label,
- modifier = Modifier.size(diameter * 0.55f),
+ modifier = Modifier.size(box * 0.55f),
)
}
}
diff --git a/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerTopBar.kt b/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerTopBar.kt
index 2b40348..767c8b4 100644
--- a/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerTopBar.kt
+++ b/app/src/main/java/com/github/tvbox/osc/player/ui/PlayerTopBar.kt
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.displayCutout
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
@@ -37,6 +38,8 @@ import com.github.tvbox.osc.R
import com.github.tvbox.osc.player.state.PlayerActions
import com.github.tvbox.osc.player.state.PlayerUiState
+private val TopBarLineHeight = 36.dp
+
@Composable
fun PlayerTopBar(state: PlayerUiState, actions: PlayerActions) {
val anyVisible = state.topLeftVisible || state.topRightVisible
@@ -84,13 +87,12 @@ fun PlayerTopBar(state: PlayerUiState, actions: PlayerActions) {
bottom = playerDim(R.dimen.vs_5),
)
) {
- // —— 左块:返回箭头 + 片名 + 分辨率 ——
+ // —— 左块:返回箭头 + 片名 ——
if (state.topLeftVisible) {
Row(Modifier.weight(3f), verticalAlignment = Alignment.CenterVertically) {
- // 返回箭头:点击等价于遥控器返回键(onBackClicked)
Box(
Modifier
- .size(36.dp)
+ .size(TopBarLineHeight)
.pointerInput(Unit) {
detectTapGestures(onTap = { actions.onBackClicked() })
},
@@ -102,32 +104,18 @@ fun PlayerTopBar(state: PlayerUiState, actions: PlayerActions) {
modifier = Modifier.size(24.dp),
)
}
- Column {
- Text(
- text = state.title,
- color = Color.White,
- fontSize = playerTextSize(R.dimen.ts_22),
- fontWeight = FontWeight.Medium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.padding(
- start = playerDim(R.dimen.vs_10),
- top = playerDim(R.dimen.vs_5),
- )
+ Text(
+ text = state.title,
+ color = Color.White,
+ fontSize = playerTextSize(R.dimen.ts_24),
+ fontWeight = FontWeight.Bold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.padding(
+ start = playerDim(R.dimen.vs_10),
+ top = playerDim(R.dimen.vs_5),
)
- Text(
- text = state.videoSize,
- color = Color.White,
- fontSize = playerTextSize(R.dimen.ts_20),
- fontWeight = FontWeight.Medium,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis,
- modifier = Modifier.padding(
- start = playerDim(R.dimen.vs_10),
- top = playerDim(R.dimen.vs_5),
- )
- )
- }
+ )
}
} else {
Spacer(Modifier.weight(3f))
@@ -135,7 +123,10 @@ fun PlayerTopBar(state: PlayerUiState, actions: PlayerActions) {
// —— 右块:网速/进度时间/系统时间 ——
if (state.topRightVisible) {
Column(Modifier.weight(1f), horizontalAlignment = Alignment.End) {
- Row(verticalAlignment = Alignment.CenterVertically) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.height(TopBarLineHeight),
+ ) {
if (state.netSpeedSideVisible) {
TopBarText(state.netSpeedTopRight)
}
diff --git a/app/src/main/java/com/github/tvbox/osc/ui/components/ScallopShape.kt b/app/src/main/java/com/github/tvbox/osc/ui/components/ScallopShape.kt
index 6f18a81..073d15a 100644
--- a/app/src/main/java/com/github/tvbox/osc/ui/components/ScallopShape.kt
+++ b/app/src/main/java/com/github/tvbox/osc/ui/components/ScallopShape.kt
@@ -10,10 +10,6 @@ import kotlin.math.cos
import kotlin.math.min
import kotlin.math.sin
-/**
- * 扇贝花边形(13 瓣,音乐播放器跳播键与播放器上一/下一集共用):
- * 半径按 `r = R * (1 + depth * cos(lobes * angle))` 波动,外接尺寸与同尺寸圆一致。
- */
class ScallopShape(
private val lobes: Int = 13,
private val depth: Float = 0.07f,
diff --git a/app/src/main/java/com/github/tvbox/osc/ui/components/SearchField.kt b/app/src/main/java/com/github/tvbox/osc/ui/components/SearchField.kt
index 4e10a17..4854ffb 100644
--- a/app/src/main/java/com/github/tvbox/osc/ui/components/SearchField.kt
+++ b/app/src/main/java/com/github/tvbox/osc/ui/components/SearchField.kt
@@ -4,7 +4,9 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.isImeVisible
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -19,10 +21,17 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
+import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.SolidColor
+import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
@@ -41,6 +50,16 @@ fun SearchField(
hint: String = stringResource(R.string.search_field_hint),
trailing: (@Composable () -> Unit)? = null,
) {
+ val focusManager = LocalFocusManager.current
+ val imeVisible = WindowInsets.isImeVisible
+ var fieldFocused by remember { mutableStateOf(false) }
+ var imeWasVisible by remember { mutableStateOf(false) }
+ LaunchedEffect(imeVisible) {
+ if (imeWasVisible && !imeVisible && fieldFocused) {
+ focusManager.clearFocus()
+ }
+ imeWasVisible = imeVisible
+ }
val glass = if (LocalTopBarGlassBackdrop.current != null) {
Modifier.glassTopBarSurface(ContinuousCapsule, MaterialTheme.colorScheme.cardContainer, pressEffect = false)
} else {
@@ -70,7 +89,9 @@ fun SearchField(
BasicTextField(
value = query,
onValueChange = onQueryChange,
- modifier = Modifier.weight(1f),
+ modifier = Modifier
+ .weight(1f)
+ .onFocusChanged { fieldFocused = it.isFocused },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
diff --git a/app/src/main/java/com/github/tvbox/osc/ui/components/Skeleton.kt b/app/src/main/java/com/github/tvbox/osc/ui/components/Skeleton.kt
index 878eb9c..007d962 100644
--- a/app/src/main/java/com/github/tvbox/osc/ui/components/Skeleton.kt
+++ b/app/src/main/java/com/github/tvbox/osc/ui/components/Skeleton.kt
@@ -20,6 +20,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import com.github.tvbox.osc.ui.theme.cardContainer
+import kotlin.math.hypot
fun Modifier.shimmer(
base: Color? = null,
@@ -37,17 +38,23 @@ fun Modifier.shimmer(
label = "shimmerProgress",
)
drawWithCache {
- val bandWidth = size.width
- val startX = -bandWidth + 2f * bandWidth * progress
+ val startX = shimmerStartX(size.width, size.height, progress)
val brush = Brush.linearGradient(
colors = listOf(baseColor, shineColor, baseColor),
start = Offset(startX, 0f),
- end = Offset(startX + bandWidth, size.height),
+ end = Offset(startX + size.width, size.height),
)
onDrawBehind { drawRect(brush) }
}
}
+internal fun shimmerStartX(width: Float, height: Float, progress: Float): Float {
+ val diagonal = hypot(width, height)
+ if (width <= 0f || diagonal <= 0f) return 0f
+ val travel = diagonal * diagonal / width
+ return -travel + 2f * travel * progress
+}
+
@Composable
fun SkeletonBox(
modifier: Modifier = Modifier,
diff --git a/app/src/main/res/drawable/player_ic_back.xml b/app/src/main/res/drawable/player_ic_back.xml
index bae1279..b21a3a8 100644
--- a/app/src/main/res/drawable/player_ic_back.xml
+++ b/app/src/main/res/drawable/player_ic_back.xml
@@ -1,10 +1,13 @@
+
+
-
-
+ android:viewportWidth="960"
+ android:viewportHeight="960">
+
+
+
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 4dec0c7..5fe2427 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -4,6 +4,7 @@
19mm
20mm
22mm
+ 24mm
26mm
30mm
diff --git a/app/src/test/java/com/github/tvbox/osc/ui/components/ShimmerBandTest.kt b/app/src/test/java/com/github/tvbox/osc/ui/components/ShimmerBandTest.kt
new file mode 100644
index 0000000..8a38472
--- /dev/null
+++ b/app/src/test/java/com/github/tvbox/osc/ui/components/ShimmerBandTest.kt
@@ -0,0 +1,64 @@
+package com.github.tvbox.osc.ui.components
+
+import kotlin.math.hypot
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class ShimmerBandTest {
+
+ private fun spanU(width: Float, height: Float, progress: Float): ClosedFloatingPointRange {
+ val diagonal = hypot(width, height)
+ val startU = shimmerStartX(width, height, progress) * width / diagonal
+ return startU..(startU + diagonal)
+ }
+
+ private fun assertClearsBoxAtBothEnds(width: Float, height: Float) {
+ val diagonal = hypot(width, height)
+ val tolerance = 0.5f
+ val entering = spanU(width, height, 0f)
+ assertTrue(
+ "progress=0 高光应整体在框外(左上), 实际末端 ${entering.endInclusive} > 0",
+ entering.endInclusive <= tolerance,
+ )
+ val leaving = spanU(width, height, 1f)
+ assertTrue(
+ "progress=1 高光应整体在框外(右下), 实际首端 ${leaving.start} < 对角线 $diagonal",
+ leaving.start >= diagonal - tolerance,
+ )
+ }
+
+ @Test
+ fun posterAspectClearsBoxAtBothEnds() = assertClearsBoxAtBothEnds(200f, 300f)
+
+ @Test
+ fun everyAspectClearsBoxAtBothEnds() {
+ listOf(100f to 100f, 200f to 300f, 300f to 400f, 300f to 200f, 100f to 560f)
+ .forEach { (width, height) -> assertClearsBoxAtBothEnds(width, height) }
+ }
+
+ @Test
+ fun midCyclePatternCoversBoxExactly() {
+ val width = 200f
+ val height = 300f
+ val span = spanU(width, height, 0.5f)
+ assertEquals(0f, span.start, 0.5f)
+ assertEquals(hypot(width, height), span.endInclusive, 0.5f)
+ }
+
+ @Test
+ fun sweepAdvancesMonotonically() {
+ val width = 200f
+ val height = 300f
+ val diagonal = hypot(width, height)
+ val positions = listOf(0f, 0.25f, 0.5f, 0.75f, 1f)
+ .map { shimmerStartX(width, height, it) * width / diagonal }
+ assertTrue(positions.zipWithNext().all { (previous, next) -> next > previous })
+ }
+
+ @Test
+ fun degenerateSizeStaysFinite() {
+ assertTrue(shimmerStartX(0f, 0f, 0.5f).isFinite())
+ assertTrue(shimmerStartX(0f, 300f, 0.5f).isFinite())
+ }
+}
diff --git a/skill/avbox-mobile-ui-spec.md b/skill/avbox-mobile-ui-spec.md
index 20647ac..843bc17 100644
--- a/skill/avbox-mobile-ui-spec.md
+++ b/skill/avbox-mobile-ui-spec.md
@@ -120,7 +120,7 @@
- **chips 分区行**(「清晰度」/「线路」)= `surfaceBright` 圆角卡片(圆角 16dp、距屏 6dp、卡内 vertical 12dp、标题 start 16 / bottom 8、chips 行 contentPadding 16),**宽度必须与「选集」卡对齐**(`ChipRow` 的 `Column` 与 `LazyRow` 各加 `fillMaxWidth()`,否则仅两条线路时卡片明显变窄)。
- **选集网格**(`全部` sheet):自适应多列网格 + 稳定定位当前集;格子 label 13sp + `contentPadding` 水平 6dp + `TextOverflow.Ellipsis`;**不要用 `basicMarquee`** —— 仅差几 dp 的溢出会表现成"文字乱滚/错位"。
- **换源行 / 播放容器 / 帧率的硬约束见 §6.1**(点击即停 + 失败回滚原源 + 进度继承 + Exo 帧率匹配必须保持关闭)。
-- 播放手势:已有手势保留;**新增长按 2 倍速**(移动端惯例)、亮度/音量/进度手势指示器、全屏拖动进度预览。**横滑进度灵敏度(2026-09-13 用户要求调钝)**:`ComposeVideoController.SLIDE_POSITION_FULL_WIDTH_MS = 240000f` —— 滑满一个屏宽 = 4 分钟(约 1dp ≈ 0.58s),原值 120000f(滑满 = 2 分钟,照抄 dkplayer BaseController)已翻倍。手感仍嫌灵敏就继续调大,嫌迟钝调回 120000f。判定方向用 `abs(distanceX) >= abs(distanceY)`;**四边各 40dp 内按下不触发任何滑动手势**(`PlayerUtils.isEdge`)。
+- 播放手势:已有手势保留;**新增长按 2 倍速**(移动端惯例)、亮度/音量/进度手势指示器、全屏拖动进度预览。**横滑进度灵敏度(2026-09-13 用户要求调钝)**:`ComposeVideoController.SLIDE_POSITION_FULL_WIDTH_MS = 240000f` —— 滑满一个屏宽 = 4 分钟(约 1dp ≈ 0.58s),原值 120000f(滑满 = 2 分钟,照抄 dkplayer BaseController)已翻倍。手感仍嫌灵敏就继续调大,嫌迟钝调回 120000f。判定方向用 `abs(distanceX) >= abs(distanceY)`;**四边各 40dp 内按下不触发任何滑动手势**(`PlayerUtils.isEdge`)。**预览态(竖屏详情页)也支持横滑 seek(2026-09-29 用户「竖屏影视详情页面能否支持手势控制调整进度,就是手势滑动屏幕调整进度」)**:`onScroll` 开头原是 `if (previewMode) return true` 把预览态滑动一次性吃掉(`canSlide` 本就是 true —— `PlayContainer.initView` 已 `setEnableInNormal(true)`),现改为只拦竖向 —— 横滑与全屏同链路(`slideToChangePosition` + 顶部 `PlayerSeekHint` 药丸),竖向在 `!changePosition` 分支里提前返回(**不调亮度/音量、长按倍速仍 `onLongPress` 提前返回**,维持旧版预览态行为)。⚠️ **别再往 `onScroll` 开头加 `previewMode` 全局早退**,否则预览态横滑进度又没了。
- **⚠️ 竖屏上下滑 = 调亮度/音量,不再是切集(2026-09-13 用户决策,取代旧 `VodController` 扩展)**。旧实现在 `onScroll` 开头插了一个"竖屏上下滑切集"分支(`isPortraitEpisodeSwipe`:`abs(Δy) > abs(Δx)` 即成立,与 80dp 阈值无关),并且**无条件 `return true`** —— 结果竖屏下所有上下滑都被它吃掉,亮度/音量手势**在竖屏永远走不到**,用户表现为"上下滑没反应/变成别的动作"。现已整套删除(分支 + `isPortraitEpisodeSwipe` + `PORTRAIT_EPISODE_SWIPE_DP`/`_TITLE_SHOW_MS` + `portraitEpisodeSwipeTriggered` + `episodeTitleRunnable` + `PlayerUiState.portraitEpisodeTitleTemp`),竖屏与横屏手势行为**完全一致**,与上游 fongmi 对齐(它也没有这个分支)。**不要凭"切集方便"再加回来** —— 加了必然重新吃掉亮度/音量。**seek / 亮度音量 / 长按倍速三处提示 = 同一套药丸(2026-09-28 用户定稿,取代 2026-09-13/19 的"浅底 M3 surface 药丸 + 三处位置不一")**:三处都复用 `PlayerLayers.HintPill` —— ①底色 = **半透明黑**(`Color.Black` × `OVERLAY_PILL_ALPHA`,该常量定义在 `PlayerBottomBar`、与底栏左下角那颗时间胶囊**共用同一个值** —— 2026-09-28 用户「改成和左下角进度展示胶囊一样的」,即 0.2;曾一度是 `PlayerLayers` 自己的 `HINT_PILL_SCRIM_ALPHA = 0.6`,已删,勿再各写一份);②4dp 投影、无描边、尺寸内容自适应;③内容一律**白色**(文字 `Color.White`、seek 图标白色 tint)。**不再用 `surfaceContainer` 50% 浅底 + `onSurface` 字**(2026-09-19 那次调透明度已作废):提示浮层压在视频画面上,浅底随亮画面糊掉、对比不稳,故**不随深浅主题反转**。⚠️ **0.2 是"与底栏胶囊一致"的口径,不是"与参考图一致"的口径**:用户给的参考截图实测 ≈50% 黑(白底 215 → 胶囊内 107),而底栏胶囊虽同为 0.2,却压在底栏那层 `Transparent→Black 50%` 渐变 scrim 上、实际合成 ≈27%。若后续真机反馈"提示太淡",改的是这一个共用常量(取 0.45~0.5 即回到参考图观感),**不要**再拆回两份。**位置统一到屏幕上部** = `HintPillLayer`(顶部居中 + 下移 `vs_60`,落在屏幕上方四分之一区域内)—— 此前只有 seek 在此、亮度音量与倍速在屏幕正中,同类提示位置不一;seek 的**快进/快退图标改为复用参数面板的「设为片尾 / 设为片头」矢量**(`player_ic_params_time_end` / `player_ic_params_time_start`,方向同 `▶|` / `|◀`),弃用 media3 的 `exo_icon_fastforward` / `exo_icon_rewind`;**图标盒与垂直内距(2026-09-28 同日第四轮,用户「胶囊的高度能否矮一点」)**:seek 图标盒由 `vs_40` 定到 **`vs_50`** —— 这两颗矢量图形只占画布约 46%,`vs_50` 盒下图形 ≈23mm、与 `ts_30` 的数字等高(参考图实测同为「图标与数字等高」:图标 25px / 数字 24px);`HintPill` 垂直内距 `vs_10` → **`vs_5`**;**胶囊高 80mm → 60mm**(实测:用户 2800×1260 设备上 156px → 预期 ≈118px,该机 mm 换算实测 ≈1.97px/mm)。⚠️ **不要再靠加大图标盒来"把图标显大"**:胶囊高度 = 图标盒 + 2×内距,加盒只会顶高胶囊 —— 80mm 那版正是 `vs_60` + `vs_10` 撑出来的,而图标本体当时也只有 27mm。**三支提示现统一为:图标盒 `vs_50`、垂直内距 `vs_5` ⇒ 胶囊高 60mm**(seek / 亮度音量都带图标盒,倍速由文字行高撑)。字号未动(seek / 亮度音量 `ts_30`,倍速 `ts_26` 加粗)。**seek 提示只显示目标时间(2026-09-28 同日第三轮,用户「进度展示不要再保留 18:23/23:41 这样了,改成图片中的这样」)**:`ComposeVideoController.updateSeekUiHint` 由 `stringForTime(seekTo) + " / " + stringForTime(duration)` 改为只留目标时间,`duration` 形参一并删除 —— 总时长在底栏时间胶囊里已有,提示里再带一份是冗余(参考图 = 图标 + 单个时间)。⚠️ **底栏时间胶囊(`PlayerTimePill` 的「当前 / 总时长」)未动**,用户引用的数字虽与它同形,但参考图带快进图标、且 18:23 正是图一里 seek 提示的目标时间,故判定指的是 seek 提示;真要改底栏那颗需另开一轮。旧 `shape_user_focus` 深灰底(#6C3D3D3D)+ 白描边 + 固定 200x100mm 尺寸早已废弃,勿再回。**亮度/音量提示的图标与文案(2026-09-28 同日第二轮,用户「图标用 .tubiao 里的图标代替字体,不要音量和亮度了」)**:图标取 `.tubiao/亮度.svg` / `.tubiao/音量.svg` 按 `player_ic_*` 范式转出的 `player_ic_brightness` / `player_ic_volume`(viewport 960 + `translateY=960` 抵消负 viewBox、白填充交由 tint),由新增字段 `PlayerUiState.slideHintBrightness` 二选一(控制器在两处滑动写入时置位);文本**不再出现「亮度 / 音量」两词**,只剩百分比 —— 新资源 `player_gesture_percent`(`%1$d%%`,格式串无译文,values / values-en / values-b+zh+Hant 三份同值、`values-zh-rHK` 回落基础层),原 `player_brightness_value` / `player_volume_value` 两键从三份 strings.xml 删除(留着会被 `i18n_check_keys.py` 报 UNUSED)。图标盒 **`vs_50`**(2026-09-28 同日第五轮,用户「亮度/音量那支的图标盒也改成 vs_50,然后把注释删了」)—— 与 seek 那支同盒,两支提示的图标外框因此等高;⚠️ **图形本身仍不同大**(亮度矢量占画布 ≈92%、音量 ≈75%,seek 的片头/片尾只占 ≈46%),所以"同盒"不等于"同视觉大小",别据此再去反推 seek 那支该缩到多小。**直播页的亮度/音量提示是另一套实现**(`LiveScreens` 的 `Surface` 圆角 10dp + 黑 60% + 居中,文案 `live_gesture_hint` = `%1$s %2$d%%` 仍带「亮度 / 音量」词),未纳入本次统一。
- DLNA 投屏保留:**入口两处、链路同一套** —— ①播放器底栏「投屏」(`PlayerActions.onCastClicked`);②**竖屏详情页标题行投屏图标**(2026-09-13 用户要求,图标取 `.tubiao/投屏.svg` → `drawable/ic_detail_cast.xml`,`IconButton` tint `onSurfaceVariant` 24dp,置于收藏图标左侧),点击走 `PlayContainer.showCast()` → 与 ① 完全同一条 `showCastDialog()` 链路(同一个 `CastSheet` Dialog 面板、同一套 DLNA/TVBox 扫描与投送、无可投地址时的 Toast 也一样)。**同一 sheet 内也扫描局域网 TVBox 设备**(`RemoteTVBox.searchAvalible`),选中即 `post("http:///action")` 推送;**扫描到 / 投屏成功时都要记住 host**(`RemoteTVBox.setAvalible` → KV `REMOTE_TVBOX`),因为 `PlayerHelper` 的 **13 号「RemoteTVBox 播放器」**(把 TVBox 当外部播放器用,`RemoteTVBox.run`)与它的可用性判定都依赖这个值 —— 2026-09-13 修复:Compose 迁移时漏掉了这次写入,导致该播放器恒不可用;同时 `PlayerHelper.invalidatePlayersExistInfo()` 必须跟着调用(该可用性表是**进程级缓存**,不重置则本次进程内不会重新计算)。
- **手动进入音乐播放页(2026-09-19)**:标题行最左那颗 = `ic_detail_music_player.xml`(取自 `.tubiao/进入音乐播放器.svg`),点击 `DetailActivity.openMusicPlayer()` → 详情未解析完 Toast「内容还没加载好」;会话未建则先 `playCurrent()` 建会话;再 `handOffToMusicPlayer()` 交接。**交接后是否 `finish()` 按内容类型分流**:`isAudioContent()`(= `looksLikeAudioUrl(webPlayUrl)` 或 `isConfirmedAudioOnly()`,与自动跳转同一判定)为真 → 纯音频,收掉本页;为假 → 影视,**本页留在栈里**,音乐页返回即回到竖屏详情页。⚠️ 保留本页这条路径有两个连带点:①返回时靠已有的 `PlayContainer.reattachIfOwnedByOther()` 重新挂载引擎(原用于"从直播页回来"),**必须同时把 `handedOver` 复位**(见 §6.1);②音乐页改的是 `session.vod`(预览副本)、本页 UI 读的是 `vm.vodInfo`,是两个对象 —— `onResume` 的 `syncEpisodeAfterMusicPage()` 要把 `playFlag/playIndex` 同步回来,否则选集高亮停在交接那一集、点播放还会跳回那一集(用 `pendingEpisodeSync` 标记只在交接后同步一次,避免"从历史进详情页"被上次残留会话覆盖)。返回后视频为**暂停态**(引擎「退页面即停」),需手动续播。
@@ -136,14 +136,14 @@
⚠️ **退后台保持播放仍只对纯音频生效**(视频退后台照旧自动暂停,由 `HostPause` 决定,dkplayer 的 autoPause 未启用)。所以影视的通知是"暂停态可看到并可从通知恢复播放",**不是后台播放视频**;若将来要后者,改的是 `hostPause` 的判据而不是这里的通知判据。
- **纯音频(音乐)强制 TextureView 渲染(2026-09-13 修,用户报三联症状)**:SurfaceView 画面在独立合成层(本渲染视图用 RGBA_8888 可透明格式),应用窗口在播放器矩形被"打洞";无视频帧内容全靠空 Surface 垫底呈黑 → 退后台任务快照里垫底消失(多任务卡片播放器区域**变白**)、回前台 Surface 重建前过渡动画**透视桌面**(TextureView 实测三症状全无)。双层修复:①起播前 `PlayContainer.looksLikeAudioUrl()`(mp3/m4a/aac/flac/wav/ogg/oga/opus/wma 后缀,去 query/fragment;**m3u8 永不命中**)→ `updateCfg` 后覆盖渲染工厂为 Texture;②`STATE_PLAYING` 确认纯音频(`isAudioOnlyPlayback()==TRUE`)且当前是 SurfaceView → `MyVideoView.switchRenderToTexture()`(`setRenderViewFactory(Texture)` + protected `addDisplay()` 热切换,音频不中断、零渲染开销)。⚠️ **`replay(false)` 不重建渲染视图**(fork 的 `keepRenderViewOnReset` 分支与 `startPrepare(true,true)` 都只 rebind;`addDisplay()` 只在 `start()`→`startPlay` 跑)—— reusePlayer(切线路/清晰度)路径改渲染工厂不生效,必须靠②在 STATE_PLAYING 后 `addDisplay()` 兜住;换集/换源下次 `updateCfg` 按用户设置恢复渲染类型,影视不受影响。旧 SurfaceView 摘除后异步 `surfaceDestroyed → setDisplay(null)` 落在无视频轨播放器上是 no-op(ExoMediaPlayer → `mInternalPlayer.setVideoSurface(null)`),不伤新 Texture 挂载。IJK `getTrackInfo` 已过滤内嵌封面(`isAttachedPicture`);EXO 侧 mp3/m4a 内嵌封面走 metadata 不产生视频轨 → 三态判定可靠。
- **全屏/退出全屏的旋转过渡(2026-09-13,A+B 方案,防"先变形再转屏")**:播放器区形态**禁止直接用目标态 `full`/`fullScreen` 驱动布局**,一律走形态判定 —— 点播 = `DetailActivity.isFullBox()` / `DetailScreen` 的 `fullBox`(`if (rotating) 当前是否横屏 else full`)+ `DetailViewModel.rotating`;直播 = `LivePlayActivity.isFullBox()` + `rotating`。置位规则:`setFullScreen/applyFullscreen` 里「目标方向 ≠ 当前方向」→ `rotating = true`,**`onConfigurationChanged` 清位**(点播侧"目标方向"由进全屏时的 `isPortraitVideo()` 定,见 §4.4 ⇒ 竖屏视频进全屏不置位);过渡期形态跟随**当前方向**(横屏=全屏样、竖屏=预览样),旋转落地那一帧才切到目标形态(与系统旋转同帧)。⚠️ **改播放页布局时必须用 `isFullBox()`/`fullBox`**,否则重现:横屏窗口里算竖屏 16:9 盒(高 = 宽×9/16 ≈ 1.25×屏高 → Compose 退化成按高定尺寸、视频缩小靠左上)与竖屏窗口里直接 `fillMaxSize`(黑屏几百 ms)。连带项:预览态覆盖层 `setPreviewMode` 与字幕 0.6 倍字号也改由形态驱动(`syncFullBoxSideEffects`),不在过渡期当帧跳。**方案 B(几何钳制,对齐 fongmi `changeHeight`)**:预览态播放区高度 = `短边 × 16:9` 再 `coerceAtLeast(150dp).coerceAtMost(max(150dp, 长边/2))`(与窗口方向无关,永远合法);全屏形态 = `fillMaxSize()`。兜底:若 ROM 不下发 `onConfigurationChanged`,`rotating` 卡 true 时形态退化为"当前方向的自然形态",不会卡死。
-- **预览态进度行播放/暂停钮 + 双击暂停(2026-09-13 定稿)**:竖屏详情页进度行 = `[播放/暂停钮][当前时间][进度条][总时长]`,按钮 = `PlayerBottomBar.PreviewPlayPauseButton`(触摸盒 40dp / 图形 22dp / 白色 90%,与右下角全屏入口 `DetailActivity` 完全同款;图标状态判定含 `BUFFERING/BUFFERED`,同中央控制组)。**三者(暂停钮/进度条/全屏钮)水平中心线统一**为「距播放区底 16dp + `vs_30`/2」—— 故预览态底栏底距 = `16dp + vs_30/2 - 40dp/2`(与全屏入口 `bottom` 偏移同一式子,**改一边必须同步另一边**,进度条中心线位置不变)。**预览态双击播放区 = 暂停/播放**(`ComposeVideoController.onDoubleTap` 不再对 previewMode 提前返回);⚠️ 副作用:单击显隐需等双击窗口超时(~300ms),属双击功能的固有代价;滑动/长按在预览态仍不响应。
+- **预览态进度行播放/暂停钮 + 双击暂停(2026-09-13 定稿)**:竖屏详情页进度行 = `[播放/暂停钮][当前时间][进度条][总时长]`,按钮 = `PlayerBottomBar.PreviewPlayPauseButton`(触摸盒 40dp / 图形 22dp / 白色 90%,与右下角全屏入口 `DetailActivity` 完全同款;图标状态判定含 `BUFFERING/BUFFERED`,同中央控制组)。**三者(暂停钮/进度条/全屏钮)水平中心线统一**为「距播放区底 16dp + `vs_30`/2」—— 故预览态底栏底距 = `16dp + vs_30/2 - 40dp/2`(与全屏入口 `bottom` 偏移同一式子,**改一边必须同步另一边**,进度条中心线位置不变)。**预览态双击播放区 = 暂停/播放**(`ComposeVideoController.onDoubleTap` 不再对 previewMode 提前返回);⚠️ 副作用:单击显隐需等双击窗口超时(~300ms),属双击功能的固有代价;**横滑进度自 2026-09-29 起在预览态放行**(见播放手势条),竖向亮度/音量与长按在预览态仍不响应。
- **播放器覆盖层左右边距(2026-09-13 定稿;2026-09-26 用户由 24dp 提至 48dp)**:= `playerEdgePadding()`(`player/ui/PlayerOverlay.kt`)= `screenWidthDp >= 600 ? 48dp : 16dp`(compact 16dp 用于竖屏详情页预览态;medium/expanded 档 48dp —— 比原 24dp 更内缩,横屏挖孔落在左/右边缘的 safeInset 覆盖得更足)。落点 = 顶栏 Row 左右 / 底栏 Column 左右 / 锁屏钮右(`PlayerTopBar`/`PlayerBottomBar`/`PlayerLayers`);顶栏顶 12dp;**底栏下 = 全屏态 6dp**(2026-09-26 由 16dp 下调,进度条随之下移;预览态仍走 `16dp + vs_30/2 - 40dp/2` 的原式子)。**底栏内所有控件(时间胶囊 / 进度条 / 动作行)同源共享这个边距且各自无额外水平偏移 ⇒ 三者左端天然对齐(铺满的动作行右端也与进度条对齐)**,改边距时不必单独补偿。⚠️ 与边缘手势带无关:dkplayer `PlayerUtils.isEdge()` 已忽略四边各 40dp 内的视频手势(48dp 边距落在该带内,但控件在 Compose 层、手势在 View 层,不冲突)。**触摸目标(锁屏钮 24dp、进度条 `vs_30`≈24dp、胶囊图标 `vs_70` 档)仍低于 M3 的 48dp,待决见 §7**。
- **横屏全屏「选集」入口 + 右侧滑出面板(2026-09-24;2026-09-26 入口位置改为左下胶囊第 2 格)**:底栏「选集」图标(文案复用 `detail_episodes` 作 contentDescription,动作 `PlayerActions.onEpisodeClicked`),**仅 `PlayerUiState.episodeBtnVisible` 为真时显示** —— 该标志由 `PlayContainer.updateEpisodeBtnState()` 在 `prepared()` 判定(当前线路剧集数 >1;单集时点开没有可选项,不显示)。链路 = `ComposeVideoController` → `VodControlListener.showEpisodes()` → `PlayContainer` → `PageHost.showEpisodeSheet()` → `DetailActivity` → 详情页既有 `EpisodeSheet`(`vm.episodeSheet`):面板内容 / 线路切换 / 倒序 / 定位当前集全部复用竖屏那套,播放层不新建面板状态。形态由「横屏全屏」决定(`AVBoxBottomSheet(slideFromEnd = fullBox && 窗口横屏)`):竖屏仍是贴底(**含大屏设备点全屏时系统忽略应用方向限制、窗口仍竖屏的情形**),**只有横屏全屏才改右侧滑出** —— 贴右、全高、左两角 28dp、宽度 = `min(640dp, 窗口宽 × 0.45)`、水平拖拽右滑关闭、无把手(标题顶到面板上缘 16dp);窄面板里选集网格列数上限由 4 压到 2,网格改 `weight(1f)` 撑满全高;**侧滑形态只列当前线路的剧集,不显示线路切换**(线路切换仍只在竖屏详情页 chips 行;竖屏贴底面板不变)。
- **全屏入口带「详情就绪」门禁(2026-09-25,用户报"预览态加载中点全屏变纯黑")**:`DetailScreen.kt` 右下角全屏钮只在 `pageState is Ready` 时渲染,`DetailViewModel.setFullScreen(true)` 同时拒进并经 `toastEvent` 说明原因 —— 判据收在纯函数 `DetailFullScreenGate.refusalReason()`(未就绪返回原因,就绪返回 null;源返回过 `msg` 优先透传,否则空结果用 `detail_empty_source`、加载中用 `detail_content_not_ready`,两条都是既有文案),`DetailFullScreenGateTest` 用 JVM 单测锁住这张真值表。成因:详情未就绪时全屏里什么都不渲染 —— 页面内容整段 `if (!fullBox)`、加载覆盖层另加 `!fullBox`(§6.1),而播放器侧此刻没有会话(遮罩由取流链路的 `setTip` 驱动,起点在拿到详情之后)⇒ 进去只有纯黑,分不清"在加载"还是"坏了"。⚠️ **改一处不够**:图标只是可见性,`setFullScreen` 才是唯一写 `fullScreen` 的入口(画质点击 `onQualityClick` 也走它),所以 VM 侧必须有同一判据。全屏态下 `pageState` 不会退回 `Loading`(`switchSource` 只在非全屏可达,且全屏时 `applyFullscreen` 会关掉自动换线),故无需"超时退出全屏"兜底。
-- **顶栏右块维持原实现(2026-09-26 试改后回退)**:右块 = 「网速(行内) → 进度时间 → 电量 + 电池图标 → 系统时间」一行,底栏唤出时网速另起第二行(`netSpeedTopRightVisible`);左块 `weight(3f)` / 右块 `weight(1f)`。当日曾按效果图改成「电量 │ 时间 │ 网络图标 + 网速」一行三段 + 竖分隔线(配套:左块 `weight(1f)`、右块按内容包裹、电量与网速固定宽防抖动),真机看后用户要求恢复原样 ⇒ **该方向已否,勿再按效果图改这个区域**(源 `.tubiao/网络.svg` 与转出的 `player_ic_network.xml` 已一并撤掉)。
+- **顶栏右块维持原实现(2026-09-26 试改后回退)**:右块 = 「网速(行内) → 进度时间 → 电量 + 电池图标 → 系统时间」一行,底栏唤出时网速另起第二行(`netSpeedTopRightVisible`);左块 `weight(3f)` / 右块 `weight(1f)`。当日曾按效果图改成「电量 │ 时间 │ 网络图标 + 网速」一行三段 + 竖分隔线(配套:左块 `weight(1f)`、右块按内容包裹、电量与网速固定宽防抖动),真机看后用户要求恢复原样 ⇒ **该方向已否,勿再按效果图改这个区域**(源 `.tubiao/网络.svg` 与转出的 `player_ic_network.xml` 已一并撤掉)。**左块内容(2026-09-28 用户「字体改为ts24,字重改为700」)**:片名 = `ts_24` + `FontWeight.Bold`(原 `ts_22` + Medium),与片名同列的**分辨率行已移出顶栏**、改挂底栏进度条上方的胶囊(见 §146)。**左右两块首行共用同一条 36dp 行高(2026-09-29 用户「图二右上角的电量时间这些信息的高度要对齐左边的影视标题」)**:`PlayerTopBar.TopBarLineHeight = 36.dp` 同时用于返回箭头触摸盒与右块首行 `Row(Modifier.height(...))` —— 片名(在 36dp 盒内居中,内容盒中心 = `36/2 + vs_5/2`)与「网速 / 进度时间 / 电量 + 电池图标 / 系统时间」首行因共盒而垂直对齐(与字号、窗口 mm 缩放无关);真机实测改前右侧整体偏高 15.5px ≈ (36 − 27.1)/2 dp,改任一侧的盒高会立刻回到错位状态。
-- **底栏菜单行 = 铺满整行的动作胶囊 + 右侧「播放参数」抽屉(2026-09-26 用户定稿,同日二次改版)**:原「文字即状态值」的整行菜单撤掉,拆成两处 —— ①**动作行**(**无底色** —— 2026-09-26 用户"将胶囊去掉吧",图标 + 文字直接浮在底栏渐变 scrim 上;`vs_70` 盒 / 图标 = 盒 × **0.55**(2026-09-28 用户"图标本体改为 24dp"⇒ 本机横屏全屏 43.7dp 盒下 ≈24dp,原 0.6 ≈26dp;`ICON_TO_BOX_RATIO`) / 水平内距 `vs_8`,**整行铺满、两端与进度条对齐**:每颗 `weight(1f)` 等分槽位(`PlayerPillIconButton` 的触摸盒随槽拉宽,图标盒与文字在槽内居中),**图标下方带文字标签**(复用既有文案 `common_refresh` / `common_cast` / `player_menu_subtitle` / `player_menu_audio_track` / `player_menu_video_track` / `player_menu_danmu` / `detail_episodes` / `player_menu_more`(2026-09-28 新增;`player_menu_params` 此后只作参数面板标题),字号 `ts_18`、`fontWeight = Medium`(与顶栏片名同档)、`maxLines = 1`;文字同时充当无障碍文案 —— Image 的 contentDescription 置 null 防重复朗读;按压底为胶囊形、覆盖图标 + 文字,padding `vs_10 / vs_2`)。⚠️ **图标不再套 `size(iconBox)` 的方盒**:方盒里图形只占 60%,上下各 ≈11.6dp 空白会变成"图标与文字间距过大"、并连带把胶囊撑高 ≈30dp 顶起进度条 —— 直接 `size(iconBox × ICON_TO_BOX_RATIO)`,图标与文字间距 `2dp`,压缩后胶囊总高与纯图标时代持平(≈62dp)。左侧 6 颗 = 刷新 / 投屏 / 字幕 / 弹幕 / 音轨 / 视轨(2026-09-28 用户"弹幕放在字幕和音轨中间":由末位前移),右侧经**竖分隔线**(1dp 宽、白 30%、线高 = 图标盒 × 0.55、两侧各留 `vs_8`)隔出**选集 / 更多**两颗(第二颗标签 2026-09-28 由「播放参数」改为「更多」`player_menu_more`,行为不变仍点开参数面板)(**2026-09-27 起 8 颗全部常驻** —— 用户"能否全部都不隐藏,常驻显示";原先的按需规则〈选集=剧集数 >1 / 弹幕=总开关 / 音轨·视轨=内核 IJK·EXO〉已废弃,`PlayerUiState` 的 `trackBtnVisible`/`danmuBtnVisible`/`episodeBtnVisible` 三条派生属性因此暂无消费方、暂留未删),`PlayerBottomBar.PlayerActionPill` / `PlayerPillDivider`);**进度条整行在它上方,时间胶囊(`当前 / 总时长`)贴在进度条左上角**(同色同圆角,`PlayerTimePill`)—— 时间从进度条两侧移到上方胶囊是 2026-09-26 用户按 YouTube 截图定的,**预览态不受影响**(仍是「播放钮 + 当前时间 - 进度条 - 总时长」一行)。尺寸由 YouTube 底栏截图**实测标定**(同日:初版 `vs_40`/`vs_5` 被用户判"太小怎么操作",两张截图归一化到屏宽后量出 YouTube 图标图形宽 = **2.40% 屏宽**、图形间隙 **3.59%**,初版是 1.46% / 2.14% ⇒ **整体 ×1.65**,取 `vs_70`/`vs_8` 宁大勿小;随后按用户要求底色 45% → **20%** → **整体去掉**(见上)、垂直内距归零、与进度条的间距 8dp → 4dp → **2dp** → **6dp**(底距同时 16dp → **6dp**) —— 前两处是为把进度条压低(2026-09-26 用户"进度条的位置还能再低一点吗"),最后一处回到 6dp 是**为拉开与图标的距离**(2026-09-27 用户"改成大概三毫米的空隙……拖动进度条的时候容易误触图标";白线到图标顶边的视觉间距 ≈14.8dp → ≈18.8dp,进度条相应上移 4dp,底距不动);②**参数入口在底栏分隔线右侧**(2026-09-28 标签由「播放参数」改为**「更多」**;动作 `onParamsClicked`,点开 `PlayerParamsSheet` —— 同日二次改版由右侧竖排移入底栏);`PlayerLayers.PlayerSideButtons` 只剩**旋转 / 锁**两颗,改为**屏幕左 / 右两侧各一颗、垂直居中**(旋转在左、锁在右,各自贴 `playerEdgePadding()` 边距;`SideButton(startSide)` 一处开关同时决定对齐与 padding 落点),显隐沿用 `lockState == SHOWN && !locked`。**图标尺寸与动作胶囊共用同一个图标盒** —— `PlayerOverlay.playerIconBox(可用宽) × ICON_TO_BOX_RATIO`,不再写死 24dp(2026-09-26 用户要求;原先 24dp 固定,只在特定窗口尺寸下才与胶囊图标数值巧合相等,换窗口就错位)。**盒只在 `PlayerOverlay` 根节点算一次**(`BoxWithConstraints` 的 `maxWidth - playerEdgePadding()×2`)再传给底栏与右侧竖排 ⇒ 两处必然等大;早先是各自算,竖屏全屏下胶囊被钳小而右侧没钳、两者差约 15%。
+- **底栏菜单行 = 铺满整行的动作胶囊 + 右侧「播放参数」抽屉(2026-09-26 用户定稿,同日二次改版)**:原「文字即状态值」的整行菜单撤掉,拆成两处 —— ①**动作行**(**无底色** —— 2026-09-26 用户"将胶囊去掉吧",图标 + 文字直接浮在底栏渐变 scrim 上;`vs_70` 盒 / 图标 = 盒 × **0.55**(2026-09-28 用户"图标本体改为 24dp"⇒ 本机横屏全屏 43.7dp 盒下 ≈24dp,原 0.6 ≈26dp;`ICON_TO_BOX_RATIO`) / 水平内距 `vs_8`,**整行铺满、两端与进度条对齐**:每颗 `weight(1f)` 等分槽位(`PlayerPillIconButton` 的触摸盒随槽拉宽,图标盒与文字在槽内居中),**图标下方带文字标签**(复用既有文案 `common_refresh` / `common_cast` / `player_menu_subtitle` / `player_menu_audio_track` / `player_menu_video_track` / `player_menu_danmu` / `detail_episodes` / `player_menu_more`(2026-09-28 新增;`player_menu_params` 此后只作参数面板标题),字号 `ts_18`、`fontWeight = Medium`(与顶栏片名同档)、`maxLines = 1`;文字同时充当无障碍文案 —— Image 的 contentDescription 置 null 防重复朗读;按压底为胶囊形、覆盖图标 + 文字,padding `vs_10 / vs_2`)。⚠️ **图标不再套 `size(iconBox)` 的方盒**:方盒里图形只占 60%,上下各 ≈11.6dp 空白会变成"图标与文字间距过大"、并连带把胶囊撑高 ≈30dp 顶起进度条 —— 直接 `size(iconBox × ICON_TO_BOX_RATIO)`,图标与文字间距 `2dp`,压缩后胶囊总高与纯图标时代持平(≈62dp)。左侧 6 颗 = 刷新 / 投屏 / 字幕 / 弹幕 / 音轨 / 视轨(2026-09-28 用户"弹幕放在字幕和音轨中间":由末位前移),右侧经**竖分隔线**(1dp 宽、白 30%、线高 = 图标盒 × 0.55、两侧各留 `vs_8`)隔出**选集 / 更多**两颗(第二颗标签 2026-09-28 由「播放参数」改为「更多」`player_menu_more`,行为不变仍点开参数面板)(**2026-09-27 起 8 颗全部常驻** —— 用户"能否全部都不隐藏,常驻显示";原先的按需规则〈选集=剧集数 >1 / 弹幕=总开关 / 音轨·视轨=内核 IJK·EXO〉已废弃,`PlayerUiState` 的 `trackBtnVisible`/`danmuBtnVisible`/`episodeBtnVisible` 三条派生属性因此暂无消费方、暂留未删),`PlayerBottomBar.PlayerActionPill` / `PlayerPillDivider`);**进度条整行在它上方,时间胶囊(`当前 / 总时长`)贴在进度条左上角**(同色同圆角,`PlayerTimePill`;**2026-09-28 起同一行右侧多一颗分辨率胶囊**:原挂在顶栏片名下方的那行 `state.videoSize`(「1920 X 1080」)移到这里,与时间胶囊**共用同一容器** `PlayerBottomBar.PlayerInfoPill`(底色 `OVERLAY_PILL_ALPHA` / `RoundedCornerShape(50)` / 内距 `vs_10`·`vs_5`,不要再各写一份),一行内左=时间、右=分辨率;`videoSize` 为空串时**不画**(不判空会渲染出一颗空胶囊);**预览态该行整行不画(2026-09-29 用户「图一竖屏影视详情页面的预览态不要在右下角显示胶囊分辨率,只要在进入横屏观看或者竖屏观看时才显示」)**:分辨率胶囊移进 `if (!state.previewMode)` 块、与时间胶囊同一守卫 —— 预览态两颗都不画,横屏 / 竖屏全屏(`setPreviewMode(false)`,含详情页点全屏的竖屏全屏)照常显示。**进度条中心线不受影响** —— 底栏 Column 贴底,新增这行是让内容往上长,进度条位置仍由 `bottomPad` 决定,预览态「暂停钮 / 进度条 / 全屏钮」同中心线那条约束照旧)—— 时间从进度条两侧移到上方胶囊是 2026-09-26 用户按 YouTube 截图定的,**预览态不受影响**(仍是「播放钮 + 当前时间 - 进度条 - 总时长」一行)。尺寸由 YouTube 底栏截图**实测标定**(同日:初版 `vs_40`/`vs_5` 被用户判"太小怎么操作",两张截图归一化到屏宽后量出 YouTube 图标图形宽 = **2.40% 屏宽**、图形间隙 **3.59%**,初版是 1.46% / 2.14% ⇒ **整体 ×1.65**,取 `vs_70`/`vs_8` 宁大勿小;随后按用户要求底色 45% → **20%** → **整体去掉**(见上)、垂直内距归零、与进度条的间距 8dp → 4dp → **2dp** → **6dp**(底距同时 16dp → **6dp**) —— 前两处是为把进度条压低(2026-09-26 用户"进度条的位置还能再低一点吗"),最后一处回到 6dp 是**为拉开与图标的距离**(2026-09-27 用户"改成大概三毫米的空隙……拖动进度条的时候容易误触图标";白线到图标顶边的视觉间距 ≈14.8dp → ≈18.8dp,进度条相应上移 4dp,底距不动);②**参数入口在底栏分隔线右侧**(2026-09-28 标签由「播放参数」改为**「更多」**;动作 `onParamsClicked`,点开 `PlayerParamsSheet` —— 同日二次改版由右侧竖排移入底栏);`PlayerLayers.PlayerSideButtons` 只剩**旋转 / 锁**两颗,改为**屏幕左 / 右两侧各一颗、垂直居中**(旋转在左、锁在右,各自贴 `playerEdgePadding()` 边距;`SideButton(startSide)` 一处开关同时决定对齐与 padding 落点),显隐沿用 `lockState == SHOWN && !locked`。**图标尺寸与动作胶囊共用同一个图标盒** —— `PlayerOverlay.playerIconBox(可用宽) × ICON_TO_BOX_RATIO`,不再写死 24dp(2026-09-26 用户要求;原先 24dp 固定,只在特定窗口尺寸下才与胶囊图标数值巧合相等,换窗口就错位)。**盒只在 `PlayerOverlay` 根节点算一次**(`BoxWithConstraints` 的 `maxWidth - playerEdgePadding()×2`)再传给底栏与右侧竖排 ⇒ 两处必然等大;早先是各自算,竖屏全屏下胶囊被钳小而右侧没钳、两者差约 15%。
- **面板形态与选集面板完全同源**:走 app 内那套 `AVBoxBottomSheet`(Compose **内嵌覆盖层**,不是 `PlayerDialog`),横屏 `slideFromEnd = true` 贴右滑出、竖屏贴底滑出 —— 于是**可拖拽关闭、遮罩随动画淡入、进出场 280ms、按面板自身高度滑入**,手感与选集面板一致(2026-09-26 用户反馈"两个面板手感不一样"后统一)。尺寸沿用 `AVBoxBottomSheet` 自己的规则(贴底限宽 640dp;侧滑 `min(640dp, 屏宽×0.45)` 且全高)。⚠️ **内嵌覆盖层必须吃掉拖拽**:它的遮罩只 `clickable`(只消费点击)、且进场动画期间还是禁用的,落在播放器覆盖层里时拖动会穿透到 dkplayer 的进度/音量手势 ⇒ `SheetOverlay` 的遮罩加了一层 `pointerInput`,只消费**有位移**的 change(`positionChanged()`),点击仍留给 `clickable` 关闭面板。其余播放器面板(字幕/音轨/弹幕/投屏/尺寸选择)仍走 `PlayerDialog`,不受影响。
- **面板内容 = 4 组 chips + 1 组滑块 + 1 个可选动作(自上而下;2026-09-26 用户重排)**:播放器(`player_params_player`)/ 解码方式(`settings_play_decode`)/ 调整倍速(`player_params_speed`,**滑块**)/ 片头片尾 / 画面比例(`live_group_scale`),订阅源支持时追加「搜弹幕」按钮。**五组之间以水平分隔线分区(2026-09-27 用户「采用分隔线隔开来」→ 追问后选定组间)**:`ParamsGroupDivider` = `vs_15` + `HorizontalDivider`(`outlineVariant`,线宽 1dp 固定不缩放) + `vs_15`,同时移除三个 Group 函数末尾的 `vs_30` 间距(由分隔线统一承载组间节奏;搜弹幕按钮前补回 `vs_30`)。选中态由控制器每次选择后**重建 `ParamsSheetState`** 刷新。**标签行统一为 `ParamsGroupHeader`(2026-09-27 由 `ParamsLabelRow` + `ParamsGroupLabel` 合并)**:可选前置图标 + 组名 `onSurfaceVariant` 在左占 `weight(1f)` + 可选当前值 `onSurface` 贴右(2026-09-27 组名与当前值升到 `ts_22` + `FontWeight.Medium`)。**图标只挂组标题与动作按钮、不挂 chips**(同组两颗选项共用一颗图标 = 噪音,故 `.tubiao` 每组只出一颗):播放器 / 解码方式 / 调整倍速三组标题各用 `.tubiao` 转换的 `player_ic_params_player` / `_decode` / `_speed`(`vs_24` + 间距 `vs_10`、`onSurfaceVariant` 着色);片头 / 片尾两颗下放到「设为片头 / 设为片尾」按钮(`player_ic_params_time_start` / `_end`),**「清空」也挂图标(2026-09-28 用户「给清空按钮加上一个图标,项目里好像有几个垃圾箱的图标可以用」)** —— 复用全仓唯一那颗垃圾箱 `ic_delete`(源 `.tubiao/删除.svg`;历史 / 收藏批量删除、搜索记录清空、配置管理删除等 7 处已在用,全仓无第二颗垃圾箱图标,故不新建 drawable);「搜弹幕」按钮前置 `player_ic_menu_danmu`。**画面比例 / 片头片尾两组标题图标(2026-09-27 用户新增素材)**:`.tubiao/画面比例.svg` 与 `.tubiao/片头片尾.svg` 转出 `player_ic_params_scale` / `player_ic_params_time` 挂到对应组标题(`vs_24` + `vs_10`、`onSurfaceVariant`),至此五组标题全部带图标;`ic_layout_horizontal/vertical` 是搜索页「列表横排 / 竖排」、`ic_player_expand` 是详情页全屏入口,仍不得借用。`SheetButton` 新增可选 `iconRes`(默认 `null`),宽度由调用方固定(`weight(1f)` / `fillMaxWidth`)的行不受影响。**片头片尾不再有右侧状态值(2026-09-27 用户要求「右上角的标题就删了」)**:已设值的时间下移到「设为片头 / 设为片尾」按钮文案内(「片头 13:14」/「片尾 45:10」,私有 `timeMarkText`:值为空串时才是动作文案「设为片头 / 设为片尾」),按钮同时进入 `selected` 激活态(`primaryContainer` 底 + `onPrimaryContainer` 字,与硬解码 / 画面比例同款的动态取色选中态);该组因此只剩「组标题行 + 三键行」两行。⚠️ **播放器弹窗内字重一律 500(2026-09-27 先只落参数面板,2026-09-28 用户「播放器界面全部 dialog 弹窗的字重都统一为 500」扩到全部)** —— 实现收在骨架里、不再逐调用点传参:`SheetButton` 恒 `Medium`(**`fontWeight` 与 `boldOnSelect` 两个参数已删**,选中态只靠 `primaryContainer` 底色区分;`iconRes` 保留)、`SheetLabelRow` / `SheetStepper` / `SheetInput`(输入文本与占位提示)同样写死 `Medium`;面板各自的自绘文本(投屏 `cast_hint` `ts_18` + 权限提示/搜索中/无设备三处 `ts_20`、字幕的大小/位置/延迟三处 `ts_26` + 延迟说明 `ts_20`)也补了 `Medium`。**范围**:`player/ui` 下用 `PlayerSheets` 骨架的那 6 个弹窗文件(内核/倍速/画面尺寸选择、弹幕设置与搜索、字幕设置与搜索、投屏、播放参数);**选集面板(`ui/activity/DetailEpisodes`)不在内** —— 它是详情页的 sheet、与竖屏详情页共用,且其 M3 `FilterChip` 标签本就是 `labelLarge`(M3 默认 Medium)。**代价(待收口)**:画面比例六枚 chips 走 `contentPadding`(宽度随内容),Medium 下总宽 ≈1016 + 6×5 + 5×`vs_10`(22px) = **1156px** 超出内容区 **1129px**(45% 屏宽 − 2×`vs_30`)⇒ 末位「裁剪」会掉到第二行(实测口径:未加粗 1126px 余量仅 3px、"4:3" 加粗 57→60px);可选收口 = 缩该行 chips 的间距或水平内距。⚠️ 其余弹窗的 chips 宽度由 `weight(1f)`(`SheetChipRow`)或固定 `vs_140`(字幕大小/位置键)决定,**不随字重变化** —— 改字重/字号前先分清"随内容宽度"与"调用方固定宽度"这两类(字重现已全局 500,不再有"选中加粗"这个变量)。
- **播放器组顺序 = exo 左、ijk 右(2026-09-26 用户指定)**:`getExistPlayerTypes()` 原序是 `1(ijk), 2(exo), …`(HashMap 小整数键升序),面板里经 `ComposeVideoController.sheetPlayerOrder()` 把 `[2, 1]` 提到最前、其余内核保持原序跟在其后。⚠️ **只改面板**,底栏「播放器」按钮的**循环切换顺序**(`onPlayerClicked`)仍按原序,两者不一致是有意保留的(用户只要求弹窗)。
@@ -175,7 +175,7 @@
顶部 TextField + 系统输入法(删自绘键盘 SearchKeyboard);搜索历史 chips;各源结果分区;快搜功能已删除(2026-09-09,删除清单见 `history/features.md`),同名/换源需求由详情页换源行承担。**求解中进度 = 波浪线不定长 `LinearWavyProgressIndicator`**(2026-09-11 用户要求,替代 LinearProgressIndicator;左右 16dp 与搜索框对齐、上下各 12dp 留白,不贴搜索控件与下方卡片)。**结果源筛选**(2026-09-11 用户要求):进度条下方**横向滑动 FilterChip 行**(「全部」+ 所有有结果的源,单选;再点已选中的源 = 取消筛选回「全部」;仅 1 个源时整行不显示),仅过滤下方分区显示,不触发重搜;发起新搜索(含历史 chip/热搜/外部带标题进入)时筛选重置为「全部」。**空态文案居中**(2026-09-11 用户要求):「搜索历史」卡片的「暂无搜索历史」与「热搜榜」卡片的「暂无热搜数据」由靠左改居中(`Modifier.fillMaxWidth()` + `textAlign = TextAlign.Center`)。
- **搜索记录区内部可以有过渡(2026-09-26)**:「记录 chips ↔ 暂无搜索历史」之间用 `AnimatedContent`(`contentKey` = `isEmpty`,`SizeTransform(clip = false)`)交叉淡入淡出,清空 / 删到最后一条不再硬切。⚠️ 与上一条的分区级硬切换**不冲突**:这里只在「搜索历史」卡片内部、且只在"有记录 ↔ 无记录"这一次跃迁时叠绘,不是两棵全屏子树;真机上若仍见卡顿感,按上条口径退回硬切。**列表内部删单条(chips 仍在)无动画** —— `FlowRow` 非 lazy,没有 `animateItem` 等价物,做逐条淡出/位移需引入 `LookaheadScope + animatePlacement`(项目内暂无先例)。
-- **内容分区不做过渡动画(2026-09-21 用户定稿)**:「闲置区(历史+热搜+联想)↔ 结果区」保持 `if/else` **直接切换**。当天曾用 `AnimatedContent` 试过两版(fade+slide+spring / 纯 alpha 交叉淡入淡出),用户以「效果不好 + 卡顿」为由要求**整体删除、改回硬切换** —— 结论是这里**不要加过渡**:① 搜索页不换 Activity,首帧结果区只有左侧源栏 + 波浪进度条,给整屏内容做位移/淡入都缺少可衔接的视觉主体;② 任何 `AnimatedContent` 都会把**两棵全屏子树**(闲置区 FlowRow chips + 结果区 LazyColumn)同时留在组合树里,叠绘 + 双份绘制本身就是卡顿源。过程与两版实现细节见 `history/features.md`。
+- **内容分区不做过渡动画(2026-09-21 用户定稿)**:「闲置区(历史+热搜+联想)↔ 结果区」保持 `if/else` **直接切换**。当天曾用 `AnimatedContent` 试过两版(fade+slide+spring / 纯 alpha 交叉淡入淡出),用户以「效果不好 + 卡顿」为由要求**整体删除、改回硬切换** —— 结论是这里**不要加过渡**:① 搜索页不换 Activity,首帧结果区只有左侧源栏 + 波浪进度条,给整屏内容做位移/淡入都缺少可衔接的视觉主体;② 任何 `AnimatedContent` 都会把**两棵全屏子树**(闲置区 FlowRow chips + 结果区 LazyColumn)同时留在组合树里,叠绘 + 双份绘制本身就是卡顿源。过程与两版实现细节见 `history/features.md`。**2026-09-29 第三次尝试:加过渡 → 再按「过渡期间不拉网络」降掉帧 → 真机仍判掉帧 ⇒ 当日全部回退(现行口径仍是硬切,勿再提)**:用户再次要求「搜索页 → 结果页」的过渡,并补了新约束「首页海报菜单带标题进页那条不要动画(外面已有 Activity 转场)」。当时的实现 = `SearchScreen` 里 `AnimatedContent(targetState = `SearchStage`(Empty/Idle/Results))`,默认 `None togetherWith None`,只有 `submit()`(页内提交 / 点历史 / 点热搜 / 点联想)置位 `animateStage` 才播 `fadeIn(180ms) + slideInVertically(spring Medium, 16dp) togetherWith fadeOut(120ms)`。**当时还试过「过渡期间不拉网络」(用户「感觉有点掉帧,是否应该等动画播完再搜索」之后)**:`SearchViewModel.search(title, startDelayMs = 0L)` 加过一个可选延时 —— **占位(各源 Pending)仍同步落库**(结果区首帧就有源栏 + 波浪线,不是空白),只有网络 fan-out 先 `delay(startDelayMs)` 再发;页内提交传 `SEARCH_START_DELAY_MS = 200L`(≈ 入 180ms),带标题进页的自动搜索不传(即时搜且本就不播过渡)。掉帧主源 = 过渡那 200ms 里**逐源回填**(重排 + 卡片合成 + Coil 取图)与过渡抢帧;token 在 `search()` 同步捕获,延后发起的批次仍受 `myToken != token` 守卫,不会与新搜索打架。⚠️ **与 2026-09-21 被否的第一版(fade+slide+spring)唯一实质差别就是这个闸门** —— 那版连"带标题进页的自动搜索"也一起播、叠在 Activity 转场上,极可能就是当时"效果不好"的一半原因;而**"两棵全屏子树同时在场"的卡顿风险并未消除**,**回退动作已于当日完成**:`SearchScreen` 的 `if/else` 硬切换与 `SearchViewModel.search(title)` 均已还原(残留检查无 `SearchStage`/`animateStage`/`SEARCH_START_DELAY_MS`/`startDelayMs` 等标识符),`AnimatedContent` 只剩结果区「竖排 ↔ 横排」那处;上一条"搜索记录区内部可以有过渡"是卡片内部的叠绘,同样不受影响。
- **结果展示方式:默认竖排(2026-09-21 用户改定)**:两种排版都实现,`AnimatedContent` 在两者间切(各带一次 spring 位移+淡入,**这处动画保留**,与上面"闲置区↔结果区不加过渡"不冲突):
- **竖排(默认)** = `RailResults`,左侧 140dp 站点栏(`SearchRailWidth`)+ 右侧结果列表;横排 = `SearchListResults`,各源分区 + 横向卡片行。
- 切换入口 = 搜索框 `trailing` 槽位那颗 ⋮(`LayoutSwitchAction` → `DropdownMenu`,`SearchScreens.kt`);取值/落库 = `util/SearchSettings.kt` 的 `resultLayout()` / `setResultLayout()`,KV 键 `search_result_layout`(未登记 `KVKeySpec`,**读取必须带默认值**)。
@@ -316,13 +316,14 @@
- 卡片触摸反馈:ripple + 按压缩放(0.96~0.98)。**涟漪透明度全局 = M3 默认 2 倍**(2026-09-13,照搬 `示例文件/android` 的 `Theme.kt`):`AVBoxTheme` 用 `LocalRippleConfiguration` 下发 `RippleConfiguration(rippleAlpha = …)`,pressed 0.20 / hovered 0.16 / focused 0.20 / dragged 0.32 —— M3 默认 pressed 仅 10%,首页海报卡是深色图片 + 黑色渐变 scrim,几乎看不出"点到了"。走全局配置而不是逐卡传 `indication`:`clickable`/`combinedClickable`/`Surface(onClick)`/`ToggleButton` 一次覆盖。⚠️ `RippleConfiguration` 已 deprecated 但官方无替代入口,必须 `@Suppress("DEPRECATION")`。圆角卡必须 **先 `.clip(shape)` 再挂 `clickable`**,否则涟漪与长按激活区会溢出圆角变成矩形(`PressableCard` 已是此顺序)。参考项目的按压缩放目标是 **0.94**(经 `Modifier.scale` + `spring(dampingRatio=0.6f, stiffness=800f)`),本项目沿用自定的 0.96~0.98 不改。
- **选中态色统一为 `primaryContainer`(2026-09-28 用户定稿)**:全项目 M3 `FilterChip` 一律经 `ui/theme/Color.kt` 的 `ColorScheme.filterChipColors()` 取色(选中容器 `primaryContainer` / 文字 `onPrimaryContainer`;未选中容器默认可按调用点覆盖),**不再沿用 M3 默认的 `secondaryContainer`** —— 深色下后者是低彩度灰调,与同屏的播放参数面板(`SheetButton` 选中 `primaryContainer`)、倍速滑块(M3 `Slider` 默认 `primary`)差距一眼可见(用户以播放器「选集面板 vs 参数面板」两张截图提出)。调用点:详情页(清晰度 / 线路 / 选集行 / 换源卡 / 选集面板的分段与集数网格)、搜索页结果源筛选、首页筛选面板、主题设置页配色风格。**未纳入(维持原样)**:首页排序 `HomeFilterChip`(自绘,选中 `primary`)、底部导航选中指示(玻璃胶囊 / M3 默认)、单选与开关(`SettingsOptionRow` 选中 `primary`)。
- 字号阶梯 18/14/12sp 替代 TV 的 mm 大字号;间距 8dp 栅格。页面左上角大标题(设置/历史/收藏)= `headlineSmall` 24sp/700(2026-09-09 用户定稿,Typography 集中定义)。
-- 骨架屏 shimmer 统一组件(替代 LoadSir;2026-09-27 两处修复:①高光由 `Color.White` 15% 改为 `onSurface` 18%(周期 1200ms → 1000ms;18% 为当日二次回调,10% 时用户仍判"不是很明显")—— `cardContainer` 是 `surfaceBright` 的别名(§`ui/theme/Color.kt`),浅色主题下白色高光叠在近白底上对比度≈0,动画一直在跑但肉眼看不见;②分类网格骨架由固定 6 个改为 18 个 —— 原数量在竖屏只够两行(3 列),下方留大片空白);空态/错误重试统一三态组件。
+- 骨架屏 shimmer 统一组件(替代 LoadSir;2026-09-27 两处修复:①高光由 `Color.White` 15% 改为 `onSurface` 18%(周期 1200ms → 1000ms;18% 为当日二次回调,10% 时用户仍判"不是很明显")—— `cardContainer` 是 `surfaceBright` 的别名(§`ui/theme/Color.kt`),浅色主题下白色高光叠在近白底上对比度≈0,动画一直在跑但肉眼看不见;②分类网格骨架由固定 6 个改为 18 个 —— 原数量在竖屏只够两行(3 列),下方留大片空白);**2026-09-28 第三处修复**:高光位移由 2×宽改为 2×对角线(`shimmerStartX`,`ui/components/Skeleton.kt`)—— 渐变图案沿对角线铺、长度 = 对角线,而矩形沿对角线方向的投影跨度也恒等于对角线,只走 2×宽时**竖版盒子(高 > 宽)里高光走不出框**,无限循环重启会把它从右下角硬切回左上角(竖向海报骨架 `aspectRatio(2f/3f)` 必现,横版 Hero `aspectRatio(1.5f)` 恰好看不出);⚠️ 别再按"宽度"给这类对角线扫光算位移。口径锁在 `ShimmerBandTest`;**代价**:高光现在用周期的中间一半(≈500ms)穿过盒子,竖版观感比修复前快,要回到原速调 `durationMillis`(当前 1000ms 为 2026-09-27 定稿,未改)。空态/错误重试统一三态组件。
- **胶囊分段选择器(2026-09-11 引入,2026-09-12 加 `SegmentStyle`)**:`ui/components/CapsuleSegmentedButton.kt` —— M3 expressive `ToggleButton` 组,按压弹簧回弹(0.94→1),整行等宽,语义 `Role.RadioButton`;选项 `SegmentOption(label, value, icon/iconPainter, badge)`,**`badge` = 可选的第二行摘要**(模式开关兼作状态显示;为 null 时渲染与单行版一致)。**两种外观由 `style` 选择,默认 `Connected`(不传即保留引入时原样)**:
- `SegmentStyle.Connected`(原样式,**主题设置页**):段间 `ConnectedSpaceBetween` 细缝 + `ButtonGroupDefaults.connectedShapes`,除形状外全走 M3 `ToggleButton` 默认(选中 primary 实心 / 未选中 surfaceContainer)。
- `SegmentStyle.Track`(**配置管理页**):外层全圆角胶囊(**`surfaceContainer`**,2026-09-21 由 `surfaceContainerHighest` 改 —— 用户要求与音乐页底部胶囊同款)+ 4dp 内缩 + 4dp 段间距 + **段容器 `surfaceBright` 子弹头**(2026-09-21 由"透明"改:未选中 = `surfaceBright`,选中 = M3 默认 primary 实心;两态同时有底色才与音乐页一致)+ `elevation = null`(留默认阴影会有一圈灰边)+ 段内边距 12/2dp ⇒ 两行内容总高 **48dp**;段形状统一为全圆角(`RoundedCornerShape(percent = 50)` = 四角全圆的子弹头),顺带关掉 M3 默认的按压形变。⚠️ 段高下限 = M3 `ToggleButtonDefaults.MinHeight`(40dp,硬编码在 `ToggleButton` 内部的 `defaultMinSize`),要更小只能放弃 `ToggleButton` 自绘。⚠️ **`Track` 只有配置管理页在用**(主题设置页 = `Connected`、搜索设置 sheet = `Separated` 且显式传色),故改 `Track` 不影响另外两处;反之改动本组件前仍要按 2026-09-12 的教训逐页确认。
- ⚠️ **改本组件前先确认两个调用页各自要的外观** —— 2026-09-12 教训:轨道样式一度全局生效,主题设置页观感变差,当天改为按页选择。
- **搜索框(2026-09-23 引入并当天改为两处共用)**:`ui/components/SearchField.kt` —— 胶囊搜索框,无描边、内层高 40dp、24dp 放大镜、有内容时右侧 18dp 清空钮(`Icons.Filled.Close`,contentDescription = `common_clear`)、`ImeAction.Search` 的动作由调用方给;`hint` 走参数(默认 `search_field_hint`,首页订阅源 sheet 传 `home_site_search_hint`)。调用点两处:**搜索页顶栏**(2026-09-23 由 `SearchScreens.kt` 内的 `internal fun SearchField` 提到 `ui/components`,行为未改)与**首页订阅源 sheet**(就地过滤站点)。**玻璃两档(2026-09-23 补)**:有顶栏 backdrop 时用 `glassTopBarSurface(ContinuousCapsule, cardContainer)`(搜索页顶栏,可折射页面内容);**取不到 backdrop 时(弹层被 `SheetHost` 提到窗口根渲染)改用 `glassSurface(…)`(空底)** —— 仍跟随「液态玻璃应用控件」开关,只是没有可折射的内容可采样。⚠️ 弹层**不要**为此单独铺 `LayerBackdrop`:面板不透明,采它只有一块纯色、与空底视觉一致,却多付一层每帧录制,还让玻璃采到自己(先例 = 配置管理页选文件按钮 `ConfigManagePage.kt:924`,同一路)。
- **2026-09-28 修正:搜索框改用「静态玻璃」,且玻璃与输入框分节点**。两档都传 `pressEffect = false`(`glassTopBarSurface` / `glassSurface` 新增该参数,**默认 true ⇒ 其余顶栏控件行为不变**):不做按压缩放、不挂按压光斑与手势观察器。`SearchField` 同时改为 **`Box` + `matchParentSize()` 背景玻璃 + 内容行(兄弟节点)** 的结构 —— 玻璃观感(模糊/折射/通透/高光/内外阴影/底板)全部保留,几何尺寸与改造前逐像素一致(外层 `Box` 尺寸仍由内容行决定,背景层 `matchParentSize` 取同一尺寸;外层取 `contentAlignment = Center` —— 顶栏 title 槽会给标题内容最小高度,被拉伸时内容仍需居中)。**原因**:玻璃的按压缩放(`layerBlock` 驱动 `Modifier.graphicsLayer`)与 `drawBackdrop` 把内容放进「带 shape 裁剪的离屏层」都会带动输入框几何,而 Compose 编辑态的水滴手柄(`CursorHandle`,渲染在独立 Popup 里)的显隐判据是 `isSelectionHandleInVisibleBound` —— **插入符底边是否落在「输入框被裁剪后的可见区域」内(边界比较)**,几何一动就重算,且 Popup 是「销毁/重建」而非淡入淡出 ⇒ 表现为**点击已有文字的搜索框后水滴手柄反复闪**。手柄只在 `transformedText` 非空时存在(空框看不到),surface 档既无动画层也无离屏 shape 裁剪 ⇒ 不复发。⚠️ 后续任何「把输入框塞进玻璃层 / 动画图层」的写法都会重新触发;按压形变这类交互只给不含输入框的控件(圆钮)用。
+ - **2026-09-29 修正:键盘收起时一并清焦点(用户报「长按搜索框出「粘贴」浮层 → 侧滑返回收掉键盘后浮层仍停留,只有输入新文字才消失」)**。组件内观察 `WindowInsets.isImeVisible` 的「显示→隐藏」跳变,此刻若本输入框仍有焦点(`onFocusChanged` 记录)则 `focusManager.clearFocus()` —— 编辑器失焦即结束平台的插入态 action mode,浮层与键盘一起消失。⚠️ **别再退回「只收键盘 / 只 hide 工具栏」**:那个「粘贴」气泡是编辑器持有的**浮动工具栏**(与键盘是两个独立窗口),只在「改文 / 选区变化 / 输入框失焦」三种情况下结束,点空白处也不掉;而收键盘那一次返回事件在系统层**先派发给输入法**(Android 13+ 规则),页面侧根本收不到、无法即时响应 ⇒ 只能靠「键盘已收 ⇒ 清焦点」这条补偿。副作用:键盘收起后光标不再闪,再点一下输入框即恢复(**含点键盘自带的收起键、提交搜索**这两类收键盘场景)。范围只到 `SearchField` 的两个调用点,其余输入框(配置管理页、弹幕 / 字幕搜索、播放参数面板)仍有原现象。
- **加载指示器(2026-09-11 用户定稿)**:页面/内容加载态统一改用 M3 expressive **`ContainedLoadingIndicator`**(替代 `CircularProgressIndicator`),**尺寸统一 64dp**(例外见下)。接入点 = `LoadStateBox` 默认 `loadingContent` 64dp(覆盖首页 / 搜索 / 栏目二级列表页等整页加载态)+ `MainScreen.BootLoading` 64dp + 历史 / 收藏页 loading 分支 64dp。**尺寸例外**:① 竖屏详情页两处(内容 Loading 分支、竖屏玩家区 Loading)保持 48dp;② ~~直播页(播放器页)整页 LOADING 显式传 48dp~~ —— **2026-09-19 用户要求改为 64dp**(`LiveScreens.LiveScreen` 的 `PageState.LOADING`,与首页整页加载态一致),该项已不再是例外。详情页竖屏玩家区(纯黑底)另**显式传白色系配色**(容器 `White 20%`、指针 `White 75%`):默认 `secondaryContainer` 在纯黑上过亮,且与紧接其后的播放器白色 spinner 不接续。**播放器视频覆盖层 spinner 保持 `CircularProgressIndicator` 不动**(`PlayerLayers.PlayerLoadingLayer`、`LivePlayActivity` 切台快照 36dp / 缓冲态 40dp;`PlayerSheets.SheetLoading` 仍供弹幕 / 字幕面板使用 —— **投屏面板已于 2026-09-19 改用 `ContainedLoadingIndicator` 64dp**,见本节末)。组件尺寸由调用方 `Modifier.size` 控制(实现内部走 `SizeKt.size`,指针按 `ActiveIndicatorScale` 等比缩放);该 API 在 material3 1.5.0-alpha23 中标注 `@ExperimentalMaterial3ExpressiveApi`,接入文件统一 `@file:OptIn(androidx.compose.material3.ExperimentalMaterial3ExpressiveApi::class)`(与 HomePage/SettingsPage 同风格)。**2026-09-12 扩展定稿:全项目所有圆形加载指示器(播放器页面除外)一律 `ContainedLoadingIndicator`** —— 全库审计后非播放器的 `CircularProgressIndicator` 仅剩首页下拉刷新一处,已换成引导页(BootLoading)同款 48dp。**2026-09-19 追加**:①直播页整页 LOADING 由 48dp 改为 **64dp**(与首页一致);②**投屏面板**(`player/ui/CastSheet.kt` "正在搜索设备...")由 `SheetLoading`(`CircularProgressIndicator`)改为 **`ContainedLoadingIndicator` 64dp**,成为播放器 Dialog 里唯一使用该组件的面板 —— 弹幕 / 字幕面板的 `SheetLoading` 保持不变。剩余保留清单 = `PlayerLayers.PlayerLoadingLayer`、`LivePlayActivity` 切台快照 36dp / 缓冲态 40dp、弹幕 / 字幕面板 `SheetLoading`。
- **播放器对话框 M3 化(2026-09-16)**:点播覆盖层的全部 Dialog(内核/倍速/画面尺寸选择、投屏、弹幕设置/搜索、字幕设置/搜索)统一走 M3 语义色与形状 —— 面板 `surfaceContainer` + **18dp 圆角**(2026-09-21 用户定稿:28dp → 16dp → 18dp)+ 6dp 阴影;选项 `surfaceBright`,选中 `primaryContainer`/`onPrimaryContainer`,聚焦 `surfaceContainerHighest` + `primary` 2dp 描边;提示文字 `onSurfaceVariant`;输入框 `surfaceContainerHighest` + `outlineVariant`(聚焦 `primary`)描边。骨架集中在 `player/ui/PlayerSheets.kt`(全部面板共用),`PlayerSelectDialog` 改为复用同一骨架(不再自带一套配色);res 的旧 `dialog_*` 深色描边调色板随之下线。**字号/尺寸仍走 AutoSize(mm) 档**(`playerDim`/`playerTextSize`):覆盖层按屏宽等比缩放,换 M3 固定 sp 会在电视上明显偏小;交互层 = **触摸点按**(DPAD 那套 `tvConfirmKey`/焦点链/默认聚焦项随 TV 分支在 Step 1 一并删除,见 §4.5)。
- **面板进出场动画(2026-09-22 用户定稿:保留平台 Dialog,只给面板内容加动画)**:窗口结构不动(仍是 `Dialog(usePlatformDefaultWidth = false)`,靠独立窗口天然屏蔽播放器手势并隔离返回键),动画在窗口内做 —— 壳 `PlayerDialog`(`PlayerSheets.kt`)把内容套 `0.92 → 1.0` 缩放 + 淡入,`220ms`,与 app 内 `AVBoxDialog` 同参数;退场先播完动画再回调。面板内的关闭入口一律走 `LocalPlayerSheetDismiss`(只关闭)或 `LocalPlayerSheetDismissThen`(先播退场→执行动作→再关闭,给"关掉本面板并打开下一个面板"用,避免两个面板窗口重叠)。**点面板外空白也关闭**:平台 `dismissOnClickOutside` 在"满屏内容"下永不触发(`DialogLayout.isInsideContent` 比的是整屏 Box 的实测尺寸),所以自己铺一层遮罩收该手势(面板是 M3 `Surface`,自带 `pointerInput` 挡穿透,点面板本体不会误关);遮罩**变暗**仍是平台窗口 dim(瞬现)。退场 220ms 里额外加一层吃触摸的盖子,防止连点两个选项触发两次动作;组合在退场途中被销毁(旋转/全屏 reparent/Activity 重建)时由 `DisposableEffect` 兜底补一次 `onDismiss()`,否则弹窗状态留在 `PlayerUiState` 上、重建后面板会自己弹回来。
@@ -370,7 +371,7 @@
- **本机(实测 vivo 2425A)无 AC3/EAC3/DTS 系统解码器**:ROM 把这些解码器在 codec 配置里整段注释,AOSP 配置亦不含 `eac3` ⇒ 这类音轨落 ffmpeg 扩展渲染器(**软解**),是"两路音频渲染器"最常见的来源;对照 AAC 因有 AOSP `c2.android.aac.decoder` 仍走 MediaCodec。排查同类问题先看文件日志 `echo-setTrack renderers:` 与 `adb shell grep -i eac3 /vendor/etc/media_codecs*.xml`。
- **fork `VideoView` 的旧内核释放不依赖播放状态(2026-09-22)**:`release()` 与 `startPlay()` 都必须先释放已有 `mMediaPlayer`(`stopPlaybackKeepPlayer()` 在 PREPARING/BUFFERING 下会留下「IDLE + 内核仍在」,旧写法里 `release()` 因 `!isInIdleState()` 闸而空操作 ⇒ 旧实例被 `initPlayer()` 覆盖、无人 release)。
- **加载/错误遮罩必须画在控制器层最底、顶栏/底栏之前(2026-09-24,用户报"影视加载时唤不出控件和进度条")**:遮罩(`PlayerLayers.PlayerTipLayer`,状态经 `PlayerTipBridge` → `PlayContainer.onTipStateChanged` → `PlayerUiState.applyTip`)盖的是视频面;画到控制条之上(历史上它在详情页 Compose 层,位于整个 `PlayContainer` 之上)就会:单击照常翻转 `controlsVisible`(遮罩无 `pointerInput`,不拦触摸,**不要**用"加 pointerInput 拦触摸"来修)但用户看不到任何控件、10s 空闲计时可能在加载结束前就把它收掉、且那次不可见翻转会让返回键先被 `onBackPressed` 当"收菜单"消费一次。**两个配套约束**:①弹幕视图在 `view_play_container.xml` 里排在 `surfaceSlot` 之后(位置在控制器之上),遮罩期间必须由 `DanmuLoadController.setOverlayHidden` 收起,否则"黑遮罩上飘上一集的弹幕";②遮罩自带指示器,`PlayerLoadingLayer` 在遮罩期间不得再画(否则叠两层转圈)。⚠️ **遮罩在屏时要让路的浮动层只能逐个加守卫,不要靠调 z 序**:暂停浮层/中央网速/倍速提示/中央控制组彼此重叠,挪层会连带改掉既有叠放(倍速药丸原与中央三键同在屏幕中部、靠叠放顺序避免压字;2026-09-28 三处提示药丸已统一移到屏幕上部,该冲突随之消失,其余浮动层仍照此办理)。⚠️ 遮挡带来的"新可点"动作要复查:`onPlayPauseClicked` 在 `tipVisible && !isInPlaybackState()` 时必须短路(否则解析/失败期点它会 `startPlay()` 按内核里残留的旧地址起播);其余菜单项(刷新/切内核/选集/轨道)已验证为安全(轨道选择全程判空只 Toast)。
-- **中央控制组在遮罩在屏 / PREPARING / BUFFERING 期一律隐藏(2026-09-24 用户定稿「全部都隐藏,解析和取流也隐藏中央三控件」)**:判据收进 `PlayerUiState.centerControlsVisible`(= `controlsVisible && !loadingVisible && !tipVisible && !locked`),`PlayerOverlay.PlayerCenterControls` 只读它。**别按 `playState` 判断"是不是在加载"**:解析/取流期内核状态停在 `IDLE`(在屏标志是 `tipVisible`),只判 `loadingVisible` 会让三键压在黑遮罩上,且中间那颗播放/暂停已被 `onPlayPauseClicked` 短路成死键(错误态同理)。加载期要跳过等待只能绕道:全屏走底栏胶囊的「选集」图标(`episodeBtnVisible` 判可见性),竖屏预览态那行菜单本就不显示,由详情页的选集列表承接。
+- **中央控制组在遮罩在屏 / PREPARING / BUFFERING 期一律隐藏(2026-09-24 用户定稿「全部都隐藏,解析和取流也隐藏中央三控件」)**:判据收进 `PlayerUiState.centerControlsVisible`(= `controlsVisible && !loadingVisible && !tipVisible && !locked`),`PlayerOverlay.PlayerCenterControls` 只读它。**别按 `playState` 判断"是不是在加载"**:解析/取流期内核状态停在 `IDLE`(在屏标志是 `tipVisible`),只判 `loadingVisible` 会让三键压在黑遮罩上,且中间那颗播放/暂停已被 `onPlayPauseClicked` 短路成死键(错误态同理)。加载期要跳过等待只能绕道:全屏走底栏胶囊的「选集」图标(`episodeBtnVisible` 判可见性),竖屏预览态那行菜单本就不显示,由详情页的选集列表承接。**外观(2026-09-28 三轮,现口径)**:中央三键 + 暂停浮层共用 **`PlayerOverlay.CenterControlIcon`** —— 底色一律 `Color.Black × 0.35f`、图标一律盒的 `0.55`;**尺寸/形状分两档**:**左右两颗(上一集/下一集)= 44dp + `ScallopShape()` 扇贝底**(用户「小一点改为44dp,原来的花瓣形状」),**中间那颗(播放/暂停)与暂停浮层 = 48dp + `CircleShape` 圆底**(暂停浮层与中间那颗是同一视觉位,必须一致 —— 用户「暂停时也一样」)。⚠️ **四处只此一份实现,别再各写一份**:当日三连改(先"删除半透明圆形背景容器,只留图标本身"→ 加回并统一 48dp → 左右改 44dp 扇贝),**前两轮"无底色""一律 48dp 正圆"的口径均已作废**,勿按旧结论回改。三键间距仍 `28.dp`。
### 6.2 异步与列表
- **爬虫 `getSearch` / `getDetail` 等阻塞调用必须在 IO 线程**调用(主线程调用 → 卡死 / ANR);并发限流 6、单源 30s 超时(`future.get`)。
diff --git a/skill/history/features.md b/skill/history/features.md
index 230c0de..bdd27aa 100644
--- a/skill/history/features.md
+++ b/skill/history/features.md
@@ -3589,3 +3589,113 @@ echo-exo-player-error: code=ERROR_CODE_UNSPECIFIED, msg=Unexpected runtime error
**验证**:`.\gradlew.bat :app:assembleDebug` + `:app:testDebugUnitTest` 通过(48 类 / 376 用例 / 0 失败);日志里的 `BaseActivity.java` 过时 API 提示是该文件一直用旧式 `SYSTEM_UI_FLAG_*` 的既有 note(本次未新增过时调用)。**未真机验证**,待走查:①横屏全屏下退出→入场,顶栏应不再下滑回弹;②竖屏全屏/贴顶预览的挖孔避让不变;③平板竖屏(≥600dp)与横屏全屏的顶栏位置正常;④全屏切换与回前台的系统栏隐藏、以及那条 100ms 兜底是否够快(若系统露出动画仍在进行中被抢,可回调该常量)。
**未做**:`LiveScreens.kt:191` 直播页顶栏用 `statusBarsPadding()`,同源抖动机制仍在(其顶栏本就整体跟 inset 走,未纳入本次范围)。
+
+## 竖向海报骨架 shimmer「闪回左上角」修复:高光位移按对角线算(2026-09-28,用户"首页影视海报骨架的加载动画不平滑,就是那个涟漪加载到左下角后会突然闪现会右上角" → 追问后更正为"从左上角到右下角,加载到右下角后会突然闪现到左上角,然后继续动画")
+
+**现象**:首页竖向海报骨架(`HomeGridLayout.kt` / `HomePage.kt` 的 `.aspectRatio(2f/3f)`,高 > 宽)上,高光沿对角线扫到右下角后不消失,被无限循环直接切回左上角再扫一遍 —— 肉眼即"闪一下再继续"。同一共享组件的 Hero 骨架(`aspectRatio(1.5f)`,宽 > 高)看不出异常。
+
+**根因**:`ui/components/Skeleton.kt` 的 shimmer 渐变**沿盒子对角线铺**(`end - start = (width, height)`),两个量咬在一起:①图案长度 = 对角线,而**矩形沿对角线方向的投影跨度也恒等于对角线** ⇒ 图案永远正好铺满整个盒子,高光从来不是"一道窄光带"而是整块软渐变,任何时刻都盖在框内;②位移只按宽度算(`startX = -width + 2*width*progress`,共 2×宽),投影到对角线方向只剩 `2w²/对角线`,在 `高 > 宽` 时**短于对角线** ⇒ 峰值在整个周期里都走不出框(200×300 的框:progress 0 时峰值在对角线 19% 处、progress 1 时只到 81%,一圈只走了 222px / 366px)。于是 `infiniteRepeatable`(默认 `RepeatMode.Restart`)把它从右下角硬切回左上角,两端亮度差极大 ⇒ 可见跳变。判据可精确写出:峰值在 progress=0 落到框外需 `|d|/2 - w²/|d| ≤ 0`,即 **`height ≤ width`** —— 这解释了为什么只有竖版海报中招、横版 Hero 正常。
+
+**改动(1 文件)**:`ui/components/Skeleton.kt` —— 位移改为 `travel = 对角线² / 宽`(投影到对角线方向正好 **2×对角线**),抽成 `internal fun shimmerStartX(width, height, progress)`。修正后 progress=0 图案整体停在框外左上、progress=1 整体停在框外右下,且渐变两端颜色都是 `baseColor` ⇒ 循环重启处框内是同一底色,接缝不可见;周期中点(progress=0.5)图案仍正好铺满盒子,原观感不变。判据与宽高比无关,竖版 / 正方形 / 横版一并成立。
+
+**新增单测**:`app/src/test/java/com/github/tvbox/osc/ui/components/ShimmerBandTest.kt`(5 例)—— 锁"progress 0/1 时高光整体在框外"(竖版 / 正方形 / 横版 / 极端 100×560 各跑一遍;**把旧公式代进去会失败**:200×300 时末端 249.7 > 容差 0.5)、"中点图案覆盖整框"、"单向扫过不回弹"、"零尺寸不出 NaN"。这条几何不变量 review 时看不出来(只有竖版才暴露),抽成纯函数才测得到。
+
+**验证**:`.\gradlew.bat :app:assembleDebug` + `:app:testDebugUnitTest` 通过(49 类 / 381 用例 / 0 失败)。**未真机验证**。**待走查**:①竖向海报骨架的高光是否不再闪回 —— 从左上角扫入、右下角扫出后自然淡出;②Hero 与首页横排 3 张骨架的观感是否与修复前一致。**代价 / 待确认**:修复前高光在竖版上"没走完就被切回",现在要用周期的中间一半(≈500ms)走完整个盒子 ⇒ **比修复前快,也比现在的 Hero 快约 1.7 倍**;`durationMillis` 仍是 2026-09-27 定的 1000ms(未擅自改),若偏快调到 1600~1800ms 可让穿过耗时回到修复前 Hero 的水平;想更像"细光条"需把渐变的 start/end 缩短到对角线的一小段,代价是每次扫过之间留一段全暗。**未做**:每个 `SkeletonBox` 各自持有 `rememberInfiniteTransition`,惰性网格里后组合进来的格子相位不同步(既有,与本次无关);全仓仅 `Skeleton.kt` 一处用 `infiniteRepeatable`,无同类问题第二例。
+
+## 播放器:中央三键去底色 + 顶栏返回箭头换 .tubiao 新素材(2026-09-28,用户"将播放器界面中间三个控件的半透明黑色圆形背景容器删除,只留下图标本身,然后将左上角的箭头图标换成.tubiao文件夹里的影视播放器界面的左箭头.svg" + 一张播放器截图)
+
+**改动(3 文件)**:
+
+- `player/ui/PlayerOverlay.kt`:中央三键(`PlayerCenterControls`)的底色整体删除 —— `CenterControlCircle` 改名 `CenterControlIcon`、去掉 `shape: Shape = CircleShape` 形参,`.background(Color.Black.copy(alpha = 0.35f), shape)` 整行删除;**触摸盒(`.size(48.dp)`/中间 `60.dp`)与 `pointerInput` 原样保留**(点按热区与点击行为零变化),图形仍为盒的 `0.55`;三颗调用点的 `shape = ScallopShape()` 两处删除(上一/下一集原为扇贝形底)。连带清掉三个因此不再使用的 import(`CircleShape`/`Shape`/`ScallopShape`)。
+- `res/drawable/player_ic_back.xml`:**内容整体替换**为用户新素材(`.tubiao/影视播放器界面的左箭头.svg`,Material Symbols 的粗 chevron,单 path、`viewBox="0 -960 960 960"`)→ 按本仓约定转 `viewportWidth/Height=960` + `` 平移负坐标 + `fillColor="#FFFFFFFF"`(用点 `PlayerTopBar.kt:100` 是 `Image(painter=)` 不走 tint,故白色写进 drawable)。**选择覆盖而非新建**:该 drawable 全仓仅 `PlayerTopBar.kt:100` 一处引用,同一语义位直接换字形,不留废弃文件(与 `ic_switch_repo` 那种"两个消费者要各自可调"的情形不同)。
+- `ui/components/ScallopShape.kt`:KDoc 首行由"音乐播放器跳播键与播放器上一/下一集共用"改为"音乐播放器的跳播键在用"(类未删,`MusicPlayerScreen` 仍在用)。
+
+**验证**:`.\gradlew.bat :app:assembleDebug` + `:app:testDebugUnitTest` 通过(49 类 / 381 用例 / 0 失败);Kotlin 无新增告警。**未真机验证**,待走查:①中央三键只剩白图标压在画面上(亮画面下可读性)、三键间距 28dp 在无底色后的观感(两颗相邻图标的视觉留白会显得比原来大,若嫌空需调 `Arrangement.spacedBy`);②新返回箭头在 24dp 下的视觉大小 —— 该 chevron 字形只占画布 x 297→676(**约 39.5% 宽**)、y -844→-101(约 77% 高),24dp 盒里可见部分约 **9.5×18.5dp**,比被替换的 `arrow_back`(约 16×16dp)**明显更窄**,若要求视觉等需把 `Modifier.size(24.dp)` 提到 32~36dp(或换更宽的字形);③顶栏返回钮 36dp 触摸盒未动。
+
+### 追加:中央三键去底色**回退** + 统一 48dp 圆底(2026-09-28,用户"算了还是加回半透明的圆形背景容器吧,圆形尺寸48dp,暂停时也一样")
+
+**净效果**:中央三键 + 暂停浮层 = 同款 48dp 半透明黑圆 + 白图标,四处等大。
+
+- `player/ui/PlayerOverlay.kt`:恢复 `.background(Color.Black.copy(alpha = 0.35f), CircleShape)`,并把该组件由 private 提为 **`internal fun CenterControlIcon(icon, label, onClick)`** —— 尺寸(48dp)、图形占比(0.55)、底色 alpha 全部收进函数体,三颗调用点不再各传尺寸(上一轮为此加的 `box: Dp` 形参随之删掉)。**中间那颗由 60dp 收到 48dp**,与左右两颗等大;上一/下一集**不再用 `ScallopShape()`**(此次口径是"圆形",故一律 `CircleShape`)。
+- `player/ui/PlayerLayers.kt`:`PlayerPauseLayer`(暂停浮层,原先自绘 60dp 圆底 + 同一 alpha)改为 `Box(fillMaxSize, contentAlignment = Center) { CenterControlIcon(play 图标, common_play, actions::onPlayPauseClicked) }` —— **与中央三键同一组件**,尺寸 60→48dp,**行为零变化**(点按续播、退后台暂停与遮罩在屏时仍不显示);连带删掉该文件里因此不再使用的 `CircleShape` import。两个文件同包,无需新增 import。
+
+**未做 / 已知后果**:①`ScallopShape` 自此只剩音乐播放器一个消费者(类保留),播放器里不再有扇贝边;②中央键触摸盒由 60dp 缩到 48dp(仍是 Material 最小触摸尺寸);③三键间距 `28.dp` 未动。
+
+**验证**:`.\gradlew.bat :app:assembleDebug` + `:app:testDebugUnitTest` 通过(49 类 / 381 用例 / 0 失败);Kotlin 无新增告警(仅既有的 `kotlin.android` 插件弃用提示)。**未真机验证**,待走查:①底栏唤出时三颗圆底是否等大 48dp、中间那颗与暂停浮层那颗(点暂停出现的那颗)是否同款同大;②中间那颗由 60→48dp 后与左右两颗并排的观感(比原来更"平"了);③暂停态若同时按下"控件可见 + 暂停浮层在屏",两者在屏幕正中同尺寸重合,视觉上应无感(改前两者也都是 60dp 且同色,该重合是既有的)。
+
+### 追加 2(取代上方"追加"里的"统一 48dp 圆底"):左右两颗改 44dp + 恢复扇贝底(2026-09-28,用户"下一集和上一级控件的半透明圆形背景容器小一点改为44dp,原来的花瓣形状")
+
+**改动(2 文件)**:
+
+- `player/ui/PlayerOverlay.kt`:`CenterControlIcon` 重新开出声参 **`box: Dp = 48.dp` / `shape: Shape = CircleShape`**(默认档 = 中间那颗与暂停浮层),上一/下一集两颗显式传 `box = 44.dp, shape = ScallopShape()` ⇒ **左右 44dp 扇贝底、中间 48dp 正圆底**;图标仍按盒的 `0.55` 等比 ⇒ **左右两颗的图标由 26.4dp 缩到 24.2dp**。补回 `Shape` / `ScallopShape` 两个 import。
+- `ui/components/ScallopShape.kt`:KDoc 退回"音乐播放器跳播键与播放器上一/下一集共用"(上一轮刚改成"仅音乐播放器",现又回到两处共用;该文件内容因此与 HEAD 完全一致,不再出现在改动清单里)。
+
+**未做 / 已知后果**:①左右两颗的触摸盒随圆底缩到 **44dp**(低于 Material 建议的 48dp;若要"圆底 44dp、触摸盒仍 48dp",得把该组件的盒与圆底拆成两个参数);②三键间距 `28.dp` 未动 —— 左右两颗变小后,与中间那颗之间的视觉留白会比等大时略增。
+
+**验证**:`.\gradlew.bat :app:assembleDebug` + `:app:testDebugUnitTest` 通过(49 类 / 381 用例 / 0 失败);Kotlin 无新增告警。**未真机验证**,待走查:①左右两颗是否比中间那颗明显小一圈且带扇贝边(44dp 扇贝 vs 48dp 正圆)、一眼能看出"两颗小跳集键夹一颗大播放键";②暂停浮层那颗仍与中间那颗一致(48dp 正圆);③44dp 下点击是否仍好按。
+
+### 追加 3:清掉本轮改动处的注释(2026-09-28,用户"把注释删掉包括已经存在的")
+
+删的是**本轮改动所及元素**上的注释共 4 处:`PlayerOverlay.PlayerCenterControls` 与 `CenterControlIcon` 的 KDoc、`PlayerLayers.PlayerPauseLayer` 的 KDoc、`PlayerTopBar` 返回箭头那行内联注释、`ScallopShape` 类头 KDoc(删后该文件已无任何注释;删掉的这几处内容在活规范 §128/§372/§373 与 history 396–401 都有同源记录,不丢信息)。
+
+**刻意留下**(属"不写会再踩"的就地防卫,也是规范里点名过的):`PlayerOverlay` 文件头(层级顺序 + ⚠️ 遮罩必须最底)、`playerMmScale`(⚠️ 别改回 `getDimension*()` + 方向补偿)、`rawMm`(⚠️ 必须 `complexToFloat`)、`playerTextSize`(非负保证)、`PlayerMenuButton`/`PlayerPillIconButton`/`playerIconBox`(图标盒钳制)、`PlayerTipLayer`(遮罩画在控制栏之前)、`PlayerTopBar` 的安全区分档与左右块标记。**要连这些一起清需用户明说**(会丢的就地信息见上表,规范里只有部分同源)。
+
+## 播放器三处细节:预览态分辨率胶囊 / 顶栏左右首行对齐 / 预览态横滑进度(2026-09-29,用户"图一竖屏影视详情页面的预览态不要在右下角显示胶囊分辨率,只要在计入横屏观看或者竖屏观看时才显示……图二右上角的电量时间这些信息的高度要对齐左边的影视标题……竖屏影视详情页面能否支持手势控制调整进度")
+
+**现象与判定(用户 2800×1260 真机截图两张,像素扫描而非目测)**:
+
+- **分辨率胶囊**:预览态与横屏全屏态的胶囊字形带**完全同尺寸**(高 42px),即同一颗 `VideoSizePill` 在两态都画 —— 它挂在 `if (!state.previewMode)` **之外**,与时间胶囊不同守卫。用户诉求 = 只进横屏/竖屏观看看时显示。
+- **右上角对齐**:两侧字形带中心 —— 片名(`ts_24` CJK)`y=110px`,右侧「78% 🔋 23:42」(`ts_20` 数字)`y=94.5px`,**差 15.5px**;竖屏预览态同一位置的差值同为 15.5px,证明是布局性偏差而非字体差异。根因:左块片名在**返回箭头 36dp 触摸盒内居中**(内容盒中心 = `36/2 + vs_5/2` = 19.56dp),右块首行却从 Column 顶起算(内容盒中心 = `vs_5` + 24dp/2 = 15.1dp),差的正是 (36 − 27.1)/2 ≈ 4.4dp = 15.5px。
+- **预览态手势**:`ComposeVideoController.onScroll` 开头有 `if (previewMode) return true`,把预览态全部滑动一次性吃掉;而 `canSlide` 本身是 true(`PlayContainer.initView` 早已 `setEnableInNormal(true)`,配 `setPlayerState(PLAYER_NORMAL)`)⇒ **不是能力缺失,只是被显式拦掉**。
+
+**改动(3 文件)**:
+
+- `player/ui/PlayerBottomBar.kt`:`VideoSizePill` 移进 `if (!state.previewMode)` 块(与时间胶囊同一守卫),预览态整行不画。
+- `player/ui/PlayerTopBar.kt`:新增 `private val TopBarLineHeight = 36.dp`,同时用于①返回箭头触摸盒 ②右块首行 `Row(modifier = Modifier.height(TopBarLineHeight))` ⇒ 两侧首行内容盒中心都 = `36/2 + vs_5/2`,必然对齐,且**与字号/窗口 mm 缩放无关**(两侧都是"在同一个 36dp 盒里居中")。
+- `player/controller/ComposeVideoController.kt`:`onScroll` 删掉开头的 `if (previewMode) return true`,改在 `!changePosition`(竖向)分支里 `if (previewMode) return true` ⇒ 预览态只放行横滑进度,亮度/音量与长按倍速维持旧行为;链路其余部分(预览态 `onTouch` 直通 gestureDetector、`onTouchEvent` 的 ACTION_UP 提交 `mSeekPosition`、`PlayerSeekHint` 药丸)都是现成的,未新建。
+
+**同日追加:删掉本轮改动所及处的注释** —— `onScroll` 里本轮新增的那行与紧邻的「禁用手势控制」行、`onTouch` 预览态分支的三行、`PlayerBottomBar` 胶囊行的块标记(共 6 行;所涉事实已在 `history/steps.md` 的「详情页透明点击层已移除」、活规范「禁用手势控制」与底栏胶囊条留档,不丢信息)。
+
+**未做 / 已知后果**:①预览态竖向滑动(亮度/音量)与长按倍速仍不响应(用户只提进度);②预览态横滑起手仍受 `PlayerUtils.isEdge` 限制(**四边各 40dp 内按下不触发任何滑动**),竖屏预览高度 ≈171dp ⇒ 起手可用带从距顶 40dp 起,属既有约束未动;③36dp 这个盒高只在 `PlayerTopBar` 内收成一个 val,未提到跨文件常量。
+
+**验证**:`.\\gradlew.bat :app:assembleDebug` + `:app:testDebugUnitTest` 通过(49 类 / 381 用例 / 0 失败);已装真机(vivo `10AF1J04JX0016G`,`lastUpdateTime` 核对)。**未真机走查**,待走查:①预览态唤出底栏 —— 右下角应只剩「播放钮 + 当前时间 - 进度条 - 总时长」、无分辨率胶囊;点全屏(横屏 / 竖屏视频)或进播放页后胶囊照常出现;②横屏全屏顶栏:「78% 🔋 23:42」与片名应在同一水平线上(改前右侧高约 4dp);③竖屏预览内横滑:应出现顶部药丸提示(快进/快退图标 + 目标时间),松手后进度跳过去;竖滑应无任何反应(与改前一致)。
+
+## 搜索框:键盘收起时清焦点,收掉残留的「粘贴」浮层(2026-09-29,用户"在搜索框长按出现粘贴,但是我无法通过侧边滑动屏幕边缘退出手势的方式让这个粘贴消失……键盘消失了,这个粘贴还停留在上面,我只有输入新的文字才能消失,这是bug吗?")
+
+**定性(先给结论,未直接动手)**:不是我们改出来的回归,是**平台层行为 × 应用侧没兜住** —— ①那个「粘贴 ▶」气泡是**文本编辑器的浮动工具栏**(插入态 ActionMode,独立窗口),与键盘无关;②Android 13+ 的返回事件**先派发给输入法**,所以侧滑那一下页面侧收不到、无法即时反应;③该工具栏只在「改文 / 选区变化 / 输入框失焦」时结束 ⇒ 点空白处也不掉、只有打字才消失(与用户观察一致)。收口权限在应用侧(能清焦点),故可修。用户选方案 A(只修搜索框)。
+
+**改动(1 文件)**:`ui/components/SearchField.kt` —— 组件内读 `WindowInsets.isImeVisible`,在「显示→隐藏」跳变且本输入框仍有焦点(`onFocusChanged` 记录)时 `focusManager.clearFocus()`。
+
+**取舍(为什么不是另外两条路)**:①不用 `LocalTextToolbar.status` 做条件 —— 该状态是否覆盖「光标态(空框)的粘贴浮层」取决于 Compose 版本,判定失败就是**静默不修**(最差结果);②不只 `hideSoftInputFromWindow` —— 键盘与工具栏是两个窗口,收键盘不结束工具栏。代价 = **任何**收键盘场景都会让输入框失焦(光标不再闪,再点一下即恢复);范围 = 只覆盖 `SearchField` 的两个调用点(搜索页顶栏 / 首页订阅源 sheet),配置管理页、弹幕/字幕搜索、播放参数面板等输入框仍有原现象。
+
+**验证**:`.\\gradlew.bat :app:assembleDebug` + `:app:testDebugUnitTest` 通过(49 类 / 381 用例 / 0 失败);已装真机(`lastUpdateTime` 核对)。**未真机走查**,待走查:①搜索框长按出「粘贴」→ 侧滑返回:气泡应与键盘一起消失;②收键盘后光标不再闪、点一下输入框恢复输入;**③反面检查:若浮层仍残留**(说明该气泡不随失焦结束),下一步在清焦点之后再补一次 `LocalTextToolbar.hide()`,回报即可;④正常"输入 → 提交搜索"链路不受影响;⑤首页订阅源 sheet 的搜索框表现一致。
+
+## 搜索页「闲置区 → 结果区」过渡动画:重开一次(带"仅页内发起的搜索才播"闸门)(2026-09-29,用户"能否在搜索页面添加跳转至搜索结果页面的动画效果,首页点击影视海报进入的搜索页面则不需要,因为已经有 activity 跳转动画了,动画该怎么实现什么效果呢" → 给出 A/B 两案后用户选 A)
+
+**过程教训(提案前提)**:给出方案前只读了搜索页附近几段,没读到 §4.6 那条**2026-09-21 的定稿**「内容分区不做过渡动画(两版 AnimatedContent 都被用户以"效果不好 + 卡顿"整体删除)」—— 方案 A 基本就是当年被否的第一版(fade+slide+spring)。落地方案时才发现;补救 = 保留本次实现(用户当天明确要求"试试"),同时把新旧口径的差别与回退路径写回规范。
+
+**这次能重开的实质差别**:用户新给的约束"首页海报菜单带标题进页那条不要动画"恰好把当年那版的最大疑点去掉 —— 那版对**带标题进页的自动搜索**也播过渡,与 Activity 转场叠在一起(进页先看转场、紧接着内容又来一次位移/淡入),这极可能就是当时"效果不好"的一半原因。为此本次把过渡挂在**触发源**上而不是阶段上:`animateStage` 默认 false,只有 `submit()`(键盘搜索键 / 点历史 / 点热搜 / 点联想)置 true ⇒ 页面首帧与自动搜索那条天然不播。**"两棵全屏子树同时在场 ⇒ 叠绘"的卡顿风险未消除**,故仍以硬切为回退口径。
+
+**改动(1 文件)**:`ui/activity/SearchScreen.kt` —— ①新增 `private enum class SearchStage { Empty, Idle, Results }` 与三个过渡参数常量(`STAGE_FADE_IN_MS = 220` / `STAGE_FADE_OUT_MS = 150` / `STAGE_RISE_DP = 16`);②`var animateStage by remember { mutableStateOf(false) }`,只在 `submit()` 里置 true;③原来那段 `if/else` 收进 `AnimatedContent(targetState = stage, transitionSpec = if (!animateStage) None togetherWith None else (fadeIn(220ms) + slideInVertically(spring(StiffnessMedium), 16dp)).togetherWith(fadeOut(150ms)), modifier = Modifier.fillMaxSize())`,三分支改 `when` 且入参不变。`SearchViewModel` / `SearchIdleScreens` / `SearchListScreens` 未动。
+
+**未做**:①没做"结果淡入、闲置区不动"(少一次叠绘)的变体;②没测低端机;③`animateStage` 一旦置位不复位 —— 当前不存在"用户发起之后又需要静默切换"的路径,若将来有(如自动换源触发的重搜),要在那里显式复位。
+
+### 追加:过渡期间不拉网络(2026-09-29,用户"感觉有点掉帧,是否应该等动画播完再搜索" → 认可该方向后"修改试试")
+
+**定性**:掉帧有三个来源 —— ①**过渡期间结果区正被网络回填**(逐源到达 ⇒ 结果区重排 + 卡片合成 + Coil 取图);②两棵全屏子树叠绘(09-21 被否的那条理由);③液态玻璃顶栏的 backdrop 在内容移动时每帧重采。用户的建议只治 ①,但那是最大也最容易去的一块;**②③未消除**,故回退口径不变。另给了一个不用改代码的对照实验判据:结果页切「竖排 / 横排」那段动画(此前保留的)若同样掉帧 ⇒ 卡在 ②/③,该回退;若它顺、只有 idle→结果 卡 ⇒ 就是 ①。
+
+**改动(2 文件)**:①`SearchViewModel.search(title, startDelayMs = 0L)` —— **占位仍同步落库**(`results` = 各源 Pending + `running = true`),网络 fan-out 在 `scope.launch` 里先 `delay(startDelayMs)` 再发;token 在同一函数同步捕获 ⇒ 延后发起的批次仍被 `myToken != token` 守卫,不会与新搜索打架。②`SearchScreen`:`submit()` 传 `SEARCH_START_DELAY_MS = 200L`(≈ 入 180ms);过渡时长由 出 150 / 入 220 压到 **出 120 / 入 180**。带标题进页的自动搜索不传延时(即时)。
+
+**为什么不让 UI 侧"先设 pending、动画后再调 search()"**(最初的写法):那样动画期间 `results` 仍为空,结果区只剩「全部」一颗 rail 项 + 波浪线(观感更空),还要额外维护 pending 状态并在结果区覆盖 `running`;"占位立即 + 网络延后"这套则**动画首帧就有完整源栏**,UI 侧一行都不用加。
+
+**未做**:①没测低端机;②没做"结果回填攒到动画后一次性放"的 VM 缓冲变体(启动延迟更小但更重,若 200ms 起搜延迟不可接受再走);③没动 ②③(叠绘 / 玻璃)。
+
+### 回退:不要过渡动画了(2026-09-29,用户"全部回退吧不要动画效果了")
+
+**决定**:试完「延后网络」那一版后用户仍判掉帧,要求整段回退。**已按原样还原 2 文件**:`SearchScreen.kt`(删掉 `SearchStage` 枚举、三个过渡参数常量、`animateStage`、`AnimatedContent` 包裹与四个新增 import,恢复 `if/else` 硬切换)、`SearchViewModel.kt`(`search(title)` 去掉 `startDelayMs` 与 `delay` 导入及其 KDoc 行)。**保留不动**:结果区「竖排 / 横排」切换动画(`SearchResultsContent` 内的 `AnimatedContent`)、搜索记录卡片内部的过渡(2026-09-26)。
+
+**结论(两次被否的共同点,别再提第三版)**:搜索页不换 Activity + 两棵全屏子树叠绘 + 液态玻璃顶栏 backdrop 逐帧重采 —— 这三项成本不是靠"错开网络 / 压时长"能消掉的;`SearchScreen` 的 idle↔results 保持硬切。若哪天真要做,前提是先解决玻璃/子树,而不是再调参数。
+
+**验证**:`.\\gradlew.bat :app:assembleDebug` + `:app:testDebugUnitTest` 通过(49 类 / 381 用例 / 0 失败);残留检查无 `SearchStage`/`animateStage`/`SEARCH_START_DELAY_MS`/`startDelayMs`/`slideInVertically`/`LocalDensity` 等标识符残留;已装机(`lastUpdateTime` 核对)。**未真机走查**,待走查:页内提交应立刻出结果区(与改动前一致)、无任何内容过渡。
+
+**验证**:`.\\gradlew.bat :app:assembleDebug` + `:app:testDebugUnitTest` 通过(49 类 / 381 用例 / 0 失败);已装真机(`lastUpdateTime` 核对)。**未真机走查**,待走查:①页内提交(键盘搜索键 / 点历史词 / 点热搜词):闲置区淡出、结果区自下上浮 16dp 淡入,顶部波浪线随之出现;②首页海报长按菜单 → 搜索:进页**不应**再出现内容二次动画(只有 Activity 转场);③连打多个搜索:只刷新内容、不重播过渡;④真机观感 + 帧率 —— 若仍"卡顿 / 不好看",按 §4.6 原口径把这段整段回退(deleting `AnimatedContent` + `animateStage` + 枚举与常量即回到硬切)。