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 - } -}