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" />