diff --git a/AGENTS.md b/AGENTS.md index 07a0cf9..a8d17a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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()` 每次都重新比对曲库,**自动吸纳新增歌曲**、剔除已删除歌曲;一轮播完自动开新一轮。 diff --git a/app/build.gradle b/app/build.gradle index 9bc9bf8..db04d8e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -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,否则 diff --git a/app/src/main/assets/mobile/app.css b/app/src/main/assets/mobile/app.css index 88e867b..a113935 100644 --- a/app/src/main/assets/mobile/app.css +++ b/app/src/main/assets/mobile/app.css @@ -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} diff --git a/app/src/main/assets/mobile/app.js b/app/src/main/assets/mobile/app.js index 96ea2f1..89ad9bf 100644 --- a/app/src/main/assets/mobile/app.js +++ b/app/src/main/assets/mobile/app.js @@ -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(); diff --git a/app/src/main/assets/mobile/index.html b/app/src/main/assets/mobile/index.html index ebcaba0..f7c165a 100644 --- a/app/src/main/assets/mobile/index.html +++ b/app/src/main/assets/mobile/index.html @@ -41,14 +41,19 @@
-
≡均衡器
已关闭
-
- - + + -
-
-

均衡器作用于本机播放输出,不改动系统音量

02
下一首队列为空
diff --git a/app/src/main/java/com/local/ktv/CircularVolumeView.kt b/app/src/main/java/com/local/ktv/CircularVolumeView.kt deleted file mode 100644 index a9e7455..0000000 --- a/app/src/main/java/com/local/ktv/CircularVolumeView.kt +++ /dev/null @@ -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) - } -} diff --git a/app/src/main/java/com/local/ktv/KtvEqualizer.kt b/app/src/main/java/com/local/ktv/KtvEqualizer.kt index 81f5258..7e1fc70 100644 --- a/app/src/main/java/com/local/ktv/KtvEqualizer.kt +++ b/app/src/main/java/com/local/ktv/KtvEqualizer.kt @@ -55,25 +55,30 @@ object KtvEqualizer { /** * 预设曲线:以「频段在可用范围内的相对位置 0..1」为横轴、以最大增益的比例 -1..1 为纵轴。 * 这样同一套预设能自适应任意频段数的设备。 + * + * `标准` 不显示在 App 的预设行里(App 用「自定义」表示用户自己调的状态), + * 但保留给远程页面的「恢复标准」按钮使用。 */ private val PRESETS: LinkedHashMap>> = 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 = 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) diff --git a/app/src/main/java/com/local/ktv/KtvStore.kt b/app/src/main/java/com/local/ktv/KtvStore.kt index 2c5c2d2..44ce6a1 100644 --- a/app/src/main/java/com/local/ktv/KtvStore.kt +++ b/app/src/main/java/com/local/ktv/KtvStore.kt @@ -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) diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt index 3e400f9..7ea273d 100644 --- a/app/src/main/java/com/local/ktv/MainActivity.kt +++ b/app/src/main/java/com/local/ktv/MainActivity.kt @@ -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(R.id.btn_audio_close) - val btnStereo = root.findViewById(R.id.btn_tone_stereo) - val btnAccomp = root.findViewById(R.id.btn_tone_accomp) - val btnOrigin = root.findViewById(R.id.btn_tone_origin) - val btnMute = root.findViewById(R.id.btn_tone_mute) - val seekMicVol = root.findViewById(R.id.seek_mic_volume) - val seekMicEffect = root.findViewById(R.id.seek_mic_effect) - val seekMusicVol = root.findViewById(R.id.seek_music_volume) - val tvMicVol = root.findViewById(R.id.text_mic_volume_value) - val tvMicEffect = root.findViewById(R.id.text_mic_effect_value) - val tvMusicVol = root.findViewById(R.id.text_music_volume_value) - val btnDefault = root.findViewById(R.id.btn_audio_default) - val btnSave = root.findViewById(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(R.id.text_tone_counter) - val micValue = root.findViewById(R.id.text_mic_counter) - toneValue.setText(tone.toString()) - micValue.setText(micVolume.toString()) - - val dialog = AlertDialog.Builder(this).setView(root).create() - root.findViewById(R.id.btn_tuning_close) - .setOnClickListener(View.OnClickListener { v: View? -> dialog.dismiss() }) - root.findViewById(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(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(R.id.btn_mic_down) - .setOnClickListener(View.OnClickListener { v: View? -> - toast("麦克风音量由功放/混音器硬件调节") - }) - root.findViewById(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(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(R.id.btn_tone_down).requestFocus() - }) - return true - } - - private fun bindAtmospherePreset(root: View, id: Int, preset: String) { - root.findViewById(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 { - "全局混音:关" - } - globalPill.alpha = if (globalSupported) 1f else 0.45f + + // ── 标题 ── + 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) } - enabledPill = pill(if (KtvEqualizer.enabled) "均衡器:开启" else "均衡器:关闭") { + 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() + } + } + + 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(caps.bandCount) - val valueViews = ArrayList(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() 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(caps.bandCount) + val valueViews = ArrayList(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() + val barRow = LinearLayout(this).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER } - + eqArea.addView(barRow, LinearLayout.LayoutParams(-1, dp(216)).apply { topMargin = dp(6) }) caps.frequencies.forEachIndexed { index, _ -> - val row = LinearLayout(this).apply { - orientation = LinearLayout.HORIZONTAL - gravity = Gravity.CENTER_VERTICAL + val column = LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL } - 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) + 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), ) - } - - override fun onStartTrackingTouch(bar: SeekBar?) = Unit - - override fun onStopTrackingTouch(bar: SeekBar?) { + // 用户手动改动 → 预设转为「自定义」并自动保存。 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 { - gravity = Gravity.CENTER - } - valueViews.add(value) - row.addView(value, LinearLayout.LayoutParams(dp(76), dp(38))) - panel.addView(row, LinearLayout.LayoutParams(-1, dp(42))) + 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 + }, + 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 { - KtvEqualizer.applyPreset(name) - syncBarsFromEqualizer() + if (name == KtvEqualizer.CUSTOM_PRESET) { + // 保留当前频段值,仅把预设标记为「自定义」。 + KtvEqualizer.markCustomPreset() + } else { + KtvEqualizer.applyPreset(name) + 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() diff --git a/app/src/main/java/com/local/ktv/VerticalEqBar.kt b/app/src/main/java/com/local/ktv/VerticalEqBar.kt new file mode 100644 index 0000000..e0303d7 --- /dev/null +++ b/app/src/main/java/com/local/ktv/VerticalEqBar.kt @@ -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 + } +} diff --git a/app/src/main/res/layout/dialog_audio_control.xml b/app/src/main/res/layout/dialog_audio_control.xml deleted file mode 100644 index f81de08..0000000 --- a/app/src/main/res/layout/dialog_audio_control.xml +++ /dev/null @@ -1,244 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/dialog_tuning.xml b/app/src/main/res/layout/dialog_tuning.xml deleted file mode 100644 index d20fca2..0000000 --- a/app/src/main/res/layout/dialog_tuning.xml +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -