diff --git a/AGENTS.md b/AGENTS.md index 04d8b80..b571c1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -103,7 +103,7 @@ $BT/apksigner verify 目标名.apk ### 版本号 -- `app/build.gradle`:`versionName "1.3.2"` / `versionCode 8` +- `app/build.gradle`:`versionName "1.3.3"` / `versionCode 9` - App 内显示统一用纯版本号(不加 `(versionCode)` 括号) ## 三、关键实现约束(避免踩坑) @@ -134,6 +134,17 @@ $BT/apksigner verify 目标名.apk 客户端为 `MiliLog`(有界队列 + 后台线程 + 失败重试),调用点 `MiliLog.info/notice/warn/error` 保持不变。启动上报(`reportDeviceBootInfo()`)只采集这 10 项:包名、版本号、安装时间、品牌、型号、 设备名、Android 版本、CPU 架构、内核版本、屏幕分辨率 +- **焦点填充与「纯色遮罩层」**:`TvFocusStyler.install()` 会把背景整体换成**不透明**红→橙渐变, + 作为遥控器焦点/按下的反馈(`prepareTvFocusableTree(window.decorView)` 与对话框的 + `installTree(decorView)` 会遍历整棵树施加)。按钮/Tab/卡片/list 行需要这个反馈;但 + **大面积展示型表面绝不能加** —— 否则触摸一次(`state_pressed`)就用纯色盖住整屏内容。 + 已排除的表面:播放画面宿主(`home_player_container` / `player_container`)、视频层 + (`persistentVideo`)、播放触摸层(`playerTouchOverlay`)、全屏播放容器(`R.id.container`)、 + 启动加载遮罩(`databaseLoadingOverlay`)、手机点歌二维码浮层、全屏音量面板。 + **新增此类铺满/覆盖播放画面的 View 时,必须调用 `TvFocusStyler.suppressFill(view)`** +- **音量默认值**:`KtvStore.DEFAULT_MUSIC_VOLUME`(当前 85%)是唯一来源, + `KtvStore` 默认值与 `optInt` 兜底、`MainActivity.musicVolume` 初值、调音弹窗「默认」按钮 + 都引用它;远程页面 `assets/mobile/{index.html,app.js}` 的默认值需手工跟改 - **音量控制(只控 App 输出,不动系统音量)**:`musicVolume`(0-100) 是 **App 内部输出音量**, 统一在 `applyPlaybackMode()` 内按线性系数施加到播放器(`KtvVideoView.setPlaybackVolume` / `selectAudioChannel`)与副音轨播放器(`vocalPlayer` / `embeddedVocalEngine`)。 diff --git a/app/build.gradle b/app/build.gradle index 8018bb1..1073789 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -11,8 +11,8 @@ android { applicationId "com.local.ktv" minSdk 21 targetSdk 25 - versionCode 8 - versionName "1.3.2" + versionCode 9 + versionName "1.3.3" ndk { // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 diff --git a/app/src/main/assets/mobile/app.js b/app/src/main/assets/mobile/app.js index 4ebd224..4df24c7 100644 --- a/app/src/main/assets/mobile/app.js +++ b/app/src/main/assets/mobile/app.js @@ -72,9 +72,9 @@ async function refresh() { $("#playButton span:last-child").textContent = data.playing ? "暂停" : "继续"; $("#playButton").dataset.action = data.playing ? "pause" : "resume"; $("#playButton .control-icon").textContent = data.playing ? "Ⅱ" : "▶"; - $("#musicVolume").value = data.musicVolume ?? 70; + $("#musicVolume").value = data.musicVolume ?? 85; $("#micVolume").value = data.micVolume ?? 70; - $("#musicValue").textContent = data.musicVolume ?? 70; + $("#musicValue").textContent = data.musicVolume ?? 85; // 麦克风音量由功放/混音器硬件调节(滑杆已禁用), 仅保留显示值。 const micHw = $("#micValue"); if (micHw) micHw.textContent = data.micVolume ?? 70; diff --git a/app/src/main/assets/mobile/index.html b/app/src/main/assets/mobile/index.html index df7d251..e06c007 100644 --- a/app/src/main/assets/mobile/index.html +++ b/app/src/main/assets/mobile/index.html @@ -36,7 +36,7 @@
♪声音控制
即时生效
- +
diff --git a/app/src/main/java/com/local/ktv/KtvStore.kt b/app/src/main/java/com/local/ktv/KtvStore.kt index 9c95288..7365ef1 100644 --- a/app/src/main/java/com/local/ktv/KtvStore.kt +++ b/app/src/main/java/com/local/ktv/KtvStore.kt @@ -15,7 +15,7 @@ class KtvStore { @JvmField var autoNext = true @JvmField var originalVocal = true @JvmField var vocalChannelMode = "自动" - @JvmField var musicVolume = 70 + @JvmField var musicVolume = DEFAULT_MUSIC_VOLUME @JvmField var micVolume = 70 @JvmField var tone = 0 @JvmField var atmosphere = "标准" @@ -64,7 +64,7 @@ class KtvStore { autoNext = root.optBoolean("autoNext", true) originalVocal = root.optBoolean("originalVocal", true) vocalChannelMode = root.optString("vocalChannelMode", "自动") - musicVolume = root.optInt("musicVolume", 70) + musicVolume = root.optInt("musicVolume", DEFAULT_MUSIC_VOLUME) micVolume = root.optInt("micVolume", 70) tone = root.optInt("tone", 0) atmosphere = root.optString("atmosphere", "标准") @@ -203,6 +203,14 @@ class KtvStore { private fun toggleId(ids: MutableSet, id: String): Boolean = if (ids.remove(id)) false else ids.add(id) companion object { + /** + * App 内部输出音量默认值(%)。 + * + * 三处音量入口(播放界面-调音、声音设置-歌曲最大音量、远程控制-音乐音量)共用它; + * 远程页面 `assets/mobile/{index.html,app.js}` 里的默认值需与之保持一致。 + */ + const val DEFAULT_MUSIC_VOLUME = 85 + @JvmStatic fun stableId(song: Song): String = song.id?.takeIf(String::isNotEmpty) ?: song.path?.takeIf(String::isNotEmpty) diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index 025a844..dcd6151 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -376,7 +376,7 @@ class MainActivity : AppCompatActivity() { } private var originalVocal = true private var vocalChannelMode = "自动" - private var musicVolume = 70 + private var musicVolume = KtvStore.DEFAULT_MUSIC_VOLUME private var micVolume = 70 private var tone = 0 private var atmosphere = "标准" @@ -2423,8 +2423,11 @@ class MainActivity : AppCompatActivity() { val panel = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER_HORIZONTAL + // 仅用于吞掉触摸(点击穿透到全屏容器会误切控制层显示), 不是操作项; + // 因此禁止焦点/按下填充, 否则触摸时整块面板会变成纯色。 isClickable = true } + TvFocusStyler.suppressFill(panel) val indicator = CircularVolumeView(this).apply { setVolume(musicVolume) visibility = View.GONE @@ -2675,9 +2678,9 @@ class MainActivity : AppCompatActivity() { btnDefault.setOnClickListener(View.OnClickListener { v: View? -> seekMicVol.setProgress(60) seekMicEffect.setProgress(50) - seekMusicVol.setProgress(80) + seekMusicVol.setProgress(KtvStore.DEFAULT_MUSIC_VOLUME) micVolume = 60 - musicVolume = 80 + musicVolume = KtvStore.DEFAULT_MUSIC_VOLUME applyPlaybackMode() saveState() tvMicVol.setText("60") @@ -3141,6 +3144,8 @@ class MainActivity : AppCompatActivity() { bottomMargin = dp(76) } root.addView(box, params) + // 二维码浮层是展示型表面: 被焦点填充盖住会直接遮掉二维码。 + TvFocusStyler.suppressFill(box) mobileQrOverlay = box } @@ -3179,6 +3184,8 @@ class MainActivity : AppCompatActivity() { } container.addView(box, params) box.bringToFront() + // 同手机点歌二维码: 展示型表面不做焦点填充。 + TvFocusStyler.suppressFill(box) fullScreenQrOverlay = box } @@ -6631,6 +6638,8 @@ class MainActivity : AppCompatActivity() { rankCategoryGrid = findViewById(R.id.rank_category_grid) paginationBar = findViewById(R.id.pagination_bar) fullScreenContainer = findViewById(R.id.container) + // 全屏播放容器: 点击用于显隐控制层, 本身是铺满屏幕的表面, 不能有焦点/按下填充。 + fullScreenContainer?.let { TvFocusStyler.suppressFill(it) } // 设置顶栏按钮事件 if (btnTopSearch != null) btnTopSearch!!.setOnClickListener(View.OnClickListener { v: View? -> showQuickSearchPage() }) @@ -6670,6 +6679,8 @@ class MainActivity : AppCompatActivity() { if (isFullScreen) exitFullScreenPlayer() else showFullScreenPlayer() } } + // 视频层本身也是表面型 View, 只做点击进入/退出全屏, 不要焦点填充。 + TvFocusStyler.suppressFill(persistentVideo) val root = playerLayerRoot if (root != null) { val maskIndex = root.indexOfChild(findViewById(R.id.mask)) @@ -6719,6 +6730,10 @@ class MainActivity : AppCompatActivity() { isFocusable = false setOnClickListener { showFullScreenPlayer() } elevation = dp(10).toFloat() + // 这层铺满播放画面且浮在最上层: 一旦被套上焦点/按下填充, + // 触摸就会把整块播放画面刷成纯色(遮罩层), 必须显式排除。 + // 它只负责抓触摸, 保持 isFocusable=false, 不参与遥控器焦点。 + TvFocusStyler.suppressFill(this, focusable = false) }.also { root.addView(it, insertionIndex + 1, FrameLayout.LayoutParams(1, 1)) } } homeVideo = persistentVideo @@ -6765,7 +6780,9 @@ class MainActivity : AppCompatActivity() { homeCardRank, homeCardSong, homeCardSinger, homeCardLocal, homeCardRegular, homeCardFavorite, homeCardCategory, ).forEach(::installPressFeedback) - listOfNotNull(homePlayerHost, subPagePlayerHost, homePoster).forEach(TvFocusStyler::preserveBackground) + // 播放画面宿主/海报是「表面型」View: 不能套焦点/按下填充, + // 否则触摸一次就会用纯色层盖住整个播放画面或海报(见 TvFocusStyler.suppressFill)。 + listOfNotNull(homePlayerHost, subPagePlayerHost, homePoster).forEach(TvFocusStyler::suppressFill) (homePoster as? FrameLayout)?.foreground = getDrawable(R.drawable.bg_player_focus) configureTvFocusNavigation() prepareTvFocusableTree(window.decorView) @@ -8571,6 +8588,8 @@ class MainActivity : AppCompatActivity() { isFocusable = true elevation = dp(30).toFloat() } + // 启动加载遮罩铺满整屏: 不允许被焦点/按下填充覆盖, 否则点击启动界面会整屏变纯色。 + TvFocusStyler.suppressFill(overlay) val center = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL gravity = Gravity.CENTER diff --git a/app/src/main/java/com/local/ktv/TvFocusStyler.kt b/app/src/main/java/com/local/ktv/TvFocusStyler.kt index b9ad282..e62b29e 100644 --- a/app/src/main/java/com/local/ktv/TvFocusStyler.kt +++ b/app/src/main/java/com/local/ktv/TvFocusStyler.kt @@ -13,13 +13,26 @@ import java.util.WeakHashMap /** Applies the original OTT red-to-orange focus fill to remote-control targets. */ object TvFocusStyler { private val installed = Collections.newSetFromMap(WeakHashMap()) - private val preservedBackgrounds = Collections.newSetFromMap(WeakHashMap()) + private val fillSuppressed = Collections.newSetFromMap(WeakHashMap()) - /** Preserve image/video backgrounds while suppressing Android's grey focus halo. */ - fun preserveBackground(view: View) { - preservedBackgrounds.add(view) + /** + * 禁止对该 View 施加焦点/按下填充 —— 用于「大面积展示型表面」。 + * + * 背景:[install] 会把背景整体替换成不透明的红→橙渐变,作为遥控器焦点/按下的视觉反馈。 + * 这对按钮、Tab、卡片是期望效果;但对铺满屏幕或覆盖播放画面的表面型 View + * (播放器宿主、视频层、播放触摸层、全屏播放容器、启动加载遮罩、二维码浮层)就是灾难: + * 只要触摸一次(`state_pressed`)就会用一个纯色层盖住整屏内容,即「纯色遮罩层」。 + * + * 调用后该 View 保留自身背景且不再被 [install] 处理,平台默认灰色高亮也被关闭。 + * + * @param focusable 是否顺带让该 View 可被遥控器聚焦(默认 true,与旧的 [install] 行为一致); + * 纯触摸捕获层(如 `playerTouchOverlay`)应传 false,否则会抢走遥控器焦点 + * 且自身没有任何焦点视觉反馈。 + */ + fun suppressFill(view: View, focusable: Boolean = true) { + fillSuppressed.add(view) disablePlatformHighlight(view) - if (view.isClickable && view.isEnabled && view.visibility == View.VISIBLE) { + if (focusable && view.isClickable && view.isEnabled && view.visibility == View.VISIBLE) { view.isFocusable = true view.isFocusableInTouchMode = false } @@ -37,9 +50,12 @@ object TvFocusStyler { private fun install(view: View, force: Boolean) { disablePlatformHighlight(view) if (!view.isEnabled || view.visibility != View.VISIBLE || !view.isClickable) return + // 显式标记为「表面型」的 View: 既不填充背景, 也不替调用方强制可聚焦 + // (例如 playerTouchOverlay 刻意要求 isFocusable=false, 否则会抢走遥控器焦点)。 + if (!force && fillSuppressed.contains(view)) return view.isFocusable = true view.isFocusableInTouchMode = false - if (!force && (preservedBackgrounds.contains(view) || view.background?.isStateful == true)) return + if (!force && view.background?.isStateful == true) return if (!installed.add(view) && !force) return val normal = view.background ?: ColorDrawable(Color.TRANSPARENT) val normalShape = normal as? GradientDrawable