V1.3.8: 均衡器频段改横向滑杆 + 遥控确认键调节/退出 + 触控需按住滑块

1) 频段 UI 改为横向滑动条
   删除 VerticalEqBar, 新增自绘控件 EqSlider(横向), 音量与频段共用:
   每个频段一行「频率 | 滑杆 | 增益(dB)」, 带 0dB 参考线并从参考线填充到当前值。
   弹窗布局随之改为: 音量横向杆 → 均衡器开关 → 预设 → 5 行频段滑杆。

2) 电视遥控交互(按确认键才可调节控件点, 同理按确认键退出控制)
   - 焦点在滑杆上时方向键**不**改数值(先让焦点在控件间正常移动);
   - 按确认键进入「调节态」(描边变亮变粗), 此时左右/上下键才调值;
   - 再按确认键退出调节态; 调节态下 BACK 也只退出调节态、不关弹窗; 失焦自动退出。
   新增 onInteractionFinished 回调, 退出调节态/抬手/失焦时统一 applyPlaybackMode +
   saveState(音量) 或 applyEqualizerSettings(频段), 避免逐步落盘。

3) 触控优化(含远程控制): 必须按住控件点(滑块)才能滑动
   - App: EqSlider 触摸按下时先做 hitsThumb 命中判断(容差 26dp, 含纵向范围),
     未命中直接不处理 -> 点轨道空白不会跳变数值;
   - 远程点歌页面: 新增 attachThumbOnlyDrag(), 对 input[type=range] 的
     pointerdown/touchstart/mousedown 做同样的滑块命中判断, 未命中则 preventDefault,
     命中才放行浏览器原生拖动; 滑块光标改 grab/grabbing 提示可拖。

4) 自适应随之调整: 预算按「频段行数」均分得到行高(clamp 38~56dp),
   两遍 fit 超出时收缩的是频段行高(下限 34dp), 文字行仍全部 wrap_content。

版本号 1.3.7 -> 1.3.8(versionCode 13 -> 14)。按约定本次不发布 Release, 仅本地打包测试。
This commit is contained in:
zijunchiang
2026-09-20 19:00:53 +08:00
parent 7e3d233891
commit 42919cc8e3
7 changed files with 369 additions and 252 deletions
+11 -4
View File
@@ -108,7 +108,7 @@ $BT/apksigner verify 目标名.apk
### 版本号 ### 版本号
- `app/build.gradle`:`versionName "1.3.7"` / `versionCode 13` - `app/build.gradle`:`versionName "1.3.8"` / `versionCode 14`
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号) - App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
## 三、关键实现约束(避免踩坑) ## 三、关键实现约束(避免踩坑)
@@ -166,14 +166,21 @@ $BT/apksigner verify 目标名.apk
因此由 500ms 的 `lyricTicker` → `syncEqualizerSessions()` 周期对齐,消失的会话延迟 3s 释放。 因此由 500ms 的 `lyricTicker` → `syncEqualizerSessions()` 周期对齐,消失的会话延迟 3s 释放。
`globalMix`(会话 0)是设备不支持按会话挂载时的显式开关,会影响本机所有声音,默认关闭。 `globalMix`(会话 0)是设备不支持按会话挂载时的显式开关,会影响本机所有声音,默认关闭。
均衡器已并入**「调音」弹窗**(`showTuningDialog()`,自上而下:音量横向杆 → 均衡器开关 → 均衡器已并入**「调音」弹窗**(`showTuningDialog()`,自上而下:音量横向杆 → 均衡器开关 →
预设 → EQ 竖向杆),主界面右上角与播放界面「调音」是同一个弹窗;另有声音设置入口与 预设 → EQ 频段横向杆,每个频段一行:频率 | 滑杆 | 增益),主界面右上角与播放界面「调音」
是同一个弹窗;另有声音设置入口与
远程点歌页面「均衡器」卡片(`PATCH /api/v1/player/equalizer`,状态随 远程点歌页面「均衡器」卡片(`PATCH /api/v1/player/equalizer`,状态随
`GET /api/v1/state` 的 `equalizer` 字段下发)。预设:App 内为「自定义/流行/摇滚/舞曲」 `GET /api/v1/state` 的 `equalizer` 字段下发)。预设:App 内为「自定义/流行/摇滚/舞曲」
(`appPresetNames()`,手动改频段即转「自定义」并自动保存),远程页面额外保留「标准」 (`appPresetNames()`,手动改频段即转「自定义」并自动保存),远程页面额外保留「标准」
供「恢复标准」使用。设置持久化在 `KtvStore.eq*`; 供「恢复标准」使用。设置持久化在 `KtvStore.eq*`;
**刻度固定 30 档**(`eqSliderSteps`),步进由设备增益范围均分,保证 0 dB 一定可选中。 **刻度固定 30 档**(`eqSliderSteps`),步进由设备增益范围均分,保证 0 dB 一定可选中。
EQ 竖向杆是自绘控件 `VerticalEqBar`(SeekBar 只支持横向),已 `suppressFill` 并自绘焦点框; 音量与频段共用自绘控件 `EqSlider`(横向),已 `suppressFill` 并自绘焦点/调节描边。
它在上下键到达量程端点时会放行按键,否则遥控器会困在滑杆上出不去 ⚠️ **它的交互是刻意定制的,不要换回 SeekBar**:
- **遥控**:焦点在控件上时方向键**不**改数值(先让焦点在控件间移动);按**确认键进入调节态**
(描边变亮变粗)后方向键才调值,**再按确认键退出**调节态;调节态下 BACK 只退出调节态、
不关弹窗;失焦自动退出。这是"需按确认键才可调节控件点、同理按确认键退出控制"的实现。
- **触控**:必须按住滑块(控件点)才拖动,点轨道空白不跳值(`hitsThumb` 命中判断,
容差 `thumbTouchSlop`)。远程点歌页面同规则,见 `attachThumbOnlyDrag()`(app.js,对
`input[type=range]` 做指针命中判断,未命中则 preventDefault)
- **公放随机清单**:`RandomPlayQueue`(纯内存单例)——随机续播不再「每次现随机挑一首」, - **公放随机清单**:`RandomPlayQueue`(纯内存单例)——随机续播不再「每次现随机挑一首」,
而是一次性生成打乱顺序的临时清单,一轮内不重复;播完一首 `markFinished` +1; 而是一次性生成打乱顺序的临时清单,一轮内不重复;播完一首 `markFinished` +1;
`next()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。 `next()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。
+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 13 versionCode 14
versionName "1.3.7" versionName "1.3.8"
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,否则
+1
View File
@@ -18,3 +18,4 @@
.audio-panel label.mic-hw{opacity:.55}.audio-panel label.mic-hw>small{grid-column:1/3;color:#9f8eb0;font-size:10px;margin-top:2px}.audio-panel label.mic-hw input[type=range]{background:rgba(255,255,255,.14)}.eq-panel{padding:18px;margin-top:14px}.eq-switch{grid-template-columns:1fr 1fr}.eq-bands{margin-top:6px}.eq-band{display:grid;grid-template-columns:1fr auto;align-items:center;margin-top:14px;font-size:12px;color:#e9dfef}.eq-band>span{color:#cbbad9}.eq-band>b{color:var(--gold);font-size:12px}.eq-band b em{font-style:normal;font-size:14px}.eq-presets{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-top:17px}.eq-preset{height:40px;border-radius:13px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.09);color:#d9cce4;font-size:12px}.eq-preset.active{color:#36123d;background:linear-gradient(135deg,#ffdf7f,#ffb84a);font-weight:700}.eq-hint{margin:14px 0 0;color:#9f8eb0;font-size:10px;line-height:1.5} .audio-panel label.mic-hw{opacity:.55}.audio-panel label.mic-hw>small{grid-column:1/3;color:#9f8eb0;font-size:10px;margin-top:2px}.audio-panel label.mic-hw input[type=range]{background:rgba(255,255,255,.14)}.eq-panel{padding:18px;margin-top:14px}.eq-switch{grid-template-columns:1fr 1fr}.eq-bands{margin-top:6px}.eq-band{display:grid;grid-template-columns:1fr auto;align-items:center;margin-top:14px;font-size:12px;color:#e9dfef}.eq-band>span{color:#cbbad9}.eq-band>b{color:var(--gold);font-size:12px}.eq-band b em{font-style:normal;font-size:14px}.eq-presets{display:grid;grid-template-columns:repeat(3,1fr);gap:7px;margin-top:17px}.eq-preset{height:40px;border-radius:13px;background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.09);color:#d9cce4;font-size:12px}.eq-preset.active{color:#36123d;background:linear-gradient(135deg,#ffdf7f,#ffb84a);font-weight:700}.eq-hint{margin:14px 0 0;color:#9f8eb0;font-size:10px;line-height:1.5}
.eq-title{width:100%;padding:0;margin:0;background:transparent;border:0;text-align:left;cursor:pointer}.eq-title-right{display:flex;align-items:center;gap:8px}.eq-chevron{font-style:normal;color:#c9a8e8;font-size:13px;transition:transform .2s}.eq-title[aria-expanded=true] .eq-chevron{transform:rotate(180deg)}.eq-body{margin-top:4px} .eq-title{width:100%;padding:0;margin:0;background:transparent;border:0;text-align:left;cursor:pointer}.eq-title-right{display:flex;align-items:center;gap:8px}.eq-chevron{font-style:normal;color:#c9a8e8;font-size:13px;transition:transform .2s}.eq-title[aria-expanded=true] .eq-chevron{transform:rotate(180deg)}.eq-body{margin-top:4px}
input[type=range]::-webkit-slider-thumb{cursor:grab}input[type=range]::-webkit-slider-thumb:active{cursor:grabbing}input[type=range]::-moz-range-thumb{cursor:grab}
+36
View File
@@ -111,6 +111,7 @@ function renderEqualizer(eq) {
bands.forEach((band, index) => { bands.forEach((band, index) => {
const input = $("#eqBand" + index); const input = $("#eqBand" + index);
if (!input) return; if (!input) return;
attachThumbOnlyDrag(input);
input.addEventListener("input", () => { input.addEventListener("input", () => {
const value = $("#eqValue" + index); const value = $("#eqValue" + index);
if (value) value.textContent = Number(input.value).toFixed(1); if (value) value.textContent = Number(input.value).toFixed(1);
@@ -143,6 +144,40 @@ function currentEqLevels() {
return Array.from($$("#eqBands input[type=range]")).map((input) => Number(input.value)); return Array.from($$("#eqBands input[type=range]")).map((input) => Number(input.value));
} }
/**
* 触控优化:滑块必须按住「控件点(滑块)」才能拖动,点轨道空白处不跳变数值。
*
* `<input type="range">` 默认点轨道任意位置都会跳值并开始拖动;这里在指针按下时先做
* 命中判断 —— 命中滑块才放行浏览器原生拖动,否则 preventDefault 拦掉这次手势。
* 只有粘性标记滑块处于按下状态时才放行,手指移出轨道仍可继续拖动(由原生处理)。
*/
function attachThumbOnlyDrag(input) {
if (!input || input.dataset.thumbOnly === "1") return;
input.dataset.thumbOnly = "1";
const blocked = (event) => {
const rect = input.getBoundingClientRect();
const point = event.touches && event.touches.length ? event.touches[0] : event;
const x = point.clientX;
const y = point.clientY;
// 粗略还原轨道几何:控件左右各留半个滑块宽度。
const thumb = 22;
const trackLeft = rect.left + thumb / 2;
const trackRight = rect.right - thumb / 2;
const span = trackRight - trackLeft;
if (span <= 0 || y < rect.top - thumb || y > rect.bottom + thumb) return true;
const min = Number(input.min || 0);
const max = Number(input.max || 100);
const ratio = (Number(input.value) - min) / (max - min || 1);
const thumbX = trackLeft + ratio * span;
// 命中滑块(含手指触控容差)→ 放行拖动;否则拦掉,避免点轨道跳值。
return Math.abs(x - thumbX) > Math.max(thumb, 26);
};
const guard = (event) => { if (blocked(event)) event.preventDefault(); };
input.addEventListener("pointerdown", guard, { passive: false });
input.addEventListener("touchstart", guard, { passive: false });
input.addEventListener("mousedown", guard);
}
async function updateEqualizer(values) { async function updateEqualizer(values) {
try { try {
await api("/api/v1/player/equalizer", { method: "PATCH", body: JSON.stringify(values) }); await api("/api/v1/player/equalizer", { method: "PATCH", body: JSON.stringify(values) });
@@ -270,6 +305,7 @@ $("#more").addEventListener("click", () => search(false));
$("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1)); $("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1));
$("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1)); $("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1));
// 音乐音量可调; 麦克风音量由功放硬件控制, 不再向设备发送无效果请求。 // 音乐音量可调; 麦克风音量由功放硬件控制, 不再向设备发送无效果请求。
attachThumbOnlyDrag($("#musicVolume"));
$("#musicVolume").addEventListener("input", (event) => { $("#musicValue").textContent = event.target.value; }); $("#musicVolume").addEventListener("input", (event) => { $("#musicValue").textContent = event.target.value; });
$("#musicVolume").addEventListener("change", (event) => updateAudio({ musicVolume: Number(event.target.value) })); $("#musicVolume").addEventListener("change", (event) => updateAudio({ musicVolume: Number(event.target.value) }));
// 均衡器入口(与设备端弹窗等价): 开关 / 恢复标准 // 均衡器入口(与设备端弹窗等价): 开关 / 恢复标准
+268
View File
@@ -0,0 +1,268 @@
package com.local.ktv
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
/**
* 横向控制杆 —— 「调音」弹窗的音量与均衡器频段共用。
*
* 交互按两种输入方式分别设计(对应电视遥控与触控):
*
* **遥控器**:
* - 焦点在控件上时,左右/上下键**不**改变数值 —— 先让焦点在控件之间正常移动;
* - 按「确认」键进入**调节态**(控件出现高亮描边),此时左右/上下键才改变数值;
* - 再按一次「确认」退出调节态;调节态下 BACK 也只退出调节态,不关闭弹窗;
* - 失去焦点时自动退出调节态。
*
* **触控**:
* - 必须按住滑块(控件点)才能拖动,点轨道空白处**不会**跳变数值;
* - 在滑块上按下后手指移出控件范围仍可继续拖动,直到抬起。
*
* 数值范围由 [setRange] 给出;[showZeroLine] 为 true 时(均衡器频段)会画出 0 参考线,
* 并从参考线填充到当前值,便于一眼看出提升/衰减。
*/
class EqSlider(context: Context) : View(context) {
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(60, 255, 255, 255)
style = Paint.Style.FILL
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(255, 184, 74)
style = Paint.Style.FILL
}
private val zeroPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(110, 255, 255, 255)
strokeWidth = 1.5f
}
private val thumbPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.FILL
}
private val focusPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(255, 152, 48)
style = Paint.Style.STROKE
strokeWidth = 2.5f
}
private val adjustingPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(255, 213, 106)
style = Paint.Style.STROKE
strokeWidth = 4f
}
private val density = resources.displayMetrics.density
private val trackHeight = 8f * density
private val thumbRadius = 11f * density
/** 触摸命中滑块的水平容差;只有按住这个范围才允许拖动。 */
private val thumbTouchSlop = 26f * density
private var minValue = 0f
private var maxValue = 100f
private var steps = 100
private var level = 0f
private var showZeroLine = false
/** 是否处于遥控器「调节态」(按确认键进入/退出)。 */
var adjusting: Boolean = false
private set
/** 值变化回调(fromUser=true 表示用户操作触发)。 */
var onLevelChanged: ((level: Float, fromUser: Boolean) -> Unit)? = null
/** 一次调节结束(退出调节态 / 抬手 / 失焦)时回调,便于上层落盘。 */
var onInteractionFinished: (() -> Unit)? = null
private var dragging = false
init {
isFocusable = true
isFocusableInTouchMode = true
isClickable = true
// 自绘控件:禁止 TvFocusStyler 用不透明渐变覆盖背景(自己画焦点/调节描边)。
TvFocusStyler.suppressFill(this)
}
fun setRange(minValue: Float, maxValue: Float, steps: Int, zeroLine: Boolean = false) {
this.minValue = minValue
this.maxValue = maxValue
this.steps = steps.coerceAtLeast(1)
this.showZeroLine = zeroLine
invalidate()
}
fun level(): Float = level
fun setLevel(value: Float, notify: Boolean = false) {
val clamped = value.coerceIn(minValue, maxValue)
if (clamped == level) {
invalidate()
return
}
level = clamped
invalidate()
if (notify) onLevelChanged?.invoke(level, true)
}
private fun stepSize(): Float = (maxValue - minValue) / steps
private fun valueToRatio(value: Float): Float =
((value - minValue) / (maxValue - minValue)).coerceIn(0f, 1f)
private fun ratioToValue(ratio: Float): Float {
val raw = minValue + ratio.coerceIn(0f, 1f) * (maxValue - minValue)
// 吸附到刻度上,保证 0 一定落在某档(均衡器 0dB / 音量整数值)。
val ticks = Math.round((raw - minValue) / stepSize())
return (minValue + ticks * stepSize()).coerceIn(minValue, maxValue)
}
private fun trackLeft(): Float = paddingLeft + thumbRadius
private fun trackRight(): Float = width - paddingRight - thumbRadius
private fun thumbX(): Float =
trackLeft() + valueToRatio(level) * (trackRight() - trackLeft())
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val left = trackLeft()
val right = trackRight()
if (right <= left) return
val centerY = height / 2f
val radius = trackHeight / 2f
canvas.drawRoundRect(
RectF(left, centerY - radius, right, centerY + radius),
radius,
radius,
trackPaint,
)
// 0 参考线(均衡器频段):从参考线填充到当前值。
val zeroRatio = if (showZeroLine) ((0f - minValue) / (maxValue - minValue)).coerceIn(0f, 1f) else 0f
val zeroX = left + zeroRatio * (right - left)
if (showZeroLine) {
canvas.drawLine(zeroX, centerY - trackHeight, zeroX, centerY + trackHeight, zeroPaint)
}
val valueX = thumbX()
val fillStart = minOf(zeroX, valueX)
val fillEnd = maxOf(zeroX, valueX)
if (fillEnd - fillStart > 1f) {
canvas.drawRoundRect(
RectF(fillStart, centerY - radius, fillEnd, centerY + radius),
radius,
radius,
fillPaint,
)
}
canvas.drawCircle(valueX, centerY, thumbRadius, thumbPaint)
if (isFocused) {
val inset = 2f * density
canvas.drawRoundRect(
RectF(inset, inset, width - inset, height - inset),
8f * density,
8f * density,
if (adjusting) adjustingPaint else focusPaint,
)
}
}
/** 触摸点是否落在滑块(控件点)上。 */
private fun hitsThumb(x: Float, y: Float): Boolean {
if (y < 0 || y > height) return false
return Math.abs(x - thumbX()) <= thumbTouchSlop
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 触控优化:只有按住滑块才开始拖动,点空白轨道不跳变数值。
if (!hitsThumb(event.x, event.y)) return false
dragging = true
isPressed = true
if (!isFocused) requestFocus()
return true
}
MotionEvent.ACTION_MOVE -> {
if (!dragging) return false
val span = trackRight() - trackLeft()
if (span <= 0f) return true
val ratio = (event.x - trackLeft()) / span
val next = ratioToValue(ratio)
if (next != level) {
level = next
invalidate()
onLevelChanged?.invoke(level, true)
}
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
if (!dragging) return false
dragging = false
isPressed = false
performClick()
onInteractionFinished?.invoke()
return true
}
}
return super.onTouchEvent(event)
}
override fun performClick(): Boolean {
super.performClick()
return true
}
private fun step(delta: Int): Boolean {
val current = Math.round((level - minValue) / stepSize())
val next = (current + delta).coerceIn(0, steps)
if (next == current) return false
level = (minValue + next * stepSize()).coerceIn(minValue, maxValue)
invalidate()
onLevelChanged?.invoke(level, true)
return true
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val confirm = keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER ||
keyCode == KeyEvent.KEYCODE_NUMPAD_ENTER
if (confirm) {
// 按确认键:进入 / 退出调节态。
adjusting = !adjusting
invalidate()
if (!adjusting) onInteractionFinished?.invoke()
return true
}
if (keyCode == KeyEvent.KEYCODE_BACK && adjusting) {
adjusting = false
invalidate()
onInteractionFinished?.invoke()
return true
}
val delta = when (keyCode) {
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_DOWN -> 1
KeyEvent.KEYCODE_DPAD_LEFT, KeyEvent.KEYCODE_DPAD_UP -> -1
else -> return super.onKeyDown(keyCode, event)
}
if (!adjusting) {
// 未进入调节态:不消耗方向键,让焦点在控件之间正常移动。
return super.onKeyDown(keyCode, event)
}
step(delta)
return true
}
override fun onFocusChanged(gainFocus: Boolean, direction: Int, previouslyFocusedRect: android.graphics.Rect?) {
super.onFocusChanged(gainFocus, direction, previouslyFocusedRect)
if (!gainFocus && adjusting) {
adjusting = false
invalidate()
onInteractionFinished?.invoke()
}
}
}
+48 -53
View File
@@ -2557,21 +2557,22 @@ class MainActivity : AppCompatActivity() {
FloatArray(caps.bandCount) { saved.getOrElse(it) { 0f } } FloatArray(caps.bandCount) { saved.getOrElse(it) { 0f } }
} }
// 分辨率自适应:**文字行全部 wrap_content**(高度由文本决定,永不被压扁/裁字), // 分辨率自适应:**文字行全部 wrap_content**(高度由文本决定,永不被压扁/裁字),
// 只有 EQ 频段区是可伸缩的 —— 屏幕放不下时收缩的是频段区,不是文字。 // 只有均衡器频段行是可伸缩的 —— 屏幕放不下时收缩的是频段行高,不是文字。
val metrics = resources.displayMetrics val metrics = resources.displayMetrics
val screenHeightDp = metrics.heightPixels / metrics.density val screenHeightDp = metrics.heightPixels / metrics.density
// 矮屏(高度<620dp)只用来收窄竖杆列宽;文字尺寸不随分辨率变化。
val compact = screenHeightDp < 620f val compact = screenHeightDp < 620f
val chromeDp = if (compact) 132 else 152 val chromeDp = if (compact) 132 else 152
val contentBudgetDp = (screenHeightDp * 0.92f - chromeDp).coerceAtLeast(240f) val contentBudgetDp = (screenHeightDp * 0.92f - chromeDp).coerceAtLeast(240f)
// 文字/控件行占位的保守估计(内边距 + 标题 + 音量标题 + 开关 + 预设),最终以实测量为准。 val bandCount = caps.bandCount.coerceAtLeast(1)
val textRowsDp = 30f + 34f + 30f + 50f + 58f // 非频段部分(内边距/标题/音量标题+杆/开关/预设)的保守占位,最终以实测量为准。
var bandAreaHeight = dp((contentBudgetDp - textRowsDp).coerceIn(112f, 216f).toInt()) val otherRowsDp = 30f + 34f + 30f + 44f + 50f + 58f
val barColumnWidth = dp(if (compact) 38 else 46) var bandRowHeight = dp(
((contentBudgetDp - otherRowsDp) / bandCount).coerceIn(38f, 56f).toInt(),
)
Log.i( Log.i(
TAG, TAG,
"tuning dialog adaptive: screen=${screenHeightDp}dp compact=$compact " + "tuning dialog adaptive: screen=${screenHeightDp}dp compact=$compact " +
"budget=${contentBudgetDp}dp band=${bandAreaHeight}px", "budget=${contentBudgetDp}dp bandRows=$bandCount row=${bandRowHeight}px",
) )
val panel = LinearLayout(this).apply { val panel = LinearLayout(this).apply {
@@ -2616,37 +2617,31 @@ class MainActivity : AppCompatActivity() {
panel.addView(titleRow, LinearLayout.LayoutParams(-1, -2)) panel.addView(titleRow, LinearLayout.LayoutParams(-1, -2))
// ── 1. 音量:横向控制杆(即时生效)── // ── 1. 音量:横向控制杆(即时生效)──
// 交互:遥控器按「确认」进入调节态后才能左右调;触控必须按住滑块才能拖动。
val volumeTitle = label("音量 $musicVolume%", 16, Color.WHITE) val volumeTitle = label("音量 $musicVolume%", 16, Color.WHITE)
panel.addView(volumeTitle, LinearLayout.LayoutParams(-1, -2)) panel.addView(volumeTitle, LinearLayout.LayoutParams(-1, -2))
var lastVolumeAppliedAt = 0L var lastVolumeAppliedAt = 0L
val volumeBar = SeekBar(this).apply { val volumeBar = EqSlider(this).apply {
max = 100 setRange(0f, 100f, 100)
progress = musicVolume setLevel(musicVolume.toFloat())
isFocusable = true onLevelChanged = { value, fromUser ->
isFocusableInTouchMode = true if (fromUser) {
setPadding(0, 0, 0, 0) musicVolume = Math.round(value).coerceIn(0, 100)
TvFocusStyler.installAction(this)
setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(bar: SeekBar?, progress: Int, fromUser: Boolean) {
musicVolume = progress.coerceIn(0, 100)
volumeTitle.text = "音量 $musicVolume%" volumeTitle.text = "音量 $musicVolume%"
// 拖动时按 100ms 节流即时生效,避免每个像素都重建音轨。 // 按 100ms 节流即时生效,避免每一步都重建音轨。
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (now - lastVolumeAppliedAt >= 100L) { if (now - lastVolumeAppliedAt >= 100L) {
lastVolumeAppliedAt = now lastVolumeAppliedAt = now
applyPlaybackMode() applyPlaybackMode()
} }
} }
}
override fun onStartTrackingTouch(bar: SeekBar?) = Unit onInteractionFinished = {
override fun onStopTrackingTouch(bar: SeekBar?) {
applyPlaybackMode() applyPlaybackMode()
saveState() saveState()
} }
})
} }
panel.addView(volumeBar, LinearLayout.LayoutParams(-1, -2)) panel.addView(volumeBar, LinearLayout.LayoutParams(-1, dp(44)))
// ── 2. 均衡器开关(默认关闭)── // ── 2. 均衡器开关(默认关闭)──
lateinit var eqSwitch: TextView lateinit var eqSwitch: TextView
@@ -2683,8 +2678,8 @@ class MainActivity : AppCompatActivity() {
} }
eqArea.addView(presetRow, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(10) }) eqArea.addView(presetRow, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(10) })
// ── 4. EQ 频段:竖向控制杆 ── // ── 4. EQ 频段:横向控制杆(每个频段一行:频率 | 滑杆 | 增益)──
// 刻度文字用无内边距的 caption:高度由文本自身决定, 不会再被固定 dp 裁掉。 // 刻度文字用无内边距的 caption:高度由文本自身决定, 不会被固定 dp 裁掉。
fun caption(text: String, sp: Int, color: Int): TextView = TextView(this).apply { fun caption(text: String, sp: Int, color: Int): TextView = TextView(this).apply {
this.text = text this.text = text
textSize = sp.toFloat() textSize = sp.toFloat()
@@ -2693,8 +2688,9 @@ class MainActivity : AppCompatActivity() {
includeFontPadding = false includeFontPadding = false
setPadding(0, 0, 0, 0) setPadding(0, 0, 0, 0)
} }
val bars = ArrayList<VerticalEqBar>(caps.bandCount) val bars = ArrayList<EqSlider>(bandCount)
val valueViews = ArrayList<TextView>(caps.bandCount) val valueViews = ArrayList<TextView>(bandCount)
val bandRows = ArrayList<View>(bandCount)
fun syncBars() { fun syncBars() {
val applied = KtvEqualizer.levelsSnapshot() val applied = KtvEqualizer.levelsSnapshot()
for (index in working.indices) { for (index in working.indices) {
@@ -2703,25 +2699,23 @@ class MainActivity : AppCompatActivity() {
valueViews.getOrNull(index)?.text = eqLevelText(working[index]) valueViews.getOrNull(index)?.text = eqLevelText(working[index])
} }
} }
val barRow = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
}
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 row = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_HORIZONTAL gravity = Gravity.CENTER_VERTICAL
} }
row.addView(
caption(bandLabels.getOrElse(index) { "${index + 1}" } + "Hz", 13, Color.WHITE),
LinearLayout.LayoutParams(dp(62), -1),
)
val value = caption( val value = caption(
eqLevelText(working.getOrElse(index) { 0f }), eqLevelText(working.getOrElse(index) { 0f }),
12, 13,
Color.argb(170, 255, 255, 255), Color.argb(170, 255, 255, 255),
) )
valueViews.add(value) valueViews.add(value)
column.addView(value, LinearLayout.LayoutParams(-1, -2)) val bar = EqSlider(this).apply {
val bar = VerticalEqBar(this).apply { setRange(caps.minDb, caps.maxDb, eqSliderSteps, zeroLine = true)
setRange(caps.minDb, caps.maxDb, eqSliderSteps)
setLevel(working.getOrElse(index) { 0f }) setLevel(working.getOrElse(index) { 0f })
onLevelChanged = { level, fromUser -> onLevelChanged = { level, fromUser ->
if (fromUser) { if (fromUser) {
@@ -2737,14 +2731,13 @@ class MainActivity : AppCompatActivity() {
applyEqualizerSettings() applyEqualizerSettings()
} }
} }
onInteractionFinished = { applyEqualizerSettings() }
} }
bars.add(bar) bars.add(bar)
column.addView(bar, LinearLayout.LayoutParams(barColumnWidth, 0, 1f)) row.addView(bar, LinearLayout.LayoutParams(0, -1, 1f))
column.addView( row.addView(value, LinearLayout.LayoutParams(dp(58), -1))
caption(bandLabels.getOrElse(index) { "${index + 1}" } + "Hz", 12, Color.WHITE), bandRows.add(row)
LinearLayout.LayoutParams(-1, -2), eqArea.addView(row, LinearLayout.LayoutParams(-1, bandRowHeight))
)
barRow.addView(column, LinearLayout.LayoutParams(0, -1, 1f))
} }
KtvEqualizer.appPresetNames().forEach { name -> KtvEqualizer.appPresetNames().forEach { name ->
@@ -2803,16 +2796,18 @@ class MainActivity : AppCompatActivity() {
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED), View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
) )
val overflow = panel.measuredHeight - maxContentHeight val overflow = panel.measuredHeight - maxContentHeight
if (overflow > 0) { if (overflow > 0 && bandRows.isNotEmpty()) {
val shrunk = (bandAreaHeight - overflow).coerceAtLeast(dp(96)) val shrunk = (bandRowHeight - overflow / bandRows.size).coerceAtLeast(dp(34))
Log.i( Log.i(
TAG, TAG,
"tuning dialog shrink band: measured=${panel.measuredHeight}px " + "tuning dialog shrink band rows: measured=${panel.measuredHeight}px " +
"max=${maxContentHeight}px band=$bandAreaHeight->$shrunk", "max=${maxContentHeight}px row=$bandRowHeight->$shrunk",
) )
if (shrunk < bandAreaHeight) { if (shrunk < bandRowHeight) {
bandAreaHeight = shrunk bandRowHeight = shrunk
barRow.layoutParams = barRow.layoutParams.apply { height = bandAreaHeight } bandRows.forEach { row ->
row.layoutParams = row.layoutParams.apply { height = bandRowHeight }
}
} }
} }
// 最终兜底:宽度上限 + 内容区高度上限(仍放不下时弹窗内滚动,绝不裁切)。 // 最终兜底:宽度上限 + 内容区高度上限(仍放不下时弹窗内滚动,绝不裁切)。
@@ -1,190 +0,0 @@
package com.local.ktv
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
/**
* 均衡器竖向控制杆。
*
* Android 自带的 `SeekBar` 只支持横向,所以这里自绘一个竖向滑杆:
* - 触摸点击/拖动定位;
* - 遥控器上/下键步进(按住可连续微调);
* - 获得焦点时描一圈橙色边框(与 App 的 TV 焦点风格一致)。
*
* 数值单位是 dB,范围由 [setRange] 给出(取自设备真实支持的增益范围)。
* 绘制中包含 0 dB 基准线,并从基准线填充到当前值,便于一眼看出提升/衰减。
*/
class VerticalEqBar(context: Context) : View(context) {
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(60, 255, 255, 255)
style = Paint.Style.FILL
}
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(255, 184, 74)
style = Paint.Style.FILL
}
private val zeroPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.argb(110, 255, 255, 255)
strokeWidth = 1.5f
}
private val thumbPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
style = Paint.Style.FILL
}
private val focusPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.rgb(255, 152, 48)
style = Paint.Style.STROKE
strokeWidth = 3f
}
private val density = resources.displayMetrics.density
private var minDb = -15f
private var maxDb = 15f
private var steps = 30
private var level = 0f
/** 值变化回调(含 fromUser 标记,用于区分初始化与用户操作)。 */
var onLevelChanged: ((level: Float, fromUser: Boolean) -> Unit)? = null
init {
isFocusable = true
isFocusableInTouchMode = true
isClickable = true
// 自绘控件:禁止 TvFocusStyler 用不透明渐变覆盖背景(自己画焦点框)。
TvFocusStyler.suppressFill(this)
}
fun setRange(minDb: Float, maxDb: Float, steps: Int) {
this.minDb = minDb
this.maxDb = maxDb
this.steps = steps.coerceAtLeast(1)
invalidate()
}
fun level(): Float = level
/** 设置当前值;notify=false 用于初始化(不触发回调)。 */
fun setLevel(value: Float, notify: Boolean = false) {
val clamped = value.coerceIn(minDb, maxDb)
if (clamped == level) {
invalidate()
return
}
level = clamped
invalidate()
if (notify) onLevelChanged?.invoke(level, true)
}
/** 按刻度步进取值,保证 0 dB 一定落在某个刻度上。 */
private fun stepDb(): Float = (maxDb - minDb) / steps
private fun progressToLevel(progress: Int): Float =
(minDb + progress * stepDb()).coerceIn(minDb, maxDb)
private fun levelToProgress(value: Float): Int =
Math.round((value - minDb) / stepDb()).coerceIn(0, steps)
private fun levelFromY(y: Float): Float {
val usable = height - paddingTop - paddingBottom
if (usable <= 0) return level
val ratio = 1f - ((y - paddingTop) / usable).coerceIn(0f, 1f)
return minDb + ratio * (maxDb - minDb)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val trackWidth = 10f * density
val centerX = width / 2f
val top = paddingTop + 6f * density
val bottom = height - paddingBottom - 6f * density
if (bottom <= top) return
val radius = trackWidth / 2f
canvas.drawRoundRect(
RectF(centerX - radius, top, centerX + radius, bottom),
radius,
radius,
trackPaint,
)
val zeroRatio = ((0f - minDb) / (maxDb - minDb)).coerceIn(0f, 1f)
val zeroY = bottom - zeroRatio * (bottom - top)
canvas.drawLine(centerX - radius * 1.8f, zeroY, centerX + radius * 1.8f, zeroY, zeroPaint)
val valueRatio = ((level - minDb) / (maxDb - minDb)).coerceIn(0f, 1f)
val valueY = bottom - valueRatio * (bottom - top)
val fillTop = minOf(zeroY, valueY)
val fillBottom = maxOf(zeroY, valueY)
if (fillBottom - fillTop > 1f) {
canvas.drawRoundRect(
RectF(centerX - radius, fillTop, centerX + radius, fillBottom),
radius,
radius,
fillPaint,
)
}
canvas.drawCircle(centerX, valueY, trackWidth * 0.85f, thumbPaint)
if (isFocused) {
val inset = 1.5f * density
canvas.drawRoundRect(
RectF(inset, inset, width - inset, height - inset),
8f * density,
8f * density,
focusPaint,
)
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_MOVE -> {
if (!isFocused) requestFocus()
val target = levelFromY(event.y)
val snapped = progressToLevel(levelToProgress(target))
if (snapped != level) {
level = snapped
invalidate()
onLevelChanged?.invoke(level, true)
}
return true
}
MotionEvent.ACTION_UP -> {
performClick()
return true
}
}
return super.onTouchEvent(event)
}
override fun performClick(): Boolean {
super.performClick()
return true
}
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
val progress = levelToProgress(level)
val delta = when (keyCode) {
KeyEvent.KEYCODE_DPAD_UP -> 1
KeyEvent.KEYCODE_DPAD_DOWN -> -1
else -> return super.onKeyDown(keyCode, event)
}
val next = (progress + delta).coerceIn(0, steps)
if (next == progress) {
// 已到量程端点:放行按键,让焦点能离开滑杆(否则遥控器会困在这里出不去)。
return super.onKeyDown(keyCode, event)
}
level = progressToLevel(next)
invalidate()
onLevelChanged?.invoke(level, true)
return true
}
}