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:
@@ -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}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 触控优化:滑块必须按住「控件点(滑块)」才能拖动,点轨道空白处不跳变数值。
|
||||
*
|
||||
* `<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) {
|
||||
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) }));
|
||||
// 均衡器入口(与设备端弹窗等价): 开关 / 恢复标准
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
// 分辨率自适应:**文字行全部 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<VerticalEqBar>(caps.bandCount)
|
||||
val valueViews = ArrayList<TextView>(caps.bandCount)
|
||||
val bars = ArrayList<EqSlider>(bandCount)
|
||||
val valueViews = ArrayList<TextView>(bandCount)
|
||||
val bandRows = ArrayList<View>(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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
// 最终兜底:宽度上限 + 内容区高度上限(仍放不下时弹窗内滚动,绝不裁切)。
|
||||
|
||||
@@ -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