V1.3.5: 调音弹窗重构(音量+均衡器) + 公放切歌修复 + 远程均衡器可折叠

1) 公放歌曲按「切歌」不再停止
   原先 advancePlayback 在已点队列为空时把「用户主动切歌」一律当作停止(进入空闲态),
   导致公放(随机)歌曲播放中按切歌就停了。现在: 若当前曲目属于公放清单
   (RandomPlayQueue.isCurrentTrack), 按切歌直接接着播清单里的下一首;
   不调用 markFinished(跳过不等于播完, 不该 +1), 但它已在 markStarted 计入本轮已播,
   所以本轮不会重复。playRandomLocalSong 新增 skipped 参数用于提示文案。

2) 「调音」弹窗重构为 App 内唯一音控入口(播放界面 + 主界面右上角同一弹窗)
   自上而下: 音量(横向控制杆, 即时生效) → 均衡器开关(默认关闭) → 均衡器预设
   (自定义/流行/摇滚/舞曲, 手改频段自动转「自定义」并保存) → EQ 频段(竖向控制杆)。
   全部即时生效, 因此没有「完成/确认」按钮, 只有关闭。
   新增自绘控件 VerticalEqBar(SeekBar 只支持横向): 触摸拖动 + 遥控器上下键步进,
   量程端点放行按键以便焦点离开, 焦点态自绘橙色边框并 suppressFill。
   均衡器预设改为 标准(仅供远程"恢复标准", 不在 App 行内)/流行/摇滚/舞曲,
   eqPreset 默认 "自定义"。
   清除旧 UI 与代码: 删除 dialog_tuning.xml / dialog_audio_control.xml /
   CircularVolumeView.kt, 移除 showAudioControlDialog / showAlignedTuningDialog /
   showEqualizerDialog / showFullScreenVolumeDialog / bindAtmospherePreset /
   updateToggleState 及 fullScreenVolume* / fullScreenToneControl /
   hideFullScreenVolumeIndicatorRunnable 等字段与 dispatchKeyEvent 中的面板分支。
   设置页「音频控制」改为指向调音弹窗(麦克风音量仍提示由功放硬件调节;
   升降调保留在下一页「音调控制」)。

3) 远程点歌页面均衡器界面不变, 均衡器文字栏改为折叠按钮(默认收起)。

版本号 1.3.4 -> 1.3.5(versionCode 10 -> 11)。按约定本次不发布 Release, 仅本地打包测试。
This commit is contained in:
zijunchiang
2026-09-20 15:38:48 +08:00
parent dcc2acb719
commit ce57068c68
12 changed files with 416 additions and 1039 deletions
+12 -7
View File
@@ -108,7 +108,7 @@ $BT/apksigner verify 目标名.apk
### 版本号
- `app/build.gradle`:`versionName "1.3.4"` / `versionCode 10`
- `app/build.gradle`:`versionName "1.3.5"` / `versionCode 11`
- App 内显示统一用纯版本号(不加 `(versionCode)` 括号)
## 三、关键实现约束(避免踩坑)
@@ -154,8 +154,8 @@ $BT/apksigner verify 目标名.apk
统一在 `applyPlaybackMode()` 内按线性系数施加到播放器(`KtvVideoView.setPlaybackVolume` /
`selectAudioChannel`)与副音轨播放器(`vocalPlayer` / `embeddedVocalEngine`)。
**严禁调用 `AudioManager.setStreamVolume` / `adjustStreamVolume` 修改系统音量**。
三条入口必须全部收敛到 `applyPlaybackMode()`:播放界面-调音(`showFullScreenVolumeDialog()` 与
`showAudioControlDialog()` 的音乐音量滑杆)、声音设置-歌曲最大音量、远程控制-音乐音量
三条入口必须全部收敛到 `applyPlaybackMode()`:**「调音」弹窗**(`showTuningDialog()`,播放界面与
主界面右上角共用同一个弹窗)的音量横向控制杆、声音设置-歌曲最大音量、远程控制-音乐音量
(`remoteAudio()` 的 `musicVolume`)。曲线是纯衰减(100% 即 unity gain),不做系统级增益;
设备最终响度仍取决于系统音量,App 不再替用户改动它
- **音频均衡器**:内核 `KtvEqualizer`(参考开源项目 OpenEQ,`Turbofan3360/OpenEQ`,GPL-3.0 的
@@ -165,10 +165,15 @@ $BT/apksigner verify 目标名.apk
(IJK 的原生 `IjkMediaPlayer_getAudioSessionId` 返回真实 AudioTrack 会话)。会话会随歌曲变化,
因此由 500ms 的 `lyricTicker` → `syncEqualizerSessions()` 周期对齐,消失的会话延迟 3s 释放。
`globalMix`(会话 0)是设备不支持按会话挂载时的显式开关,会影响本机所有声音,默认关闭。
四个入口:主页顶栏「调音」弹窗里的「均衡器」、播放界面「调音」面板里的「均衡器」、
声音设置「均衡器入口」、远程点歌页面「均衡器」卡片(`PATCH /api/v1/player/equalizer`,
状态随 `GET /api/v1/state` 的 `equalizer` 字段下发)。设置持久化在 `KtvStore.eq*`;
**滑杆刻度固定 30 档**(`eqSliderSteps`),步进由设备增益范围均分,保证 0 dB 一定可选中
均衡器已并入**「调音」弹窗**(`showTuningDialog()`,自上而下:音量横向杆 → 均衡器开关 →
预设 → EQ 竖向杆),主界面右上角与播放界面「调音」是同一个弹窗;另有声音设置入口与
远程点歌页面「均衡器」卡片(`PATCH /api/v1/player/equalizer`,状态随
`GET /api/v1/state` 的 `equalizer` 字段下发)。预设:App 内为「自定义/流行/摇滚/舞曲」
(`appPresetNames()`,手动改频段即转「自定义」并自动保存),远程页面额外保留「标准」
供「恢复标准」使用。设置持久化在 `KtvStore.eq*`;
**刻度固定 30 档**(`eqSliderSteps`),步进由设备增益范围均分,保证 0 dB 一定可选中。
EQ 竖向杆是自绘控件 `VerticalEqBar`(SeekBar 只支持横向),已 `suppressFill` 并自绘焦点框;
它在上下键到达量程端点时会放行按键,否则遥控器会困在滑杆上出不去
- **公放随机清单**:`RandomPlayQueue`(纯内存单例)——随机续播不再「每次现随机挑一首」,
而是一次性生成打乱顺序的临时清单,一轮内不重复;播完一首 `markFinished` +1;
`next()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId "com.local.ktv"
minSdk 21
targetSdk 25
versionCode 10
versionName "1.3.4"
versionCode 11
versionName "1.3.5"
ndk {
// 双 ABI 打包:兼容 64 位(Exynos 1580 等 arm64-v8a)与 32 位设备(极米 Play 超越版
// 的 Amlogic 950X2/S905X2 采用 32 位 Android 用户态,必须包含 armeabi-v7a,否则
+1
View File
@@ -17,3 +17,4 @@
.empty.compact{padding:42px 15px}
.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}
+7
View File
@@ -275,6 +275,13 @@ $("#musicVolume").addEventListener("change", (event) => updateAudio({ musicVolum
// 均衡器入口(与设备端弹窗等价): 开关 / 恢复标准
$("#eqToggle")?.addEventListener("click", () => updateEqualizer({ enabled: !uiState.eqEnabled }));
$("#eqReset")?.addEventListener("click", () => updateEqualizer({ preset: "标准" }));
// 均衡器文字栏作为折叠按钮: 默认收起; 展开后在同一次页面会话内保持, 重新加载页面回到收起态。
$("#eqCollapse")?.addEventListener("click", () => {
const body = $("#eqBody");
if (!body) return;
const collapsed = body.classList.toggle("hidden");
$("#eqCollapse").setAttribute("aria-expanded", collapsed ? "false" : "true");
});
document.addEventListener("visibilitychange", () => { uiState.visible = !document.hidden; if (uiState.visible) refresh(); });
window.setInterval(() => { if (uiState.visible) refresh(); }, 1000);
refresh();
+6 -1
View File
@@ -41,7 +41,11 @@
</article>
<article class="glass-card eq-panel">
<div class="panel-title"><div><span class="tiny-icon">≡</span><b>均衡器</b></div><small id="eqState">已关闭</small></div>
<button type="button" class="panel-title eq-title" id="eqCollapse" aria-expanded="false">
<div><span class="tiny-icon">≡</span><b>均衡器</b></div>
<span class="eq-title-right"><small id="eqState">已关闭</small><i class="eq-chevron">▾</i></span>
</button>
<div id="eqBody" class="eq-body hidden">
<div class="seg eq-switch">
<button type="button" id="eqToggle">开启均衡器</button>
<button type="button" id="eqReset">恢复标准</button>
@@ -49,6 +53,7 @@
<div id="eqBands" class="eq-bands"></div>
<div id="eqPresets" class="eq-presets"></div>
<p class="eq-hint" id="eqHint">均衡器作用于本机播放输出,不改动系统音量</p>
</div>
</article>
<article class="next-card"><div class="next-number">02</div><div><small>下一首</small><b id="nextSong">队列为空</b></div><button data-page="queue">查看队列 <span>›</span></button></article>
@@ -1,71 +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.View
import kotlin.math.min
/** Full-screen volume indicator modelled after the original KTV circular overlay. */
class CircularVolumeView(context: Context) : View(context) {
private val density = resources.displayMetrics.density
private val arcBounds = RectF()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var volume = 0
fun setVolume(value: Int) {
volume = value.coerceIn(0, 100)
contentDescription = "音量 $volume"
invalidate()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desired = (154f * density).toInt()
val width = resolveSize(desired, widthMeasureSpec)
val height = resolveSize(desired, heightMeasureSpec)
val size = min(width, height)
setMeasuredDimension(size, size)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val size = min(width, height).toFloat()
val center = size / 2f
val outerWidth = 12f * density
val progressWidth = 12f * density
val radius = center - outerWidth
paint.style = Paint.Style.FILL
paint.color = Color.rgb(48, 39, 102)
canvas.drawCircle(center, center, radius - progressWidth * 0.15f, paint)
paint.style = Paint.Style.STROKE
paint.strokeCap = Paint.Cap.BUTT
paint.strokeWidth = outerWidth
paint.color = Color.rgb(47, 37, 106)
canvas.drawCircle(center, center, radius, paint)
val ringRadius = radius - outerWidth * 0.52f
arcBounds.set(
center - ringRadius,
center - ringRadius,
center + ringRadius,
center + ringRadius,
)
paint.strokeWidth = progressWidth
paint.color = Color.rgb(60, 51, 111)
canvas.drawArc(arcBounds, -90f, 360f, false, paint)
paint.color = Color.rgb(151, 155, 143)
canvas.drawArc(arcBounds, -90f, volume * 3.6f, false, paint)
paint.style = Paint.Style.FILL
paint.textAlign = Paint.Align.CENTER
paint.color = Color.rgb(220, 220, 214)
paint.textSize = 30f * density
canvas.drawText(volume.toString(), center, center - 3f * density, paint)
paint.textSize = 20f * density
canvas.drawText("音量", center, center + 38f * density, paint)
}
}
@@ -55,25 +55,30 @@ object KtvEqualizer {
/**
* 预设曲线:以「频段在可用范围内的相对位置 0..1」为横轴、以最大增益的比例 -1..1 为纵轴。
* 这样同一套预设能自适应任意频段数的设备。
*
* `标准` 不显示在 App 的预设行里(App 用「自定义」表示用户自己调的状态),
* 但保留给远程页面的「恢复标准」按钮使用。
*/
private val PRESETS: LinkedHashMap<String, List<Pair<Float, Float>>> = linkedMapOf(
"标准" to listOf(0f to 0f),
"流行" to listOf(0f to 0.35f, 0.25f to -0.15f, 0.6f to 0.1f, 1f to 0.35f),
"抒情" to listOf(0f to -0.1f, 0.35f to 0.25f, 0.7f to 0.15f, 1f to -0.05f),
"摇滚" to listOf(0f to 0.7f, 0.3f to -0.4f, 1f to 0.6f),
"唱将" to listOf(0f to -0.2f, 0.5f to 0.55f, 0.8f to 0.3f, 1f to 0.05f),
"舞曲" to listOf(0f to 0.85f, 0.22f to 0.2f, 0.5f to -0.35f, 0.78f to 0.25f, 1f to 0.65f),
)
/** 手动改过频段后使用的预设名。 */
const val CUSTOM_PRESET = "自定义"
/** App 内「调音」弹窗预设行的顺序(含「自定义」)。 */
fun appPresetNames(): List<String> = listOf(CUSTOM_PRESET, "流行", "摇滚", "舞曲")
@Volatile var enabled: Boolean = false
private set
@Volatile var globalMix: Boolean = false
private set
@Volatile var preset: String = "标准"
@Volatile var preset: String = CUSTOM_PRESET
private set
private var levels: FloatArray = FloatArray(0)
+2 -2
View File
@@ -49,7 +49,7 @@ class KtvStore {
@JvmField var tableBroadcastText = "欢迎光临"
// ===== 均衡器(KtvEqualizer)=====
@JvmField var eqEnabled = false
@JvmField var eqPreset = "标准"
@JvmField var eqPreset = "自定义"
/** 各频段增益(dB),逗号分隔;不同设备频段数可能不同,加载后会按实际频段数对齐。 */
@JvmField var eqLevels = ""
@JvmField var eqGlobalMix = false
@@ -103,7 +103,7 @@ class KtvStore {
tableBroadcastSeconds = root.optInt("tableBroadcastSeconds", 8)
tableBroadcastText = root.optString("tableBroadcastText", "欢迎光临")
eqEnabled = root.optBoolean("eqEnabled", false)
eqPreset = root.optString("eqPreset", "标准")
eqPreset = root.optString("eqPreset", "自定义")
eqLevels = root.optString("eqLevels", "")
eqGlobalMix = root.optBoolean("eqGlobalMix", false)
readSongs(root.optJSONArray("orderQueue"), orderQueue)
+180 -585
View File
@@ -1004,8 +1004,8 @@ class MainActivity : AppCompatActivity() {
addTile(
grid,
"音量音调",
"原伴唱 / 音量 / 升降调",
View.OnClickListener { v: View? -> showAudioControlDialog() })
"音量 / 均衡器",
View.OnClickListener { v: View? -> showTuningDialog() })
addTile(
grid,
"气氛模式",
@@ -1118,7 +1118,7 @@ class MainActivity : AppCompatActivity() {
controls.addView(
button(
"音控",
View.OnClickListener { v: View? -> showAudioControlDialog() }),
View.OnClickListener { v: View? -> showTuningDialog() }),
LinearLayout.LayoutParams(0, dp(50), 1f)
)
controls.addView(
@@ -1858,23 +1858,23 @@ class MainActivity : AppCompatActivity() {
}
1 -> {
// 音频控制(链接到调音弹窗)
// 音量与均衡器统一走「调音」弹窗(升降调在下一页单独设置)
addSettingsTitle(content, "音频控制")
addSettingsItem(
content,
"音量与均衡器",
"音量 " + musicVolume + "% · 均衡器" + (if (KtvEqualizer.enabled) "开" else "关"),
View.OnClickListener { v: View? -> showTuningDialog() })
addSettingsItem(
content,
"均衡器预设",
KtvEqualizer.preset,
View.OnClickListener { v: View? -> showTuningDialog() })
addSettingsItem(
content,
"麦克风音量",
micVolume.toString() + "%",
View.OnClickListener { v: View? -> showAudioControlDialog() })
addSettingsItem(
content,
"音乐音量",
musicVolume.toString() + "%",
View.OnClickListener { v: View? -> showAudioControlDialog() })
addSettingsItem(
content,
"音调",
(if (tone > 0) "+" else "") + tone + " 调",
View.OnClickListener { v: View? -> showAudioControlDialog() })
"由功放/混音器硬件调节",
View.OnClickListener { v: View? -> toast("麦克风音量由功放/混音器硬件调节") })
addSettingsItem(
content,
"原唱模式",
@@ -2128,7 +2128,7 @@ class MainActivity : AppCompatActivity() {
* * `onPlayPause` → [.togglePlay]
* * `onPrev` / `onNext` → [.playPrevSong] / [.playNext]
* * `onVocalSwitch` → 使用 [VocalSwitchHelper] 切换原唱/伴唱声道
* * `onVolume` → [.showAudioControlDialog]
* * `onVolume` → [.showTuningDialog]
* * `onFullScreen` → [.showFullScreenPlayer]
*
*/
@@ -2152,7 +2152,7 @@ class MainActivity : AppCompatActivity() {
}
override fun onVolume() {
showAudioControlDialog()
showTuningDialog()
}
override fun onFullScreen() {
@@ -2266,8 +2266,7 @@ class MainActivity : AppCompatActivity() {
if (playWhenPrepared) "暂停" else "播放"
) { togglePlay() }.also { (text, icon) -> fullScreenPauseText = text; fullScreenPauseIcon = icon }
addControl(R.drawable.ott_ic_ctrl_replay, "重唱") { replay() }
addControl(R.drawable.ott_ic_ctrl_volume, "调音") { showFullScreenVolumeDialog() }
fullScreenToneControl = controlItems.lastOrNull()
addControl(R.drawable.ott_ic_ctrl_volume, "调音") { showTuningDialog() }
controlItems.forEachIndexed { index, item ->
item.nextFocusLeftId = controlItems.getOrNull(index - 1)?.id ?: item.id
@@ -2359,16 +2358,9 @@ class MainActivity : AppCompatActivity() {
fullScreenSeeking = false
fullScreenControls = null
fullScreenProgressRow = null
fullScreenVolumePanel = null
fullScreenVolumeIndicator = null
fullScreenVolumeMinusButton = null
fullScreenVolumePlusButton = null
fullScreenVolumeMuteButton = null
fullScreenToneControl = null
fullScreenQrOverlay = null
fullScreenChromeVisible = false
main.removeCallbacks(hideFullScreenChromeRunnable)
main.removeCallbacks(hideFullScreenVolumeIndicatorRunnable)
scheduleAutoFullscreen()
}
@@ -2377,11 +2369,10 @@ class MainActivity : AppCompatActivity() {
fullScreenChromeVisible = visible
fullScreenControls?.visibility = if (visible) View.VISIBLE else View.GONE
fullScreenProgressRow?.visibility = if (visible) View.VISIBLE else View.GONE
if (!visible) fullScreenVolumePanel?.visibility = View.GONE
main.removeCallbacks(hideFullScreenChromeRunnable)
if (visible && isFullScreen) {
val controls = fullScreenControls
if (controls != null && !controls.hasFocus() && fullScreenVolumePanel?.hasFocus() != true) {
if (controls != null && !controls.hasFocus()) {
controls.getChildAt(0)?.requestFocus()
}
main.postDelayed(hideFullScreenChromeRunnable, 5000L)
@@ -2411,140 +2402,9 @@ class MainActivity : AppCompatActivity() {
* 供 [FullScreenPlayerFragment] 调用打开音量控制面板。
*/
fun showAudioControlFromFragment() {
showAudioControlDialog()
showTuningDialog()
}
private fun showFullScreenVolumeDialog() {
if (!isFullScreen || fullScreenContainer == null) return
val existing = fullScreenVolumePanel
if (existing != null) {
existing.visibility = if (existing.visibility == View.VISIBLE) View.GONE else View.VISIBLE
if (existing.visibility == View.VISIBLE) {
fullScreenVolumeMinusButton?.requestFocus()
} else {
fullScreenToneControl?.requestFocus()
}
return
}
if (musicVolume > 0) fullScreenPreviousVolume = musicVolume
val panel = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
// 仅用于吞掉触摸(点击穿透到全屏容器会误切控制层显示), 不是操作项;
// 因此禁止焦点/按下填充, 否则触摸时整块面板会变成纯色。
isClickable = true
}
TvFocusStyler.suppressFill(panel)
val indicator = CircularVolumeView(this).apply {
setVolume(musicVolume)
visibility = View.GONE
alpha = 1f
}
panel.addView(indicator, LinearLayout.LayoutParams(dp(154), dp(154)).apply {
bottomMargin = dp(22)
})
panel.addView(label("调音", 26, Color.WHITE).apply {
gravity = Gravity.CENTER
}, LinearLayout.LayoutParams(dp(420), dp(54)))
val buttons = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
}
fun volumeButton(text: String): TextView = TextView(this).apply {
this.text = text
id = View.generateViewId()
gravity = Gravity.CENTER
textSize = 18f
setTextColor(Color.WHITE)
setBackgroundResource(R.drawable.ott_bg_volume_ctrl_action_button)
isFocusable = true
isFocusableInTouchMode = true
isClickable = true
includeFontPadding = false
setPadding(dp(14), 0, dp(14), 0)
}
val minus = volumeButton("音量 -")
val plus = volumeButton("音量 +")
val mute = volumeButton("静音").apply {
compoundDrawablePadding = dp(8)
}
fun renderMuteButton() {
mute.text = if (musicVolume == 0) "放音" else "静音"
val drawable = getDrawable(
if (musicVolume == 0) R.drawable.ic_ctrl_sound_enable
else R.drawable.ic_ctrl_sound_disable
)
drawable?.setBounds(0, 0, dp(23), dp(23))
mute.setCompoundDrawables(null, null, drawable, null)
}
fun showVolumeResult() {
indicator.animate().cancel()
indicator.setVolume(musicVolume)
indicator.alpha = 1f
indicator.visibility = View.VISIBLE
renderMuteButton()
// 只调 App 内部输出音量, 不改系统音量(见 applyPlaybackMode)。
applyPlaybackMode()
saveState()
main.removeCallbacks(hideFullScreenVolumeIndicatorRunnable)
main.postDelayed(hideFullScreenVolumeIndicatorRunnable, 2200L)
setFullScreenChromeVisible(true)
}
minus.setOnClickListener {
musicVolume = (musicVolume - 5).coerceAtLeast(0)
if (musicVolume > 0) fullScreenPreviousVolume = musicVolume
showVolumeResult()
}
plus.setOnClickListener {
musicVolume = (musicVolume + 5).coerceAtMost(100)
if (musicVolume > 0) fullScreenPreviousVolume = musicVolume
showVolumeResult()
}
mute.setOnClickListener {
if (musicVolume > 0) {
fullScreenPreviousVolume = musicVolume
musicVolume = 0
} else {
musicVolume = fullScreenPreviousVolume.coerceAtLeast(5)
}
showVolumeResult()
}
// 播放界面「调音」面板里的均衡器入口(与主页顶栏「调音」弹窗里的入口等价)。
val equalizer = volumeButton("均衡器")
equalizer.setOnClickListener { showEqualizerDialog() }
val volumeButtons = listOf(minus, plus, mute, equalizer)
volumeButtons.forEachIndexed { index, button ->
button.nextFocusLeftId = volumeButtons.getOrNull(index - 1)?.id ?: button.id
button.nextFocusRightId = volumeButtons.getOrNull(index + 1)?.id ?: button.id
button.nextFocusUpId = fullScreenToneControl?.id ?: button.id
button.nextFocusDownId = button.id
buttons.addView(button, LinearLayout.LayoutParams(dp(136), dp(58)).apply {
leftMargin = dp(6)
rightMargin = dp(6)
})
}
panel.addView(buttons, LinearLayout.LayoutParams(-2, dp(70)).apply { topMargin = dp(12) })
renderMuteButton()
fullScreenContainer!!.addView(
panel,
FrameLayout.LayoutParams(-2, -2, Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM).apply {
bottomMargin = dp(86)
},
)
fullScreenVolumePanel = panel
fullScreenVolumeIndicator = indicator
fullScreenVolumeMinusButton = minus
fullScreenVolumePlusButton = plus
fullScreenVolumeMuteButton = mute
minus.requestFocus()
}
/**
* 切换到上一首歌曲(供控制层 onPrev 回调使用)。
*/
fun playPrevSong() {
if (orderQueue!!.isEmpty() || currentSong == null) return
val index = orderQueue.indexOf(currentSong!!)
@@ -2590,197 +2450,6 @@ class MainActivity : AppCompatActivity() {
saveState()
toast("声道模式:" + vocalChannelMode)
}
/**
* 显示调音/音频控制弹窗(对应"弹窗-调音.jpg")
* 使用 dialog_audio_control.xml 实现
*/
private fun showAudioControlDialog() {
if (showAlignedTuningDialog()) return
val root = getLayoutInflater().inflate(R.layout.dialog_audio_control, null)
val btnClose = root.findViewById<TextView>(R.id.btn_audio_close)
val btnStereo = root.findViewById<TextView>(R.id.btn_tone_stereo)
val btnAccomp = root.findViewById<TextView>(R.id.btn_tone_accomp)
val btnOrigin = root.findViewById<TextView>(R.id.btn_tone_origin)
val btnMute = root.findViewById<TextView>(R.id.btn_tone_mute)
val seekMicVol = root.findViewById<SeekBar>(R.id.seek_mic_volume)
val seekMicEffect = root.findViewById<SeekBar>(R.id.seek_mic_effect)
val seekMusicVol = root.findViewById<SeekBar>(R.id.seek_music_volume)
val tvMicVol = root.findViewById<TextView>(R.id.text_mic_volume_value)
val tvMicEffect = root.findViewById<TextView>(R.id.text_mic_effect_value)
val tvMusicVol = root.findViewById<TextView>(R.id.text_music_volume_value)
val btnDefault = root.findViewById<TextView>(R.id.btn_audio_default)
val btnSave = root.findViewById<TextView>(R.id.btn_audio_save)
// 初始值
seekMicVol.setProgress(micVolume)
seekMicEffect.setProgress(50)
seekMusicVol.setProgress(musicVolume)
tvMicVol.setText(micVolume.toString())
tvMicEffect.setText("50")
tvMusicVol.setText(musicVolume.toString())
// 4 个切换按钮:伴唱 / 原唱 / 立体声 / 静音
updateToggleState(btnAccomp, !originalVocal)
updateToggleState(btnOrigin, originalVocal)
updateToggleState(btnStereo, "自动" == vocalChannelMode)
updateToggleState(btnMute, false)
btnAccomp.setOnClickListener(View.OnClickListener { v: View? ->
if (originalVocal) toggleOriginalVocal()
updateToggleState(btnAccomp, true)
updateToggleState(btnOrigin, false)
})
btnOrigin.setOnClickListener(View.OnClickListener { v: View? ->
if (!originalVocal) toggleOriginalVocal()
updateToggleState(btnAccomp, false)
updateToggleState(btnOrigin, true)
})
btnStereo.setOnClickListener(View.OnClickListener { v: View? ->
setVocalChannelMode("自动")
updateToggleState(btnStereo, true)
})
btnMute.setOnClickListener(View.OnClickListener { v: View? ->
if (musicVolume > 0) {
muteBeforeVol = musicVolume
musicVolume = 0
} else {
musicVolume = muteBeforeVol
}
applyPlaybackMode()
seekMusicVol.setProgress(musicVolume)
tvMusicVol.setText(musicVolume.toString())
updateToggleState(btnMute, musicVolume == 0)
saveState()
})
// 3 个滑块
seekMicVol.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(s: SeekBar?, p: Int, u: Boolean) {
micVolume = p
tvMicVol.setText(p.toString())
}
override fun onStartTrackingTouch(s: SeekBar?) {}
override fun onStopTrackingTouch(s: SeekBar?) {
saveState()
}
})
seekMicEffect.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(s: SeekBar?, p: Int, u: Boolean) {
tvMicEffect.setText(p.toString())
}
override fun onStartTrackingTouch(s: SeekBar?) {}
override fun onStopTrackingTouch(s: SeekBar?) {}
})
seekMusicVol.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(s: SeekBar?, p: Int, u: Boolean) {
musicVolume = p
tvMusicVol.setText(p.toString())
}
override fun onStartTrackingTouch(s: SeekBar?) {}
override fun onStopTrackingTouch(s: SeekBar?) {
applyPlaybackMode()
saveState()
}
})
// 默认 / 保存
btnDefault.setOnClickListener(View.OnClickListener { v: View? ->
seekMicVol.setProgress(60)
seekMicEffect.setProgress(50)
seekMusicVol.setProgress(KtvStore.DEFAULT_MUSIC_VOLUME)
micVolume = 60
musicVolume = KtvStore.DEFAULT_MUSIC_VOLUME
applyPlaybackMode()
saveState()
tvMicVol.setText("60")
tvMicEffect.setText("50")
tvMusicVol.setText("80")
updateToggleState(btnMute, false)
})
// 创建并显示弹窗
val dialog = AlertDialog.Builder(this)
.setView(root)
.create()
// 透明背景显示圆角
if (dialog.getWindow() != null) {
dialog.getWindow()!!.setBackgroundDrawableResource(android.R.color.transparent)
}
btnClose.setOnClickListener(View.OnClickListener { v: View? -> dialog.dismiss() })
btnSave.setOnClickListener(View.OnClickListener { v: View? ->
saveState()
dialog.dismiss()
})
dialog.restoreSourceFocusOnDismiss()
dialog.show()
}
private fun showAlignedTuningDialog(): Boolean {
val root = getLayoutInflater().inflate(R.layout.dialog_tuning, null)
val toneValue = root.findViewById<TextView>(R.id.text_tone_counter)
val micValue = root.findViewById<TextView>(R.id.text_mic_counter)
toneValue.setText(tone.toString())
micValue.setText(micVolume.toString())
val dialog = AlertDialog.Builder(this).setView(root).create()
root.findViewById<View?>(R.id.btn_tuning_close)
.setOnClickListener(View.OnClickListener { v: View? -> dialog.dismiss() })
root.findViewById<View?>(R.id.btn_tone_down)
.setOnClickListener(View.OnClickListener { v: View? ->
tone = max(-5, tone - 1)
toneValue.setText(tone.toString())
player?.setTone(tone)
applyPlaybackMode()
saveState()
})
root.findViewById<View?>(R.id.btn_tone_up)
.setOnClickListener(View.OnClickListener { v: View? ->
tone = min(5, tone + 1)
toneValue.setText(tone.toString())
player?.setTone(tone)
applyPlaybackMode()
saveState()
})
root.findViewById<View?>(R.id.btn_mic_down)
.setOnClickListener(View.OnClickListener { v: View? ->
toast("麦克风音量由功放/混音器硬件调节")
})
root.findViewById<View?>(R.id.btn_mic_up)
.setOnClickListener(View.OnClickListener { v: View? ->
toast("麦克风音量由功放/混音器硬件调节")
})
bindAtmospherePreset(root, R.id.btn_preset_pop, "流行")
bindAtmospherePreset(root, R.id.btn_preset_lyric, "抒情")
bindAtmospherePreset(root, R.id.btn_preset_rock, "摇滚")
bindAtmospherePreset(root, R.id.btn_preset_singer, "唱将")
// 均衡器入口:主页顶栏「调音」/播放界面「调音」/控制层「音量」都走本弹窗。
root.findViewById<View?>(R.id.btn_tuning_equalizer)?.setOnClickListener {
dialog.dismiss()
showEqualizerDialog()
}
dialog.showForTv(afterShow = {
dialog.window?.apply {
setBackgroundDrawableResource(android.R.color.transparent)
attributes = attributes.apply { dimAmount = 0.72f }
addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
}
root.findViewById<View>(R.id.btn_tone_down).requestFocus()
})
return true
}
private fun bindAtmospherePreset(root: View, id: Int, preset: String) {
root.findViewById<View?>(id).setOnClickListener(View.OnClickListener { v: View? ->
atmosphere = preset
saveState()
toast("音效: " + preset)
})
}
// ===== 均衡器 =====
/** 把持久化的 "1.0,-2.0,..." 解析成频段增益数组。 */
@@ -2822,56 +2491,32 @@ class MainActivity : AppCompatActivity() {
syncEqualizerSessions()
saveState()
}
/**
* 均衡器弹窗 —— App 内统一入口(主页顶栏「调音」、播放界面「调音」、
* 声音设置「均衡器入口」,以及远程点歌页面的同名入口)。
* 「调音」弹窗 —— 播放界面与主界面右上角共用的**唯一**调音入口。
*
* 内核见 [KtvEqualizer]:参考开源项目 OpenEQ,直接使用系统 `audiofx.Equalizer`,
* 并按设备真实支持的频段数与增益范围自适应,因此频段是动态生成的。
* UI 与「调音」弹窗保持一致:深色圆角面板 + 紫色频段卡 + 橙色焦点反馈。
* 自上而下(全部即时生效,所以没有「完成 / 确认」按钮):
* 1. 音量:横向控制杆 —— App 内部输出音量,不改动系统音量;
* 2. 均衡器开关(默认关闭);
* 3. 均衡器预设:自定义(用户改动频段后自动保存)/ 流行 / 摇滚 / 舞曲;
* 4. EQ 频段调节:竖向控制杆(频段数与增益范围取自设备真实能力)。
*/
private fun showEqualizerDialog() {
private fun showTuningDialog() {
val caps = KtvEqualizer.capabilities(this)
val bandLabels = KtvEqualizer.bandLabels(caps)
val working = KtvEqualizer.levelsSnapshot().let { saved ->
FloatArray(caps.bandCount) { saved.getOrElse(it) { 0f } }
}
val globalSupported = KtvEqualizer.globalMixSupported()
val panel = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
background = getDrawable(R.drawable.bg_tuning_dialog)
setPadding(dp(26), dp(16), dp(26), dp(18))
setPadding(dp(26), dp(14), dp(26), dp(16))
}
TvFocusStyler.suppressFill(panel)
// ── 标题 ──
val titleRow = FrameLayout(this)
titleRow.addView(
label("均衡器", 24, Color.WHITE).apply { gravity = Gravity.CENTER },
FrameLayout.LayoutParams(-1, dp(46)),
)
val close = label("×", 22, Color.argb(153, 255, 255, 255)).apply {
gravity = Gravity.CENTER
isClickable = true
isFocusable = true
TvFocusStyler.installAction(this)
}
titleRow.addView(
close,
FrameLayout.LayoutParams(dp(44), dp(40), Gravity.END or Gravity.CENTER_VERTICAL),
)
panel.addView(titleRow, LinearLayout.LayoutParams(-1, dp(46)))
// ── 状态行:总开关 / 全局混音 / 重置 ──
val switchRow = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
fun pill(text: String, action: () -> Unit): TextView = TextView(this).apply {
this.text = text
textSize = 16f
textSize = 15f
setTextColor(Color.WHITE)
gravity = Gravity.CENTER
background = getDrawable(R.drawable.bg_btn_glass)
@@ -2881,66 +2526,79 @@ class MainActivity : AppCompatActivity() {
setOnClickListener { action() }
TvFocusStyler.installAction(this)
}
lateinit var enabledPill: TextView
lateinit var globalPill: TextView
fun renderPills() {
enabledPill.text = if (KtvEqualizer.enabled) "均衡器:开启" else "均衡器:关闭"
enabledPill.setTextColor(if (KtvEqualizer.enabled) Color.rgb(39, 204, 164) else Color.WHITE)
globalPill.text = if (!globalSupported) {
"全局混音:不支持"
} else if (KtvEqualizer.globalMix) {
"全局混音:开"
} else {
"全局混音:关"
// ── 标题 ──
val titleRow = FrameLayout(this)
titleRow.addView(
label("调音", 23, Color.WHITE).apply { gravity = Gravity.CENTER },
FrameLayout.LayoutParams(-1, dp(42)),
)
val close = label("×", 22, Color.argb(153, 255, 255, 255)).apply {
gravity = Gravity.CENTER
isClickable = true
isFocusable = true
TvFocusStyler.installAction(this)
}
globalPill.alpha = if (globalSupported) 1f else 0.45f
titleRow.addView(
close,
FrameLayout.LayoutParams(dp(44), dp(38), Gravity.END or Gravity.CENTER_VERTICAL),
)
panel.addView(titleRow, LinearLayout.LayoutParams(-1, dp(42)))
// ── 1. 音量:横向控制杆(即时生效)──
val volumeTitle = label("音量 $musicVolume%", 16, Color.WHITE)
panel.addView(volumeTitle, LinearLayout.LayoutParams(-1, dp(28)))
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)
volumeTitle.text = "音量 $musicVolume%"
// 拖动时按 100ms 节流即时生效,避免每个像素都重建音轨。
val now = System.currentTimeMillis()
if (now - lastVolumeAppliedAt >= 100L) {
lastVolumeAppliedAt = now
applyPlaybackMode()
}
enabledPill = pill(if (KtvEqualizer.enabled) "均衡器:开启" else "均衡器:关闭") {
}
override fun onStartTrackingTouch(bar: SeekBar?) = Unit
override fun onStopTrackingTouch(bar: SeekBar?) {
applyPlaybackMode()
saveState()
}
})
}
panel.addView(volumeBar, LinearLayout.LayoutParams(-1, dp(44)))
// ── 2. 均衡器开关(默认关闭)──
lateinit var eqSwitch: TextView
lateinit var eqArea: LinearLayout
fun renderEqEnabled() {
eqSwitch.text = if (KtvEqualizer.enabled) "均衡器:开启" else "均衡器:关闭"
eqSwitch.setTextColor(if (KtvEqualizer.enabled) Color.rgb(39, 204, 164) else Color.WHITE)
// 关闭时预设/频段整体降透明度,直观表达"当前不生效"。
eqArea.alpha = if (KtvEqualizer.enabled) 1f else 0.45f
}
eqSwitch = pill(if (KtvEqualizer.enabled) "均衡器:开启" else "均衡器:关闭") {
KtvEqualizer.setEnabled(!KtvEqualizer.enabled)
renderPills()
renderEqEnabled()
applyEqualizerSettings()
toast(if (KtvEqualizer.enabled) "均衡器已开启" else "均衡器已关闭")
}
if (KtvEqualizer.enabled) enabledPill.setTextColor(Color.rgb(39, 204, 164))
globalPill = pill("全局混音:关") {
if (!globalSupported) {
toast("本机不支持全局混音")
return@pill
}
KtvEqualizer.setGlobalMix(!KtvEqualizer.globalMix)
renderPills()
applyEqualizerSettings()
toast(if (KtvEqualizer.globalMix) "均衡器已作用于全局混音" else "均衡器只作用于本 App")
}
// 「重置」要回到『标准』预设并刷新滑杆,但滑杆此时还没建好,用 lateinit 延后绑定。
lateinit var resetEqualizer: () -> Unit
val resetPill = pill("重置") { resetEqualizer() }
renderPills()
switchRow.addView(enabledPill, LinearLayout.LayoutParams(-2, dp(40)).apply { marginEnd = dp(8) })
switchRow.addView(globalPill, LinearLayout.LayoutParams(-2, dp(40)).apply { marginEnd = dp(8) })
switchRow.addView(resetPill, LinearLayout.LayoutParams(-2, dp(40)))
panel.addView(switchRow, LinearLayout.LayoutParams(-1, dp(48)))
panel.addView(eqSwitch, LinearLayout.LayoutParams(dp(190), dp(42)).apply { topMargin = dp(8) })
// ── 设备能力提示 ──
val hint = label(
"本机支持 ${caps.bandCount} 段 · ${String.format(Locale.ROOT, "%.0f", caps.minDb)} ~ " +
"${String.format(Locale.ROOT, "%.0f", caps.maxDb)} dB" +
if (globalSupported) "" else " · 本机不支持全局混音",
13,
Color.argb(150, 255, 255, 255),
)
panel.addView(hint, LinearLayout.LayoutParams(-1, dp(26)))
eqArea = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL }
panel.addView(eqArea, LinearLayout.LayoutParams(-1, -2))
// ── 频段滑杆 ──
val bars = ArrayList<SeekBar>(caps.bandCount)
val valueViews = ArrayList<TextView>(caps.bandCount)
// 刻度换算:0 dB 必定落在某个刻度上(见 eqSliderSteps 说明)。
val stepDb = (caps.maxDb - caps.minDb) / eqSliderSteps
fun toProgress(level: Float): Int =
Math.round((level - caps.minDb) / stepDb).coerceIn(0, eqSliderSteps)
fun fromProgress(progress: Int): Float =
(caps.minDb + progress * stepDb).coerceIn(caps.minDb, caps.maxDb)
// 预设按钮与高亮渲染先声明(频段滑杆回调里要用来刷新"自定义"高亮)。
// ── 3. 均衡器预设 ──
val presetButtons = LinkedHashMap<String, TextView>()
fun renderPresets() {
presetButtons.forEach { (name, view) ->
@@ -2949,83 +2607,73 @@ class MainActivity : AppCompatActivity() {
view.alpha = if (active) 1f else 0.82f
}
}
/** 把内核当前频段值回灌到滑杆与数值文本。 */
fun syncBarsFromEqualizer() {
val presetRow = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
}
eqArea.addView(presetRow, LinearLayout.LayoutParams(-1, dp(52)).apply { topMargin = dp(10) })
// ── 4. EQ 频段:竖向控制杆 ──
val bars = ArrayList<VerticalEqBar>(caps.bandCount)
val valueViews = ArrayList<TextView>(caps.bandCount)
fun syncBars() {
val applied = KtvEqualizer.levelsSnapshot()
for (index in working.indices) {
working[index] = applied.getOrElse(index) { 0f }
bars[index].progress = toProgress(working[index])
valueViews[index].text = eqLevelText(working[index])
valueViews[index].setTextColor(
if (working[index] == 0f) Color.argb(170, 255, 255, 255)
else Color.rgb(255, 204, 92),
)
bars.getOrNull(index)?.setLevel(working[index])
valueViews.getOrNull(index)?.text = eqLevelText(working[index])
}
}
fun applyBandLevels() {
KtvEqualizer.setLevels(working.copyOf())
KtvEqualizer.markCustomPreset()
syncBarsFromEqualizer()
applyEqualizerSettings()
}
caps.frequencies.forEachIndexed { index, _ ->
val row = LinearLayout(this).apply {
val barRow = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER_VERTICAL
}
row.addView(
label(bandLabels.getOrElse(index) { "${index + 1}" } + "Hz", 15, Color.WHITE).apply {
gravity = Gravity.CENTER
},
LinearLayout.LayoutParams(dp(74), dp(38)),
)
val seek = SeekBar(this).apply {
max = eqSliderSteps
isFocusable = true
isFocusableInTouchMode = true
setPadding(0, 0, 0, 0)
TvFocusStyler.installAction(this)
setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(bar: SeekBar?, progress: Int, fromUser: Boolean) {
if (!fromUser) return
working[index] = fromProgress(progress)
valueViews[index].text = eqLevelText(working[index])
valueViews[index].setTextColor(
if (working[index] == 0f) Color.argb(170, 255, 255, 255)
else Color.rgb(255, 204, 92)
)
}
override fun onStartTrackingTouch(bar: SeekBar?) = Unit
override fun onStopTrackingTouch(bar: SeekBar?) {
eqArea.addView(barRow, LinearLayout.LayoutParams(-1, dp(216)).apply { topMargin = dp(6) })
caps.frequencies.forEachIndexed { index, _ ->
val column = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
}
val value = label(
eqLevelText(working.getOrElse(index) { 0f }),
12,
Color.argb(170, 255, 255, 255),
).apply { gravity = Gravity.CENTER }
valueViews.add(value)
column.addView(value, LinearLayout.LayoutParams(-1, dp(20)))
val bar = VerticalEqBar(this).apply {
setRange(caps.minDb, caps.maxDb, eqSliderSteps)
setLevel(working.getOrElse(index) { 0f })
onLevelChanged = { level, fromUser ->
if (fromUser) {
working[index] = level
value.text = eqLevelText(level)
value.setTextColor(
if (level == 0f) Color.argb(170, 255, 255, 255) else Color.rgb(255, 204, 92),
)
// 用户手动改动 → 预设转为「自定义」并自动保存。
KtvEqualizer.setLevels(working.copyOf())
KtvEqualizer.markCustomPreset()
renderPresets()
applyEqualizerSettings()
}
})
}
bars.add(seek)
row.addView(seek, LinearLayout.LayoutParams(0, dp(38), 1f))
val value = label("0.0", 15, Color.argb(170, 255, 255, 255)).apply {
}
bars.add(bar)
column.addView(bar, LinearLayout.LayoutParams(dp(46), 0, 1f))
column.addView(
label(bandLabels.getOrElse(index) { "${index + 1}" } + "Hz", 12, Color.WHITE).apply {
gravity = Gravity.CENTER
}
valueViews.add(value)
row.addView(value, LinearLayout.LayoutParams(dp(76), dp(38)))
panel.addView(row, LinearLayout.LayoutParams(-1, dp(42)))
},
LinearLayout.LayoutParams(-1, dp(20)),
)
barRow.addView(column, LinearLayout.LayoutParams(0, -1, 1f))
}
// ── 预设 ──
val presetRow = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.CENTER
}
KtvEqualizer.presetNames().forEach { name ->
KtvEqualizer.appPresetNames().forEach { name ->
val presetButton = TextView(this).apply {
text = name
textSize = 16f
textSize = 15f
setTextColor(Color.WHITE)
gravity = Gravity.CENTER
isClickable = true
@@ -3033,70 +2681,45 @@ class MainActivity : AppCompatActivity() {
background = getDrawable(R.drawable.bg_tuning_counter)
TvFocusStyler.installAction(this)
setOnClickListener {
if (name == KtvEqualizer.CUSTOM_PRESET) {
// 保留当前频段值,仅把预设标记为「自定义」。
KtvEqualizer.markCustomPreset()
} else {
KtvEqualizer.applyPreset(name)
syncBarsFromEqualizer()
syncBars()
}
renderPresets()
applyEqualizerSettings()
toast("均衡器预设:" + name)
}
}
presetButtons[name] = presetButton
presetRow.addView(
presetButton,
LinearLayout.LayoutParams(0, dp(56), 1f).apply { marginStart = dp(3); marginEnd = dp(3) },
LinearLayout.LayoutParams(0, dp(46), 1f).apply {
marginStart = dp(4)
marginEnd = dp(4)
},
)
}
panel.addView(presetRow, LinearLayout.LayoutParams(-1, dp(64)))
renderEqEnabled()
renderPresets()
resetEqualizer = {
KtvEqualizer.applyPreset("标准")
syncBarsFromEqualizer()
renderPresets()
applyEqualizerSettings()
toast("均衡器已重置为标准")
}
// ── 底部 ──
val actions = LinearLayout(this).apply {
orientation = LinearLayout.HORIZONTAL
gravity = Gravity.END or Gravity.CENTER_VERTICAL
}
val done = pill("完成") { }
actions.addView(done, LinearLayout.LayoutParams(dp(110), dp(44)))
panel.addView(actions, LinearLayout.LayoutParams(-1, dp(52)))
val scroll = android.widget.ScrollView(this).apply { addView(panel) }
val dialog = AlertDialog.Builder(this)
.setView(scroll)
.create()
val dialog = AlertDialog.Builder(this).setView(scroll).create()
close.setOnClickListener { dialog.dismiss() }
done.setOnClickListener { dialog.dismiss() }
dialog.restoreSourceFocusOnDismiss()
dialog.showForTv(afterShow = {
dialog.window?.apply {
setBackgroundDrawableResource(android.R.color.transparent)
attributes = attributes.apply { dimAmount = 0.72f }
addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
val available = (resources.displayMetrics.widthPixels * 0.92f).toInt()
setLayout(min(dp(700), available), ViewGroup.LayoutParams.WRAP_CONTENT)
val available = (resources.displayMetrics.widthPixels * 0.94f).toInt()
setLayout(min(dp(760), available), ViewGroup.LayoutParams.WRAP_CONTENT)
}
// 初始化滑杆位置(不触发 fromUser 回调,避免被当成自定义)。
bars.forEachIndexed { index, bar ->
bar.progress = toProgress(working.getOrElse(index) { 0f })
valueViews[index].text = eqLevelText(working.getOrElse(index) { 0f })
}
enabledPill.requestFocus()
volumeBar.requestFocus()
})
}
/** 4 个切换按钮的状态切换 */
private var muteBeforeVol = 80
private fun updateToggleState(btn: TextView?, on: Boolean) {
if (btn == null) return
btn.setBackgroundResource(if (on) R.drawable.bg_btn_toggle_on else R.drawable.bg_btn_toggle_off)
btn.setTextColor(if (on) Color.WHITE else Color.rgb(204, 204, 204))
}
private fun controlRow(
left: String?,
right: String?,
@@ -5656,7 +5279,14 @@ class MainActivity : AppCompatActivity() {
playRandomLocalSong()
return
}
// 用户主动切歌/停止:取消挂起的随机续播,进入空闲态。
// 公放(随机)歌曲播放中按「切歌」:不该停下来,接着播公放清单里的下一首。
// 注意这里不调用 markFinished —— 用户是跳过而不是播完,不该 +1;
// 但它已在 markStarted 时计入本轮已播,所以本轮不会再重复。
if (!fromCompletion && currentSong?.let { RandomPlayQueue.isCurrentTrack(it) } == true) {
playRandomLocalSong(skipped = true)
return
}
// 其它情况(没有公放曲目在播 / 用户主动停止):取消挂起的随机续播,进入空闲态。
randomLocalRequestSerial++
playbackPreparing = false
playWhenPrepared = false
@@ -5702,10 +5332,13 @@ class MainActivity : AppCompatActivity() {
}
/**
* 从本机所有有效歌曲中随机续播(启动自动播放 / 点歌列表播放完毕均走此逻辑)。
* 从本机所有有效歌曲中随机续播(启动自动播放 / 点歌列表播放完毕 / 公放中切歌均走此逻辑)。
* 随机歌曲不写入已点列表;用户此时点歌会立即取消本次随机请求并优先播放已点歌曲。
*
* @param startup 是否开机自动随机播放
* @param skipped 是否是用户在公放歌曲上按了「切歌」(仅影响提示文案)
*/
private fun playRandomLocalSong(startup: Boolean = false) {
private fun playRandomLocalSong(startup: Boolean = false, skipped: Boolean = false) {
val previousSongId = currentSong?.let(::stableId) ?: lastRandomLocalSongId
val requestSerial = ++randomLocalRequestSerial
playbackPreparing = true
@@ -5714,7 +5347,11 @@ class MainActivity : AppCompatActivity() {
currentSong = null
clearLyrics()
updateBottomBar(null, false)
status?.text = if (startup) "正在随机播放本地歌曲..." else "已点播放完成,正在随机本地歌曲..."
status?.text = when {
startup -> "正在随机播放本地歌曲..."
skipped -> "切歌:正在播放下一首公放歌曲..."
else -> "已点播放完成,正在随机本地歌曲..."
}
persistRuntimeState()
io.execute {
@@ -6809,13 +6446,6 @@ class MainActivity : AppCompatActivity() {
private var fullScreenSeeking = false
private var fullScreenControls: LinearLayout? = null
private var fullScreenProgressRow: View? = null
private var fullScreenToneControl: View? = null
private var fullScreenVolumePanel: View? = null
private var fullScreenVolumeIndicator: CircularVolumeView? = null
private var fullScreenVolumeMinusButton: TextView? = null
private var fullScreenVolumePlusButton: TextView? = null
private var fullScreenVolumeMuteButton: TextView? = null
private var fullScreenPreviousVolume = 70
private var randomLocalRequestSerial = 0L
private var lastRandomLocalSongId: String? = null
/** 启动时无恢复播放/空队列: 曲库就绪后自动随机播放本地已下载歌曲(仅触发一次)。 */
@@ -6823,14 +6453,8 @@ class MainActivity : AppCompatActivity() {
private var advancingPlayback = false
private var lastAutoAdvanceAtMs = 0L
private var fullScreenChromeVisible = false
private val hideFullScreenVolumeIndicatorRunnable = Runnable {
fullScreenVolumeIndicator?.animate()?.alpha(0f)?.setDuration(220L)?.withEndAction {
fullScreenVolumeIndicator?.visibility = View.GONE
fullScreenVolumeIndicator?.alpha = 1f
}?.start()
}
private val hideFullScreenChromeRunnable = Runnable {
if (isFullScreen && fullScreenVolumePanel?.visibility != View.VISIBLE) {
if (isFullScreen) {
setFullScreenChromeVisible(false)
}
}
@@ -7052,7 +6676,7 @@ class MainActivity : AppCompatActivity() {
if (btnTopNext != null) btnTopNext!!.setOnClickListener(View.OnClickListener { v: View? -> playNext() })
if (btnTopPause != null) btnTopPause!!.setOnClickListener(View.OnClickListener { v: View? -> togglePlay() })
if (btnTopReplay != null) btnTopReplay!!.setOnClickListener(View.OnClickListener { v: View? -> replay() })
if (btnTopTone != null) btnTopTone!!.setOnClickListener(View.OnClickListener { v: View? -> showAudioControlDialog() })
if (btnTopTone != null) btnTopTone!!.setOnClickListener(View.OnClickListener { v: View? -> showTuningDialog() })
// 设置底栏按钮事件
if (btnBottomGuess != null) btnBottomGuess!!.setOnClickListener(View.OnClickListener { v: View? -> showGuessPage() })
@@ -9273,7 +8897,7 @@ class MainActivity : AppCompatActivity() {
isClickable = true
isFocusable = true
setPadding(dp(12), dp(10), dp(12), dp(10))
setOnClickListener { showEqualizerDialog() }
setOnClickListener { showTuningDialog() }
TvFocusStyler.installAction(this)
}
val body = LinearLayout(this).apply {
@@ -10299,27 +9923,11 @@ class MainActivity : AppCompatActivity() {
code == KeyEvent.KEYCODE_NUMPAD_ENTER
val direction = code == KeyEvent.KEYCODE_DPAD_LEFT || code == KeyEvent.KEYCODE_DPAD_RIGHT ||
code == KeyEvent.KEYCODE_DPAD_UP || code == KeyEvent.KEYCODE_DPAD_DOWN
val volumePanelVisible = fullScreenVolumePanel?.visibility == View.VISIBLE
val volumePanelHasFocus = volumePanelVisible && fullScreenVolumePanel?.hasFocus() == true
if (code == KeyEvent.KEYCODE_BACK && volumePanelVisible) {
main.removeCallbacks(hideFullScreenVolumeIndicatorRunnable)
fullScreenVolumePanel?.visibility = View.GONE
fullScreenToneControl?.requestFocus()
setFullScreenChromeVisible(true)
return true
}
if (center && !fullScreenChromeVisible) {
setFullScreenChromeVisible(true)
return true
}
if (volumePanelVisible && (center || direction) && !volumePanelHasFocus &&
fullScreenControls?.hasFocus() != true
) {
setFullScreenChromeVisible(true)
fullScreenVolumeMinusButton?.requestFocus()
return true
}
if (center && fullScreenControls?.hasFocus() != true && !volumePanelHasFocus) {
if (center && fullScreenControls?.hasFocus() != true) {
setFullScreenChromeVisible(true)
fullScreenControls?.getChildAt(0)?.requestFocus()
return true
@@ -10332,19 +9940,6 @@ class MainActivity : AppCompatActivity() {
main.removeCallbacks(hideFullScreenChromeRunnable)
main.postDelayed(hideFullScreenChromeRunnable, 5000L)
}
if (volumePanelHasFocus && code == KeyEvent.KEYCODE_DPAD_UP) {
fullScreenToneControl?.requestFocus()
return true
}
if (volumePanelHasFocus && code == KeyEvent.KEYCODE_DPAD_DOWN) {
return true
}
if (volumePanelVisible && window.decorView.findFocus() === fullScreenToneControl &&
code == KeyEvent.KEYCODE_DPAD_DOWN
) {
fullScreenVolumeMinusButton?.requestFocus()
return true
}
}
if (code == KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE || code == KeyEvent.KEYCODE_SPACE) {
togglePlay()
@@ -0,0 +1,190 @@
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
}
}
@@ -1,244 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
调音/音频控制弹窗(对应"弹窗-调音.jpg")
包含:
- 4 个开关按钮:立体声/伴唱/原唱/静音
- 2 个滑块:麦克风(音量+效果)、音乐音量
- 关闭按钮
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="640dp"
android:layout_height="wrap_content"
android:background="@drawable/bg_dialog_panel"
android:orientation="vertical"
android:padding="20dp">
<!-- 标题 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="36dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:layout_marginBottom="14dp">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="🎚 调音设置"
android:textColor="#FF27CCA4"
android:textSize="18sp"
android:textStyle="bold" />
<TextView
android:id="@+id/btn_audio_close"
android:layout_width="36dp"
android:layout_height="36dp"
android:background="@drawable/bg_btn_dialog_close"
android:gravity="center"
android:text="×"
android:textColor="#FFCCCCCC"
android:textSize="22sp" />
</LinearLayout>
<!-- 4 个开关按钮 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:layout_marginBottom="20dp"
android:weightSum="4">
<TextView
android:id="@+id/btn_tone_stereo"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_toggle_on"
android:gravity="center"
android:text="🔊 立体声"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
<TextView
android:id="@+id/btn_tone_accomp"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_toggle_off"
android:gravity="center"
android:text="🎤 伴唱"
android:textColor="#FFCCCCCC"
android:textSize="14sp" />
<TextView
android:id="@+id/btn_tone_origin"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_marginEnd="6dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_toggle_on"
android:gravity="center"
android:text="🎙 原唱"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
<TextView
android:id="@+id/btn_tone_mute"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:background="@drawable/bg_btn_toggle_off"
android:gravity="center"
android:text="🔇 静音"
android:textColor="#FFCCCCCC"
android:textSize="14sp" />
</LinearLayout>
<!-- 麦克风(增益/效果由功放混音器硬件调节, 滑杆仅作指示) -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:text="麦克风音量(功放/混音器硬件调节)"
android:textColor="#FFE0E0FF"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="14dp">
<SeekBar
android:id="@+id/seek_mic_volume"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="100"
android:progress="60"
android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4"
android:enabled="false" />
<TextView
android:id="@+id/text_mic_volume_value"
android:layout_width="50dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="60"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
</LinearLayout>
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="2dp"
android:text="麦克风效果(功放/混音器硬件调节)"
android:textColor="#FFE0E0FF"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="20dp">
<SeekBar
android:id="@+id/seek_mic_effect"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="100"
android:progress="50"
android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4"
android:enabled="false" />
<TextView
android:id="@+id/text_mic_effect_value"
android:layout_width="50dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="50"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
</LinearLayout>
<!-- 音乐音量 -->
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:text="音乐音量"
android:textColor="#FFE0E0FF"
android:textSize="13sp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:layout_marginBottom="20dp">
<SeekBar
android:id="@+id/seek_music_volume"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:max="100"
android:progress="80"
android:progressTint="#FF27CCA4"
android:thumbTint="#FF27CCA4" />
<TextView
android:id="@+id/text_music_volume_value"
android:layout_width="50dp"
android:layout_height="wrap_content"
android:gravity="end"
android:text="80"
android:textColor="#FFFFFFFF"
android:textSize="14sp" />
</LinearLayout>
<!-- 底部按钮行 -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView
android:id="@+id/btn_audio_default"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:background="@drawable/bg_btn_glass"
android:gravity="center"
android:paddingStart="20dp"
android:paddingEnd="20dp"
android:text="恢复默认"
android:textColor="#FFCCCCCC"
android:textSize="13sp" />
<View
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<TextView
android:id="@+id/btn_audio_save"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:background="@drawable/bg_btn_save"
android:gravity="center"
android:paddingStart="28dp"
android:paddingEnd="28dp"
android:text="保存"
android:textColor="#FFFFFFFF"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
-116
View File
@@ -1,116 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="470dp"
android:layout_height="404dp"
android:background="@drawable/bg_tuning_dialog"
android:orientation="vertical"
android:paddingStart="28dp"
android:paddingTop="20dp"
android:paddingEnd="28dp"
android:paddingBottom="22dp">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="44dp">
<TextView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:text="调音"
android:textColor="#FFFFFFFF"
android:textSize="24sp" />
<TextView
android:id="@+id/btn_tuning_close"
android:layout_width="36dp"
android:layout_height="36dp"
android:layout_gravity="end|center_vertical"
android:gravity="center"
android:text="×"
android:textColor="#99FFFFFF"
android:textSize="22sp" />
</FrameLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="116dp"
android:layout_marginTop="12dp"
android:orientation="horizontal">
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:layout_marginEnd="12dp"
android:background="@drawable/bg_tuning_counter"
android:gravity="center"
android:orientation="vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:text="升降调" android:textColor="#BFFFFFFF" android:textSize="17sp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="54dp"
android:gravity="center" android:orientation="horizontal">
<TextView android:id="@+id/btn_tone_down" android:layout_width="48dp" android:layout_height="48dp"
android:gravity="center" android:text="−" android:textColor="#BFFFFFFF" android:textSize="34sp" />
<TextView android:id="@+id/text_tone_counter" android:layout_width="56dp" android:layout_height="48dp"
android:gravity="center" android:text="0" android:textColor="#BFFFFFFF" android:textSize="20sp" />
<TextView android:id="@+id/btn_tone_up" android:layout_width="48dp" android:layout_height="48dp"
android:gravity="center" android:text="+" android:textColor="#BFFFFFFF" android:textSize="30sp" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="1"
android:background="@drawable/bg_tuning_counter"
android:gravity="center"
android:orientation="vertical">
<TextView android:layout_width="wrap_content" android:layout_height="wrap_content"
android:text="麦克风" android:textColor="#FFFFFFFF" android:textSize="17sp" />
<LinearLayout android:layout_width="match_parent" android:layout_height="54dp"
android:gravity="center" android:orientation="horizontal">
<TextView android:id="@+id/btn_mic_down" android:layout_width="48dp" android:layout_height="48dp"
android:gravity="center" android:text="−" android:textColor="#FFFFFFFF" android:textSize="34sp" />
<TextView android:id="@+id/text_mic_counter" android:layout_width="56dp" android:layout_height="48dp"
android:gravity="center" android:text="50" android:textColor="#FFFFFFFF" android:textSize="20sp" />
<TextView android:id="@+id/btn_mic_up" android:layout_width="48dp" android:layout_height="48dp"
android:gravity="center" android:text="+" android:textColor="#FFFFFFFF" android:textSize="30sp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal"
android:weightSum="4">
<TextView android:id="@+id/btn_preset_pop" android:layout_width="0dp" android:layout_height="92dp"
android:layout_weight="1" android:gravity="center" android:text="♬\n流行" android:textColor="#FFFFFFFF" android:textSize="16sp" />
<TextView android:id="@+id/btn_preset_lyric" android:layout_width="0dp" android:layout_height="92dp"
android:layout_weight="1" android:gravity="center" android:text="▥\n抒情" android:textColor="#FFFFFFFF" android:textSize="16sp" />
<TextView android:id="@+id/btn_preset_rock" android:layout_width="0dp" android:layout_height="92dp"
android:layout_weight="1" android:gravity="center" android:text="♨\n摇滚" android:textColor="#FFFFFFFF" android:textSize="16sp" />
<TextView android:id="@+id/btn_preset_singer" android:layout_width="0dp" android:layout_height="92dp"
android:layout_weight="1" android:gravity="center" android:text="♪\n唱将" android:textColor="#FFFFFFFF" android:textSize="16sp" />
</LinearLayout>
<!-- 均衡器入口(内核参考开源项目 OpenEQ, 按设备真实频段自适应) -->
<TextView
android:id="@+id/btn_tuning_equalizer"
android:layout_width="match_parent"
android:layout_height="46dp"
android:layout_marginTop="8dp"
android:background="@drawable/bg_btn_glass"
android:gravity="center"
android:text="均衡器"
android:textColor="#FFFFFFFF"
android:textSize="17sp" />
</LinearLayout>