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:
@@ -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
@@ -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,否则
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) }));
|
||||||
// 均衡器入口(与设备端弹窗等价): 开关 / 恢复标准
|
// 均衡器入口(与设备端弹窗等价): 开关 / 恢复标准
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user