diff --git a/AGENTS.md b/AGENTS.md
index fd77760..21d1319 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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)` 括号)
## 三、关键实现约束(避免踩坑)
@@ -166,14 +166,21 @@ $BT/apksigner verify 目标名.apk
因此由 500ms 的 `lyricTicker` → `syncEqualizerSessions()` 周期对齐,消失的会话延迟 3s 释放。
`globalMix`(会话 0)是设备不支持按会话挂载时的显式开关,会影响本机所有声音,默认关闭。
均衡器已并入**「调音」弹窗**(`showTuningDialog()`,自上而下:音量横向杆 → 均衡器开关 →
- 预设 → EQ 竖向杆),主界面右上角与播放界面「调音」是同一个弹窗;另有声音设置入口与
+ 预设 → EQ 频段横向杆,每个频段一行:频率 | 滑杆 | 增益),主界面右上角与播放界面「调音」
+ 是同一个弹窗;另有声音设置入口与
远程点歌页面「均衡器」卡片(`PATCH /api/v1/player/equalizer`,状态随
`GET /api/v1/state` 的 `equalizer` 字段下发)。预设:App 内为「自定义/流行/摇滚/舞曲」
(`appPresetNames()`,手动改频段即转「自定义」并自动保存),远程页面额外保留「标准」
供「恢复标准」使用。设置持久化在 `KtvStore.eq*`;
**刻度固定 30 档**(`eqSliderSteps`),步进由设备增益范围均分,保证 0 dB 一定可选中。
- EQ 竖向杆是自绘控件 `VerticalEqBar`(SeekBar 只支持横向),已 `suppressFill` 并自绘焦点框;
- 它在上下键到达量程端点时会放行按键,否则遥控器会困在滑杆上出不去
+ 音量与频段共用自绘控件 `EqSlider`(横向),已 `suppressFill` 并自绘焦点/调节描边。
+ ⚠️ **它的交互是刻意定制的,不要换回 SeekBar**:
+ - **遥控**:焦点在控件上时方向键**不**改数值(先让焦点在控件间移动);按**确认键进入调节态**
+ (描边变亮变粗)后方向键才调值,**再按确认键退出**调节态;调节态下 BACK 只退出调节态、
+ 不关弹窗;失焦自动退出。这是"需按确认键才可调节控件点、同理按确认键退出控制"的实现。
+ - **触控**:必须按住滑块(控件点)才拖动,点轨道空白不跳值(`hitsThumb` 命中判断,
+ 容差 `thumbTouchSlop`)。远程点歌页面同规则,见 `attachThumbOnlyDrag()`(app.js,对
+ `input[type=range]` 做指针命中判断,未命中则 preventDefault)
- **公放随机清单**:`RandomPlayQueue`(纯内存单例)——随机续播不再「每次现随机挑一首」,
而是一次性生成打乱顺序的临时清单,一轮内不重复;播完一首 `markFinished` +1;
`next()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。
diff --git a/app/build.gradle b/app/build.gradle
index 7b6ac6a..6fb124a 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv"
minSdk 21
targetSdk 25
- versionCode 13
- versionName "1.3.7"
+ versionCode 14
+ versionName "1.3.8"
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.css b/app/src/main/assets/mobile/app.css
index a113935..8f0306d 100644
--- a/app/src/main/assets/mobile/app.css
+++ b/app/src/main/assets/mobile/app.css
@@ -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}
.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}
diff --git a/app/src/main/assets/mobile/app.js b/app/src/main/assets/mobile/app.js
index 89ad9bf..89c93b0 100644
--- a/app/src/main/assets/mobile/app.js
+++ b/app/src/main/assets/mobile/app.js
@@ -111,6 +111,7 @@ function renderEqualizer(eq) {
bands.forEach((band, index) => {
const input = $("#eqBand" + index);
if (!input) return;
+ attachThumbOnlyDrag(input);
input.addEventListener("input", () => {
const value = $("#eqValue" + index);
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));
}
+/**
+ * 触控优化:滑块必须按住「控件点(滑块)」才能拖动,点轨道空白处不跳变数值。
+ *
+ * `` 默认点轨道任意位置都会跳值并开始拖动;这里在指针按下时先做
+ * 命中判断 —— 命中滑块才放行浏览器原生拖动,否则 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) {
try {
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));
$("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1));
// 音乐音量可调; 麦克风音量由功放硬件控制, 不再向设备发送无效果请求。
+attachThumbOnlyDrag($("#musicVolume"));
$("#musicVolume").addEventListener("input", (event) => { $("#musicValue").textContent = event.target.value; });
$("#musicVolume").addEventListener("change", (event) => updateAudio({ musicVolume: Number(event.target.value) }));
// 均衡器入口(与设备端弹窗等价): 开关 / 恢复标准
diff --git a/app/src/main/java/com/local/ktv/EqSlider.kt b/app/src/main/java/com/local/ktv/EqSlider.kt
new file mode 100644
index 0000000..d7c8b1b
--- /dev/null
+++ b/app/src/main/java/com/local/ktv/EqSlider.kt
@@ -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()
+ }
+ }
+}
diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt
index 362b531..27a52e5 100644
--- a/app/src/main/java/com/local/ktv/MainActivity.kt
+++ b/app/src/main/java/com/local/ktv/MainActivity.kt
@@ -2557,21 +2557,22 @@ class MainActivity : AppCompatActivity() {
FloatArray(caps.bandCount) { saved.getOrElse(it) { 0f } }
}
// 分辨率自适应:**文字行全部 wrap_content**(高度由文本决定,永不被压扁/裁字),
- // 只有 EQ 频段区是可伸缩的 —— 屏幕放不下时收缩的是频段区,不是文字。
+ // 只有均衡器频段行是可伸缩的 —— 屏幕放不下时收缩的是频段行高,不是文字。
val metrics = resources.displayMetrics
val screenHeightDp = metrics.heightPixels / metrics.density
- // 矮屏(高度<620dp)只用来收窄竖杆列宽;文字尺寸不随分辨率变化。
val compact = screenHeightDp < 620f
val chromeDp = if (compact) 132 else 152
val contentBudgetDp = (screenHeightDp * 0.92f - chromeDp).coerceAtLeast(240f)
- // 文字/控件行占位的保守估计(内边距 + 标题 + 音量标题 + 开关 + 预设),最终以实测量为准。
- val textRowsDp = 30f + 34f + 30f + 50f + 58f
- var bandAreaHeight = dp((contentBudgetDp - textRowsDp).coerceIn(112f, 216f).toInt())
- val barColumnWidth = dp(if (compact) 38 else 46)
+ val bandCount = caps.bandCount.coerceAtLeast(1)
+ // 非频段部分(内边距/标题/音量标题+杆/开关/预设)的保守占位,最终以实测量为准。
+ val otherRowsDp = 30f + 34f + 30f + 44f + 50f + 58f
+ var bandRowHeight = dp(
+ ((contentBudgetDp - otherRowsDp) / bandCount).coerceIn(38f, 56f).toInt(),
+ )
Log.i(
TAG,
"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 {
@@ -2616,37 +2617,31 @@ class MainActivity : AppCompatActivity() {
panel.addView(titleRow, LinearLayout.LayoutParams(-1, -2))
// ── 1. 音量:横向控制杆(即时生效)──
+ // 交互:遥控器按「确认」进入调节态后才能左右调;触控必须按住滑块才能拖动。
val volumeTitle = label("音量 $musicVolume%", 16, Color.WHITE)
panel.addView(volumeTitle, LinearLayout.LayoutParams(-1, -2))
var lastVolumeAppliedAt = 0L
- val volumeBar = SeekBar(this).apply {
- max = 100
- progress = musicVolume
- isFocusable = true
- isFocusableInTouchMode = true
- setPadding(0, 0, 0, 0)
- TvFocusStyler.installAction(this)
- setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
- override fun onProgressChanged(bar: SeekBar?, progress: Int, fromUser: Boolean) {
- musicVolume = progress.coerceIn(0, 100)
+ val volumeBar = EqSlider(this).apply {
+ setRange(0f, 100f, 100)
+ setLevel(musicVolume.toFloat())
+ onLevelChanged = { value, fromUser ->
+ if (fromUser) {
+ musicVolume = Math.round(value).coerceIn(0, 100)
volumeTitle.text = "音量 $musicVolume%"
- // 拖动时按 100ms 节流即时生效,避免每个像素都重建音轨。
+ // 按 100ms 节流即时生效,避免每一步都重建音轨。
val now = System.currentTimeMillis()
if (now - lastVolumeAppliedAt >= 100L) {
lastVolumeAppliedAt = now
applyPlaybackMode()
}
}
-
- override fun onStartTrackingTouch(bar: SeekBar?) = Unit
-
- override fun onStopTrackingTouch(bar: SeekBar?) {
- applyPlaybackMode()
- saveState()
- }
- })
+ }
+ onInteractionFinished = {
+ applyPlaybackMode()
+ saveState()
+ }
}
- panel.addView(volumeBar, LinearLayout.LayoutParams(-1, -2))
+ panel.addView(volumeBar, LinearLayout.LayoutParams(-1, dp(44)))
// ── 2. 均衡器开关(默认关闭)──
lateinit var eqSwitch: TextView
@@ -2683,8 +2678,8 @@ class MainActivity : AppCompatActivity() {
}
eqArea.addView(presetRow, LinearLayout.LayoutParams(-1, -2).apply { topMargin = dp(10) })
- // ── 4. EQ 频段:竖向控制杆 ──
- // 刻度文字用无内边距的 caption:高度由文本自身决定, 不会再被固定 dp 裁掉。
+ // ── 4. EQ 频段:横向控制杆(每个频段一行:频率 | 滑杆 | 增益)──
+ // 刻度文字用无内边距的 caption:高度由文本自身决定, 不会被固定 dp 裁掉。
fun caption(text: String, sp: Int, color: Int): TextView = TextView(this).apply {
this.text = text
textSize = sp.toFloat()
@@ -2693,8 +2688,9 @@ class MainActivity : AppCompatActivity() {
includeFontPadding = false
setPadding(0, 0, 0, 0)
}
- val bars = ArrayList(caps.bandCount)
- val valueViews = ArrayList(caps.bandCount)
+ val bars = ArrayList(bandCount)
+ val valueViews = ArrayList(bandCount)
+ val bandRows = ArrayList(bandCount)
fun syncBars() {
val applied = KtvEqualizer.levelsSnapshot()
for (index in working.indices) {
@@ -2703,25 +2699,23 @@ class MainActivity : AppCompatActivity() {
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, _ ->
- val column = LinearLayout(this).apply {
- orientation = LinearLayout.VERTICAL
- gravity = Gravity.CENTER_HORIZONTAL
+ val row = LinearLayout(this).apply {
+ orientation = LinearLayout.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(
eqLevelText(working.getOrElse(index) { 0f }),
- 12,
+ 13,
Color.argb(170, 255, 255, 255),
)
valueViews.add(value)
- column.addView(value, LinearLayout.LayoutParams(-1, -2))
- val bar = VerticalEqBar(this).apply {
- setRange(caps.minDb, caps.maxDb, eqSliderSteps)
+ val bar = EqSlider(this).apply {
+ setRange(caps.minDb, caps.maxDb, eqSliderSteps, zeroLine = true)
setLevel(working.getOrElse(index) { 0f })
onLevelChanged = { level, fromUser ->
if (fromUser) {
@@ -2737,14 +2731,13 @@ class MainActivity : AppCompatActivity() {
applyEqualizerSettings()
}
}
+ onInteractionFinished = { applyEqualizerSettings() }
}
bars.add(bar)
- column.addView(bar, LinearLayout.LayoutParams(barColumnWidth, 0, 1f))
- column.addView(
- caption(bandLabels.getOrElse(index) { "${index + 1}" } + "Hz", 12, Color.WHITE),
- LinearLayout.LayoutParams(-1, -2),
- )
- barRow.addView(column, LinearLayout.LayoutParams(0, -1, 1f))
+ row.addView(bar, LinearLayout.LayoutParams(0, -1, 1f))
+ row.addView(value, LinearLayout.LayoutParams(dp(58), -1))
+ bandRows.add(row)
+ eqArea.addView(row, LinearLayout.LayoutParams(-1, bandRowHeight))
}
KtvEqualizer.appPresetNames().forEach { name ->
@@ -2803,16 +2796,18 @@ class MainActivity : AppCompatActivity() {
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED),
)
val overflow = panel.measuredHeight - maxContentHeight
- if (overflow > 0) {
- val shrunk = (bandAreaHeight - overflow).coerceAtLeast(dp(96))
+ if (overflow > 0 && bandRows.isNotEmpty()) {
+ val shrunk = (bandRowHeight - overflow / bandRows.size).coerceAtLeast(dp(34))
Log.i(
TAG,
- "tuning dialog shrink band: measured=${panel.measuredHeight}px " +
- "max=${maxContentHeight}px band=$bandAreaHeight->$shrunk",
+ "tuning dialog shrink band rows: measured=${panel.measuredHeight}px " +
+ "max=${maxContentHeight}px row=$bandRowHeight->$shrunk",
)
- if (shrunk < bandAreaHeight) {
- bandAreaHeight = shrunk
- barRow.layoutParams = barRow.layoutParams.apply { height = bandAreaHeight }
+ if (shrunk < bandRowHeight) {
+ bandRowHeight = shrunk
+ bandRows.forEach { row ->
+ row.layoutParams = row.layoutParams.apply { height = bandRowHeight }
+ }
}
}
// 最终兜底:宽度上限 + 内容区高度上限(仍放不下时弹窗内滚动,绝不裁切)。
diff --git a/app/src/main/java/com/local/ktv/VerticalEqBar.kt b/app/src/main/java/com/local/ktv/VerticalEqBar.kt
deleted file mode 100644
index e0303d7..0000000
--- a/app/src/main/java/com/local/ktv/VerticalEqBar.kt
+++ /dev/null
@@ -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
- }
-}