diff --git a/app/src/main/assets/mobile/app.css b/app/src/main/assets/mobile/app.css
index e8da474..ecb64c2 100644
--- a/app/src/main/assets/mobile/app.css
+++ b/app/src/main/assets/mobile/app.css
@@ -15,3 +15,5 @@
.rank-pager button:disabled{opacity:.35;box-shadow:none}
.rank-pager b{min-width:64px;text-align:center;color:#b9a9c9;font-size:11px}
.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)}
\ No newline at end of file
diff --git a/app/src/main/assets/mobile/app.js b/app/src/main/assets/mobile/app.js
index cb029b8..4ebd224 100644
--- a/app/src/main/assets/mobile/app.js
+++ b/app/src/main/assets/mobile/app.js
@@ -75,7 +75,9 @@ async function refresh() {
$("#musicVolume").value = data.musicVolume ?? 70;
$("#micVolume").value = data.micVolume ?? 70;
$("#musicValue").textContent = data.musicVolume ?? 70;
- $("#micValue").textContent = data.micVolume ?? 70;
+ // 麦克风音量由功放/混音器硬件调节(滑杆已禁用), 仅保留显示值。
+ const micHw = $("#micValue");
+ if (micHw) micHw.textContent = data.micVolume ?? 70;
$("#original").classList.toggle("active", data.vocalMode === "original");
$("#accompany").classList.toggle("active", data.vocalMode !== "original");
$("#nextSong").textContent = data.next?.title || "队列为空";
@@ -203,10 +205,9 @@ $("#searchForm").addEventListener("submit", (event) => { event.preventDefault();
$("#more").addEventListener("click", () => search(false));
$("#hotPrev").addEventListener("click", () => loadHot(uiState.hotPage - 1));
$("#hotNext").addEventListener("click", () => loadHot(uiState.hotPage + 1));
-["music", "mic"].forEach((kind) => {
- $("#" + kind + "Volume").addEventListener("input", (event) => { $("#" + kind + "Value").textContent = event.target.value; });
- $("#" + kind + "Volume").addEventListener("change", (event) => updateAudio({ [kind + "Volume"]: Number(event.target.value) }));
-});
+// 音乐音量可调; 麦克风音量由功放硬件控制, 不再向设备发送无效果请求。
+$("#musicVolume").addEventListener("input", (event) => { $("#musicValue").textContent = event.target.value; });
+$("#musicVolume").addEventListener("change", (event) => updateAudio({ musicVolume: Number(event.target.value) }));
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 7bcbaa4..df7d251 100644
--- a/app/src/main/assets/mobile/index.html
+++ b/app/src/main/assets/mobile/index.html
@@ -37,7 +37,7 @@
-
+
02
下一首队列为空
diff --git a/app/src/main/java/com/local/ktv/MainActivity.kt b/app/src/main/java/com/local/ktv/MainActivity.kt
index 5d4316f..829d5ce 100644
--- a/app/src/main/java/com/local/ktv/MainActivity.kt
+++ b/app/src/main/java/com/local/ktv/MainActivity.kt
@@ -5,6 +5,9 @@ import android.app.AlertDialog
import android.content.DialogInterface
import android.content.DialogInterface.OnShowListener
import android.content.Intent
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.res.ColorStateList
import android.content.pm.PackageManager
@@ -512,9 +515,72 @@ class MainActivity : AppCompatActivity() {
}
}
micActivator.start()
+ registerMicInputWatcher()
Log.i(TAG, "麦克风输入已激活(演唱输出通道占用)")
}
+ /**
+ * 监听输入设备变化: 外接麦克风(USB 声卡/3.5mm 麦)插入时系统会把默认输入路由切到外接,
+ * 原 AudioRecord 随之失效。检测到变化后重建占用, 让演唱输出通道跟随当前麦克风。
+ */
+ private fun registerMicInputWatcher() {
+ val manager = audioManager ?: return
+ if (Build.VERSION.SDK_INT >= 23) {
+ manager.registerAudioDeviceCallback(micDeviceCallback, null)
+ } else {
+ runCatching {
+ val filter = IntentFilter(Intent.ACTION_HEADSET_PLUG)
+ registerReceiver(micHotplugReceiver, filter)
+ }
+ }
+ }
+
+ private fun unregisterMicInputWatcher() {
+ val manager = audioManager ?: return
+ if (Build.VERSION.SDK_INT >= 23) {
+ manager.unregisterAudioDeviceCallback(micDeviceCallback)
+ } else {
+ runCatching { unregisterReceiver(micHotplugReceiver) }
+ }
+ }
+
+ private val micDeviceCallback = object : android.media.AudioDeviceCallback() {
+ override fun onAudioDevicesAdded(addedDevices: Array) {
+ handleMicInputDevicesChanged(addedDevices.any { isMicInputDevice(it) })
+ }
+
+ override fun onAudioDevicesRemoved(removedDevices: Array) {
+ handleMicInputDevicesChanged(removedDevices.any { isMicInputDevice(it) })
+ }
+ }
+
+ private val micHotplugReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ handleMicInputDevicesChanged(true)
+ }
+ }
+
+ /** 是否为可用的麦克风输入设备(内置/耳机/USB 等)。 */
+ private fun isMicInputDevice(device: android.media.AudioDeviceInfo): Boolean {
+ if (!device.isSource) return false
+ return when (device.type) {
+ android.media.AudioDeviceInfo.TYPE_BUILTIN_MIC,
+ android.media.AudioDeviceInfo.TYPE_USB_HEADSET,
+ android.media.AudioDeviceInfo.TYPE_USB_DEVICE,
+ android.media.AudioDeviceInfo.TYPE_HDMI,
+ android.media.AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
+ android.media.AudioDeviceInfo.TYPE_LINE_ANALOG,
+ -> true
+ else -> false
+ }
+ }
+
+ /** 输入设备增删后: 重新打开麦克风占用, 跟随系统当前路由(优先外接)。 */
+ private fun handleMicInputDevicesChanged(_changed: Boolean) {
+ // 延迟少许等系统完成路由切换, 再重建 AudioRecord。
+ main.postDelayed({ micActivator.restart() }, 300L)
+ }
+
private fun initializeDatabase() {
if (databaseBootstrapRunning) return
databaseBootstrapRunning = true
@@ -603,6 +669,7 @@ class MainActivity : AppCompatActivity() {
main.removeCallbacks(remoteNetworkMonitor)
main.removeCallbacks(qrAutoHideMonitor)
stopRecording(false)
+ unregisterMicInputWatcher()
micActivator.stop()
releaseVocalPlayer()
if (::playbackEngine.isInitialized) playbackEngine.release()
@@ -2644,15 +2711,11 @@ class MainActivity : AppCompatActivity() {
})
root.findViewById(R.id.btn_mic_down)
.setOnClickListener(View.OnClickListener { v: View? ->
- micVolume = max(0, micVolume - 5)
- micValue.setText(micVolume.toString())
- saveState()
+ toast("麦克风音量由功放/混音器硬件调节")
})
root.findViewById(R.id.btn_mic_up)
.setOnClickListener(View.OnClickListener { v: View? ->
- micVolume = min(100, micVolume + 5)
- micValue.setText(micVolume.toString())
- saveState()
+ toast("麦克风音量由功放/混音器硬件调节")
})
bindAtmospherePreset(root, R.id.btn_preset_pop, "流行")
bindAtmospherePreset(root, R.id.btn_preset_lyric, "抒情")
diff --git a/app/src/main/java/com/local/ktv/MicActivator.kt b/app/src/main/java/com/local/ktv/MicActivator.kt
index aa12349..7cee562 100644
--- a/app/src/main/java/com/local/ktv/MicActivator.kt
+++ b/app/src/main/java/com/local/ktv/MicActivator.kt
@@ -1,5 +1,6 @@
package com.local.ktv
+import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioRecord
import android.media.MediaRecorder
@@ -15,6 +16,11 @@ import android.util.Log
* 做法: 用一个低开销的 [AudioRecord] 常驻打开麦克风并循环读取丢弃数据,
* 保持输入通道占用(不写文件、不对外暴露声音)。与 MediaRecorder 录音互斥:
* 开始录音前先 pause, 结束录音后自动 resume。
+ *
+ * 外接麦克风(USB/耳机麦)热插拔时, 系统会把默认输入路由切到外接设备并使
+ * 原 AudioRecord 失效 —— 此时需重建 AudioRecord 并优先占用当前活动输入设备。
+ * 因此对外暴露 [restart] 供设备变化监听调用; [setPreferredInput] 支持 Android 8.0+
+ * 的 AudioRecord.setPreferredDevice 直接把输入锁定到指定麦克风(优先外接)。
*/
class MicActivator {
private val tag = "MicActivator"
@@ -23,6 +29,9 @@ class MicActivator {
@Volatile private var running = false
@Volatile private var paused = false
+ /** 期望优先使用的输入设备; null = 跟随系统默认(自动切到外接)。 */
+ @Volatile private var preferredInput: AudioDeviceInfo? = null
+
val isActive: Boolean get() = running && !paused
/** 打开麦克风并保持占用。失败静默(设备无麦克风时不影响 App 使用)。 */
@@ -31,6 +40,28 @@ class MicActivator {
if (running) return
running = true
paused = false
+ spawnThread()
+ }
+
+ /** 输入设备发生变化(外接麦克风插入/拔出等)时重建 AudioRecord, 跟随新路由。 */
+ @Synchronized
+ fun restart() {
+ if (!running || paused) return
+ releaseInternal()
+ thread?.interrupt()
+ thread = null
+ spawnThread()
+ }
+
+ /** 指定首选输入设备(传入 null 恢复跟随系统默认)。 */
+ @Synchronized
+ fun setPreferredInput(device: AudioDeviceInfo?) {
+ preferredInput = device
+ restart()
+ }
+
+ @Synchronized
+ private fun spawnThread() {
thread = Thread({ readLoop() }, "mic-activator").also { it.isDaemon = true; it.start() }
}
@@ -50,7 +81,7 @@ class MicActivator {
if (!running) return
if (!paused) return
paused = false
- thread = Thread({ readLoop() }, "mic-activator").also { it.isDaemon = true; it.start() }
+ spawnThread()
}
/** 彻底停止(不再自动恢复)。 */
@@ -89,6 +120,11 @@ class MicActivator {
sleepQuietly(5000)
continue
}
+ // Android 8.0+: 指定首选输入设备(如刚插入的外接麦克风), 避免仍占用内建。
+ val target = preferredInput
+ if (target != null && android.os.Build.VERSION.SDK_INT >= 26) {
+ runCatching { recorder.setPreferredDevice(target) }
+ }
record = recorder
recorder.startRecording()
val buffer = ByteArray(minBuffer.coerceAtLeast(4096))
@@ -125,3 +161,4 @@ class MicActivator {
try { Thread.sleep(ms) } catch (ignored: InterruptedException) { Thread.currentThread().interrupt() }
}
}
+
diff --git a/app/src/main/res/layout/dialog_audio_control.xml b/app/src/main/res/layout/dialog_audio_control.xml
index bac2358..f81de08 100644
--- a/app/src/main/res/layout/dialog_audio_control.xml
+++ b/app/src/main/res/layout/dialog_audio_control.xml
@@ -97,12 +97,12 @@
android:textSize="14sp" />
-
+
@@ -121,7 +121,8 @@
android:max="100"
android:progress="60"
android:progressTint="#FF27CCA4"
- android:thumbTint="#FF27CCA4" />
+ android:thumbTint="#FF27CCA4"
+ android:enabled="false" />
@@ -156,7 +157,8 @@
android:max="100"
android:progress="50"
android:progressTint="#FF27CCA4"
- android:thumbTint="#FF27CCA4" />
+ android:thumbTint="#FF27CCA4"
+ android:enabled="false" />