V1.3.6: 「调音」与「扫码点歌」弹窗分辨率自适应

问题: 两个弹窗都用固定 dp 尺寸 + WRAP_CONTENT 高度, 在矮屏(720p@1.5≈480dp 高、
1080p@2.0≈960dp 等)上会被屏幕裁掉, 底部内容(频段滑杆/二维码说明)看不到。

修复:
- 新增通用 helper fitDialogToScreen(dialog, scroll, content, preferredWidthDp):
  宽度 = min(首选, 屏幕宽*0.94); 高度不再用 WRAP_CONTENT, 而是把内容所在的 ScrollView
  限制为 屏幕高*0.92 - 弹窗标题/按钮框架高度。先按最终宽度量一次内容原始高度,
  放得下就紧凑显示, 放不下就在弹窗内滚动 —— 任何分辨率下都不会被裁切。
- 调音弹窗: 按屏幕高度算出内容预算, 把剩余空间给 EQ 频段区并夹在 112~216dp,
  同时矮屏(高度<620dp)压缩刻度区宽度/标签/预设行高度; 输出自适应日志便于现场核对。
- 扫码点歌弹窗: 二维码边长按 屏幕高*0.42 / 屏宽*0.62 自适应并夹在 160~220(矮屏)/280dp,
  位图按最终显示尺寸生成保证清晰; 文案宽度改为 MATCH_PARENT, 内容包进 ScrollView。
- 声音设置弹窗同样接入该 helper(同一类裁切风险)。

版本号 1.3.5 -> 1.3.6(versionCode 11 -> 12)。按约定本次不发布 Release, 仅本地打包测试。
This commit is contained in:
zijunchiang
2026-09-20 15:51:52 +08:00
parent ce57068c68
commit 0c7aa46458
3 changed files with 135 additions and 23 deletions
+8 -1
View File
@@ -108,7 +108,7 @@ $BT/apksigner verify 目标名.apk
### 版本号 ### 版本号
- `app/build.gradle`:`versionName "1.3.5"` / `versionCode 11` - `app/build.gradle`:`versionName "1.3.6"` / `versionCode 12`
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
## 三、关键实现约束(避免踩坑) ## 三、关键实现约束(避免踩坑)
@@ -179,4 +179,11 @@ $BT/apksigner verify 目标名.apk
`next()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。 `next()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。
**不显示**(界面不展示)、**不关联已点清单**(`orderQueue` 完全独立,随机歌曲不入队)、 **不显示**(界面不展示)、**不关联已点清单**(`orderQueue` 完全独立,随机歌曲不入队)、
**App 退出即清理**(`onDestroy` 里 `isFinishing` 时 `clear()`,不落盘) **App 退出即清理**(`onDestroy` 里 `isFinishing` 时 `clear()`,不落盘)
- **弹窗分辨率自适应**:TV/投影/盒子的分辨率与 density 差异很大(1280x720、1920x1080,
density 1.0~2.5),固定 dp 尺寸在矮屏上会被屏幕裁切。统一用 `fitDialogToScreen(dialog, scroll,
content, preferredWidthDp, …)`:宽度取 `min(preferredWidthDp, 屏幕宽*0.94)`,高度把内容所在的
ScrollView 限制为 `屏幕高*0.92 - 弹窗标题/按钮框架(chromeDp)` —— **不要再用 `WRAP_CONTENT` 作为
弹窗高度**。内容高度由「屏幕预算 - 固定区高度」反推(调音弹窗的 EQ 频段区就是这样自适应),
预算不足时弹窗内滚动而不是被裁掉。已应用:调音弹窗、扫码点歌弹窗、声音设置弹窗。
新增/改动弹窗时请复用该 helper,别再写死 `setLayout(w, WRAP_CONTENT)`
- **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403) - **仓库可见性**:Gitea 仓库保持 **public**(若后续把 App 更新/热更新端点迁到 Gitea,非 public 会导致 403)
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv" applicationId "com.local.ktv"
minSdk 21 minSdk 21
targetSdk 25 targetSdk 25
versionCode 11 versionCode 12
versionName "1.3.5" versionName "1.3.6"
ndk { ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版 // 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则 // 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
+125 -20
View File
@@ -1763,6 +1763,56 @@ class MainActivity : AppCompatActivity() {
afterShow: (() -> Unit)? = null, afterShow: (() -> Unit)? = null,
): AlertDialog = create().showForTv(preferredButton, afterShow) ): AlertDialog = create().showForTv(preferredButton, afterShow)
/**
* 弹窗分辨率自适应(在 [showForTv] 的 afterShow 里调用)。
*
* TV / 投影 / 盒子的分辨率与 density 差异很大(1280x720、1920x1080,density 1.0~2.0),
* 固定 dp 尺寸在矮屏上会被屏幕裁掉。这里统一按当前屏幕可用区域收敛:
* - 宽度取 `min(preferredWidthDp, 屏幕宽 * widthRatio)`;
* - 高度不交给 `WRAP_CONTENT`,而是把内容所在的 [scroll] 高度限制为
* `屏幕高 * heightRatio - 弹窗标题/按钮区(chromeDp)`,内容更高时在弹窗内滚动。
*
* 这样内容放得下时窗口紧凑,放不下时滚动,任何分辨率下都不会被裁切。
*/
private fun fitDialogToScreen(
dialog: AlertDialog,
scroll: View,
content: View,
preferredWidthDp: Int,
widthRatio: Float = 0.94f,
heightRatio: Float = 0.92f,
chromeDp: Int = 150,
) {
val metrics = resources.displayMetrics
val availableWidth = (metrics.widthPixels * widthRatio).toInt()
val availableHeight = (metrics.heightPixels * heightRatio).toInt()
val width = if (preferredWidthDp > 0) {
min(dp(preferredWidthDp), availableWidth)
} else {
availableWidth
}
// 先按最终宽度量一次内容的原始高度:放得下就紧凑,放不下才滚动。
content.measure(
View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.AT_MOST),
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
)
val maxContentHeight = (availableHeight - dp(chromeDp)).coerceAtLeast(dp(120))
val contentHeight = content.measuredHeight.coerceAtMost(maxContentHeight)
val params = scroll.layoutParams
if (params != null) {
params.width = ViewGroup.LayoutParams.MATCH_PARENT
params.height = contentHeight
scroll.layoutParams = params
} else {
scroll.layoutParams = android.widget.FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
contentHeight,
)
}
dialog.window?.setLayout(width, ViewGroup.LayoutParams.WRAP_CONTENT)
}
private fun AlertDialog.restoreSourceFocusOnDismiss(): AlertDialog { private fun AlertDialog.restoreSourceFocusOnDismiss(): AlertDialog {
val sourcePage = currentPage val sourcePage = currentPage
val sourceView = this@MainActivity.window.decorView.findFocus() val sourceView = this@MainActivity.window.decorView.findFocus()
@@ -2506,6 +2556,29 @@ class MainActivity : AppCompatActivity() {
val working = KtvEqualizer.levelsSnapshot().let { saved -> val working = KtvEqualizer.levelsSnapshot().let { saved ->
FloatArray(caps.bandCount) { saved.getOrElse(it) { 0f } } FloatArray(caps.bandCount) { saved.getOrElse(it) { 0f } }
} }
// 分辨率自适应:按屏幕高度算出「弹窗内容能占多少」,把剩余空间留给 EQ 频段区,
// 矮屏自动压缩频段区高度(而不是把弹窗顶出屏幕),再叠加 fitDialogToScreen 的
// 高度上限兜底 —— 任何分辨率下都不会被裁切。
val metrics = resources.displayMetrics
val screenHeightDp = metrics.heightPixels / metrics.density
val compact = screenHeightDp < 620f
val chromeDp = if (compact) 132 else 152
val contentBudgetDp = (screenHeightDp * 0.92f - chromeDp).coerceAtLeast(240f)
// 固定区:面板内边距 30 + 标题 42 + 音量标题 28 + 音量杆 + 开关(含间距) 50 + 预设(含间距)
val fixedHeightDp = 30f + 42f + 28f +
(if (compact) 38 else 44) + 50f +
(if (compact) 44 else 52) + 10f
val bandAreaDp = (contentBudgetDp - fixedHeightDp).coerceIn(112f, 216f)
val bandAreaHeight = dp(bandAreaDp.toInt())
val barColumnWidth = dp(if (compact) 38 else 46)
val barCaptionHeight = dp(if (compact) 18 else 20)
val volumeRowHeight = dp(if (compact) 38 else 44)
val presetRowHeight = dp(if (compact) 44 else 52)
Log.i(
TAG,
"tuning dialog adaptive: screen=${screenHeightDp}dp compact=$compact " +
"budget=${contentBudgetDp}dp bandArea=${bandAreaDp}dp",
)
val panel = LinearLayout(this).apply { val panel = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
@@ -2576,7 +2649,7 @@ class MainActivity : AppCompatActivity() {
} }
}) })
} }
panel.addView(volumeBar, LinearLayout.LayoutParams(-1, dp(44))) panel.addView(volumeBar, LinearLayout.LayoutParams(-1, volumeRowHeight))
// ── 2. 均衡器开关(默认关闭)── // ── 2. 均衡器开关(默认关闭)──
lateinit var eqSwitch: TextView lateinit var eqSwitch: TextView
@@ -2611,7 +2684,7 @@ class MainActivity : AppCompatActivity() {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER gravity = Gravity.CENTER
} }
eqArea.addView(presetRow, LinearLayout.LayoutParams(-1, dp(52)).apply { topMargin = dp(10) }) eqArea.addView(presetRow, LinearLayout.LayoutParams(-1, presetRowHeight).apply { topMargin = dp(10) })
// ── 4. EQ 频段:竖向控制杆 ── // ── 4. EQ 频段:竖向控制杆 ──
val bars = ArrayList<VerticalEqBar>(caps.bandCount) val bars = ArrayList<VerticalEqBar>(caps.bandCount)
@@ -2628,7 +2701,7 @@ class MainActivity : AppCompatActivity() {
orientation = LinearLayout.HORIZONTAL orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER gravity = Gravity.CENTER
} }
eqArea.addView(barRow, LinearLayout.LayoutParams(-1, dp(216)).apply { topMargin = dp(6) }) eqArea.addView(barRow, LinearLayout.LayoutParams(-1, bandAreaHeight).apply { topMargin = dp(6) })
caps.frequencies.forEachIndexed { index, _ -> caps.frequencies.forEachIndexed { index, _ ->
val column = LinearLayout(this).apply { val column = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.VERTICAL
@@ -2640,7 +2713,7 @@ class MainActivity : AppCompatActivity() {
Color.argb(170, 255, 255, 255), Color.argb(170, 255, 255, 255),
).apply { gravity = Gravity.CENTER } ).apply { gravity = Gravity.CENTER }
valueViews.add(value) valueViews.add(value)
column.addView(value, LinearLayout.LayoutParams(-1, dp(20))) column.addView(value, LinearLayout.LayoutParams(-1, barCaptionHeight))
val bar = VerticalEqBar(this).apply { val bar = VerticalEqBar(this).apply {
setRange(caps.minDb, caps.maxDb, eqSliderSteps) setRange(caps.minDb, caps.maxDb, eqSliderSteps)
setLevel(working.getOrElse(index) { 0f }) setLevel(working.getOrElse(index) { 0f })
@@ -2660,12 +2733,12 @@ class MainActivity : AppCompatActivity() {
} }
} }
bars.add(bar) bars.add(bar)
column.addView(bar, LinearLayout.LayoutParams(dp(46), 0, 1f)) column.addView(bar, LinearLayout.LayoutParams(barColumnWidth, 0, 1f))
column.addView( column.addView(
label(bandLabels.getOrElse(index) { "${index + 1}" } + "Hz", 12, Color.WHITE).apply { label(bandLabels.getOrElse(index) { "${index + 1}" } + "Hz", 12, Color.WHITE).apply {
gravity = Gravity.CENTER gravity = Gravity.CENTER
}, },
LinearLayout.LayoutParams(-1, dp(20)), LinearLayout.LayoutParams(-1, barCaptionHeight),
) )
barRow.addView(column, LinearLayout.LayoutParams(0, -1, 1f)) barRow.addView(column, LinearLayout.LayoutParams(0, -1, 1f))
} }
@@ -2713,9 +2786,15 @@ class MainActivity : AppCompatActivity() {
setBackgroundDrawableResource(android.R.color.transparent) setBackgroundDrawableResource(android.R.color.transparent)
attributes = attributes.apply { dimAmount = 0.72f } attributes = attributes.apply { dimAmount = 0.72f }
addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
val available = (resources.displayMetrics.widthPixels * 0.94f).toInt()
setLayout(min(dp(760), available), ViewGroup.LayoutParams.WRAP_CONTENT)
} }
// 分辨率自适应:宽度上限 + 内容区高度上限(超出时弹窗内滚动,绝不裁切)。
fitDialogToScreen(
dialog = dialog,
scroll = scroll,
content = panel,
preferredWidthDp = 760,
chromeDp = if (compact) 128 else 150,
)
volumeBar.requestFocus() volumeBar.requestFocus()
}) })
} }
@@ -3025,34 +3104,54 @@ class MainActivity : AppCompatActivity() {
.create().showForTv(DialogInterface.BUTTON_POSITIVE) .create().showForTv(DialogInterface.BUTTON_POSITIVE)
return return
} }
// 分辨率自适应:二维码与文案高度按当前屏幕收敛,矮屏压缩、大屏放大(夹在 160~280dp)。
val metrics = resources.displayMetrics
val screenWidthDp = metrics.widthPixels / metrics.density
val screenHeightDp = metrics.heightPixels / metrics.density
val compact = screenHeightDp < 620f
val qrSide = min(
min((screenHeightDp * 0.42f).toInt(), (screenWidthDp * 0.62f).toInt()),
if (compact) 220 else 280,
).coerceAtLeast(160)
val box = LinearLayout(this) val box = LinearLayout(this)
box.setOrientation(LinearLayout.VERTICAL) box.setOrientation(LinearLayout.VERTICAL)
box.setGravity(Gravity.CENTER_HORIZONTAL) box.setGravity(Gravity.CENTER_HORIZONTAL)
box.setPadding(dp(24), dp(18), dp(24), dp(12)) box.setPadding(dp(20), dp(12), dp(20), dp(8))
val qr = ImageView(this) val qr = ImageView(this)
qr.setBackgroundColor(Color.WHITE) qr.setBackgroundColor(Color.WHITE)
qr.setImageBitmap(qrBitmap(url, dp(260))) // 位图按最终显示尺寸生成,保证任何分辨率下二维码都清晰可扫。
box.addView(qr, LinearLayout.LayoutParams(dp(280), dp(280))) qr.setImageBitmap(qrBitmap(url, dp(qrSide)))
val tip = label("手机连接同一局域网后扫码点歌", 18, Color.WHITE) box.addView(qr, LinearLayout.LayoutParams(dp(qrSide), dp(qrSide)))
val tip = label("手机连接同一局域网后扫码点歌", if (compact) 16 else 18, Color.WHITE)
tip.gravity = Gravity.CENTER tip.gravity = Gravity.CENTER
box.addView(tip, LinearLayout.LayoutParams(dp(380), dp(54))) box.addView(tip, LinearLayout.LayoutParams(-1, dp(if (compact) 42 else 54)))
// 显示当前服务的 ip:端口 地址(去掉路径,便于对照路由器/局域网排查)。 // 显示当前服务的 ip:端口 地址(去掉路径,便于对照路由器/局域网排查)。
val serverHost = remoteServer.address().orEmpty() val serverHost = remoteServer.address().orEmpty()
val serverPort = remoteServer.port() val serverPort = remoteServer.port()
if (serverHost.isNotEmpty() && serverPort > 0) { if (serverHost.isNotEmpty() && serverPort > 0) {
val addressLine = label("$serverHost:$serverPort", 16, GOLD) val addressLine = label("$serverHost:$serverPort", 16, GOLD)
addressLine.gravity = Gravity.CENTER addressLine.gravity = Gravity.CENTER
box.addView(addressLine, LinearLayout.LayoutParams(dp(380), dp(34))) box.addView(addressLine, LinearLayout.LayoutParams(-1, dp(32)))
} }
// 提示:扫码连上后播放界面右上角二维码会自动隐藏。 // 提示:扫码连上后播放界面右上角二维码会自动隐藏。
val hint = label("扫码点歌后自动隐藏播放界面二维码", 14, Color.rgb(193, 193, 193)) val hint = label("扫码点歌后自动隐藏播放界面二维码", 14, Color.rgb(193, 193, 193))
hint.gravity = Gravity.CENTER hint.gravity = Gravity.CENTER
box.addView(hint, LinearLayout.LayoutParams(dp(420), dp(30))) box.addView(hint, LinearLayout.LayoutParams(-1, dp(if (compact) 26 else 30)))
AlertDialog.Builder(this) val scroll = android.widget.ScrollView(this).apply { addView(box) }
val dialog = AlertDialog.Builder(this)
.setTitle("扫码点歌") .setTitle("扫码点歌")
.setView(box) .setView(scroll)
.setPositiveButton("确定", null) .setPositiveButton("确定", null)
.create().showForTv(DialogInterface.BUTTON_POSITIVE) .create()
dialog.showForTv(DialogInterface.BUTTON_POSITIVE) {
fitDialogToScreen(
dialog = dialog,
scroll = scroll,
content = box,
preferredWidthDp = 460,
chromeDp = if (compact) 128 else 150,
)
}
} }
private fun restartRemoteServer() { private fun restartRemoteServer() {
@@ -8921,8 +9020,14 @@ class MainActivity : AppCompatActivity() {
saveState() saveState()
}.create() }.create()
dialog.showForTv(DialogInterface.BUTTON_NEGATIVE) { dialog.showForTv(DialogInterface.BUTTON_NEGATIVE) {
val available = (resources.displayMetrics.widthPixels * 0.92f).toInt() // 同样的分辨率自适应:内容过高时在弹窗内滚动而不是被裁切。
dialog.window?.setLayout(min(dp(790), available), ViewGroup.LayoutParams.WRAP_CONTENT) fitDialogToScreen(
dialog = dialog,
scroll = scroll,
content = body,
preferredWidthDp = 790,
widthRatio = 0.92f,
)
} }
} }