diff --git a/AGENTS.md b/AGENTS.md index 052f8a0..c3591df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,9 @@ PC (Server) WASAPI render "CABLE Input" ◄─ PlayoutHandle (jitter buffer, de - **Real-time rule:** `push`, `fill`, `Playout::fill` and `Mixer::fill` must never allocate, lock or block. Mixer slots are preallocated; peers attach, detach and are muted via atomics; the sender is woken with `Thread::unpark`. - **Roles:** `session::Server` is the PC side; it serves one active phone and a new connection replaces the old one (close codes 1 = replaced, 2 = rejected, 3 = shutdown). `session::Hub` is the phone side: one iroh endpoint, one task per PC, `MAX_PEERS = 8`, `SLOTS = 16`. - **Dialing:** the phone always dials the PC, which works through NAT and double NAT. -- **Control:** a single bi-stream carrying `ControlMsg` frames (`len u16 | tag | body`): Hello{secret} → Welcome/Reject, Toggles, MicDemand, MicAllowed. +- **Control (protocol v2):** a single bi-stream carrying `ControlMsg` frames (`len u16 | tag | body`): Hello{secret, PhoneControls} → Welcome{PcControls}/Reject, PcState, MicDemand, PhoneState, SetPc, SetPhone. Each side reads the stream through `spawn_control_reader` (a task feeding a channel), so `select!` never cancels a half-read frame. A Hello of another version still decodes far enough to be rejected with a readable reason. +- **Remote control:** each side owns its controls and reports them (`PcControls`: audio, mic, mic_default = CABLE Output is the Windows default recording device, default playback volume; `PhoneControls`: mic switch, mic_ready, media volume). The other side may ask for a change (`PcRequest` / `PhoneRequest`). Core never applies requests: `Server::take_requests` / `Hub::take_requests` hand them to the app, which applies them and reports the result through the setters (`Server::set_mic_default`, `set_volume`, …; `Hub::set_phone_controls`). `Status.pc` / `Status.phone` carry the reported state. +- **One-switch actions (PC):** "Микрофон" on = accept the mic + ask the phone to switch its mic on + make CABLE Output the default recording device; off = restore the remembered recording device (the phone mic is left alone: other PCs may use it). "Звук ПК" on also undoes a CABLE takeover of the default playback device. - **Datagram header:** 8 bytes, `0xAB | kind | stream | 0 | seq u32 LE`. Kinds: Audio, Silence (after 300 ms of digital silence the sender emits markers and stops), Pace (an empty keep-awake packet that the Hub sends every 20 ms while a PC's audio streams and that PC isn't receiving our mic; with the screen off, Android ≥14 won't let apps disable Wi-Fi power save, and uplink traffic is what keeps the radio awake. Receivers ignore it). - **Playout (NetEQ-lite, `audio/playout.rs`):** - Underrun policy: a missing frame with newer packets present → PLC, advance; buffer empty → "expand" PLC without advancing (no skipped content); after 70 ms of PLC fade to silence; resume with a 2.5 ms fade-in; never re-buffer the full target. @@ -32,7 +34,7 @@ PC (Server) WASAPI render "CABLE Input" ◄─ PlayoutHandle (jitter buffer, de - Loopback runs only when `connected && pc_audio_enabled` and the default device is not a VB-CABLE device. - Mic render runs only while `mic.active`. - Mic demand comes from `audio/demand.rs`: another process holding an active capture session on "CABLE Output". -- **Android audio policy** (`policy.rs`): output opens on `pcAudioActive` and closes after 30 s idle. Mic capture runs iff `micWanted && micAllowed`. +- **Android audio policy** (`policy.rs`): output opens on `pcAudioActive` and closes after 30 s idle. Mic capture runs iff `micWanted && phone.mic_allowed()` (user switch && mic_ready). ## Key Directories - `crates/core/src/` @@ -82,12 +84,12 @@ cargo ndk -t arm64-v8a clippy -p audiobridge-android --all-targets -- -D warning - Desktop: daily files in `%LOCALAPPDATA%\AudioBridge\logs`; filter via the `AUDIOBRIDGE_LOG` env var. - Android: `tracing` → `log` → logcat tag `AudioBridge`. The filter in `logging.rs` must keep `tracing::span=off`, otherwise span enter/exit lines flood logcat several times per audio frame. - **Contract coupling:** these pieces change together. - - The statusJson v2 schema (`android-native/src/status.rs`) ↔ `BridgeStatus.kt` (which throws on unknown states). - - Rust JNI symbols ↔ `NativeBridge.kt` ↔ `proguard-rules.pro` keep rules. - - Core `Server` API ↔ `crates/desktop`. + - The statusJson v2 schema (`android-native/src/status.rs`, incl. the per-PC remote controls `pcMic`, `micDefault`, `pcVolume`, `pcMuted`) ↔ `BridgeStatus.kt` (which throws on unknown states). + - Rust JNI symbols ↔ `NativeBridge.kt` ↔ `StatusListener.kt` (native calls `onStatus`, `onRemoteMic`, `onRemoteVolume` by name) ↔ `proguard-rules.pro` keep rules. + - Core `Server` API ↔ `crates/desktop`; core `ControlMsg` ↔ `PROTOCOL_VERSION` (both apps must be updated together). - **Persisted state:** - PC: `%APPDATA%\AudioBridge` holds `server.key`, `pairing.secret`, `server.port`, `settings.json` and `pairing.txt`. Writes go through tmp+rename; corrupt files are regenerated. Changing `server.key` or `pairing.secret` breaks every existing pairing. - - Phone: `/audiobridge/client.key`, plus SharedPreferences `audiobridge` (`paired_pcs` JSON list of `{id, name, uri, muted}`, `mic_enabled`, `autostart_done`). + - Phone: `/audiobridge/client.key`, plus SharedPreferences `audiobridge` (`paired_pcs` JSON list of `{id, name, uri, muted}`, `mic_enabled` (also flipped remotely by a PC), `autostart_done`). - **Default UDP port:** 47130, falling back to a random port. - **Screenshots and releases:** the pairing QR contains the PC's secret. Never publish it; promo shots replace it with a QR of the repo URL. GitHub Releases ship `AudioBridge.exe` (the release build) and `AudioBridge.apk` (the debug-signed APK from this machine's `~/.android/debug.keystore`; a different key can't update an installed app). @@ -115,7 +117,7 @@ cargo ndk -t arm64-v8a clippy -p audiobridge-android --all-targets -- -D warning - **Android build:** AGP 8.10.1, Kotlin 2.2.0, Gradle 8.11.1 wrapper, JDK 17 target. Compose versions are pinned explicitly (no BOM). `FAIL_ON_PROJECT_REPOS` means repositories are declared only in `settings.gradle.kts`. - **Android 14/15 FGS rules:** - Boot and background starts may use only `connectedDevice`. - - The `microphone` type is added only from a visible activity or the notification action (a while-in-use exemption). `setMicAllowed(true)` is sent only while that type is held. + - The `microphone` type is added only from a visible activity or the notification action (a while-in-use exemption). It is then kept whenever RECORD_AUDIO is granted, independent of the user's mic switch, so a PC can switch the mic on remotely in the background. `setMicState(enabled, ready)` reports the switch and whether the type is held; capture needs both. After a reboot the phone reports `mic_ready = false` until the app or the notification action is opened once, and the PC UI says so. - HyperOS "Clear all" force-stops unlocked apps, so users must enable Autostart and lock the app in recents. - **Windows:** - Desktop `windows` crate features are listed explicitly; new Win32 APIs need the matching feature added. @@ -127,7 +129,7 @@ cargo ndk -t arm64-v8a clippy -p audiobridge-android --all-targets -- -D warning - Core unit tests: proto, pairing, codec, tx gate, rx, the mixer limiter and persisted identity. - Playout simulations on a simulated clock (`audio/sim.rs`): jitter, power-save bursts, 2 % loss and ±0.15 % skew. They assert no exact-zero output runs and no skipped content. - `tests/rt_alloc.rs`: counts allocations and requires zero inside `push` and `fill`. - - `crates/core/tests/integration.rs`: a real Server + Hub in-process over `NetOptions::local_only()`, covering both directions plus latency < 60 ms, mixing from two PCs with mic routing, muting PCs on the phone, removing a PC, a wrong secret, reconnect, and uplink pacing. These tests are real-time and timing-sensitive; don't run them under heavy parallel load to judge flakiness. + - `crates/core/tests/integration.rs`: a real Server + Hub in-process over `NetOptions::local_only()`, covering both directions plus latency < 60 ms, mixing from two PCs with mic routing, muting PCs on the phone, removing a PC, a wrong secret, reconnect, uplink pacing and remote control in both directions. These tests are real-time and timing-sensitive; don't run them under heavy parallel load to judge flakiness. - **On-device glitch check:** the phone logs `pc audio glitch: … underruns=… lost=…` and the PC logs `mic glitch: …` whenever counters grow. Compare screen-on vs screen-off with music playing. - android-native: host tests for the statusJson v2 schema, `NotifyGate` debounce, policies and peer parsing. - Android: JVM tests `BridgeStatusTest` and `PairedPcTest` (`isReturnDefaultValues = false`); there is no instrumented test. diff --git a/Cargo.lock b/Cargo.lock index 963deeb..c2ada2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,7 +196,7 @@ dependencies = [ [[package]] name = "audiobridge-android" -version = "0.2.0" +version = "0.3.0" dependencies = [ "android_logger", "audiobridge-core", @@ -213,7 +213,7 @@ dependencies = [ [[package]] name = "audiobridge-core" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "audiobridge-opus-sys", @@ -233,7 +233,7 @@ dependencies = [ [[package]] name = "audiobridge-desktop" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "audiobridge-core", @@ -252,7 +252,7 @@ dependencies = [ [[package]] name = "audiobridge-opus-sys" -version = "0.2.0" +version = "0.3.0" dependencies = [ "cc", ] diff --git a/Cargo.toml b/Cargo.toml index f399a60..a78f356 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/core", "crates/opus-sys", "crates/desktop", "crates/android-native"] [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2021" license = "MIT" repository = "https://github.com/Lyten02/AudioBridge" diff --git a/README.md b/README.md index c3df83c..d22ae25 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Pair once by scanning a QR code. After that both apps run in the background, sta - **One-scan pairing.** The PC shows a QR code, the phone scans it, and that's it. - **Works across networks.** Peer-to-peer over [iroh](https://iroh.computer) QUIC: LAN, NAT hole punching, Tailscale, or an encrypted relay as a last resort. The phone and the PC don't need to share a Wi-Fi network. - **Several PCs at once.** Pair up to 8 computers (desktop, laptop, …). Their audio is mixed, and the mic goes only to the ones that use it. Mute one PC, or all of them with a single button, right from the phone; they stay connected. +- **Full remote control from either side.** From the PC: PC and phone volume, the phone mic, and the Windows default mic. From the phone: the PC's audio and mic switches, its volume and mute. The PC's "Mic" switch does everything at once: it turns the phone mic on and makes `CABLE Output` the Windows default mic; switching it off restores your previous mic. - **Background and autostart** on both sides. Low CPU: about 1–2 % of one core on the PC, 4–5 % on the phone while playing. - **End-to-end encrypted** (QUIC/TLS) and authenticated by the pairing secret from the QR code. diff --git a/README.ru.md b/README.ru.md index 6024e64..e27c7b1 100644 --- a/README.ru.md +++ b/README.ru.md @@ -27,6 +27,7 @@ AudioBridge передаёт весь звук компьютера на Android - **Сопряжение одним сканом QR-кода.** - **Работает между разными сетями.** P2P через [iroh](https://iroh.computer) QUIC: локальная сеть, пробой NAT, Tailscale, а в крайнем случае зашифрованный relay. Телефону и ПК не обязательно быть в одном Wi-Fi. - **Несколько компьютеров сразу.** До 8 ПК (стационарный, ноутбук, …): их звук смешивается, а микрофон уходит только туда, где он нужен. Звук отдельного ПК или всех сразу можно выключить одной кнопкой прямо с телефона, связь при этом не рвётся. +- **Полный пульт с обеих сторон.** С ПК: громкость компьютера и телефона, микрофон телефона, выбор микрофона Windows. С телефона: звук и микрофон компьютера, его громкость и mute. Кнопка «Микрофон» на ПК делает всё сразу: включает микрофон на телефоне и ставит `CABLE Output` микрофоном Windows по умолчанию; выключение возвращает прежний микрофон. - **Фон и автозапуск** на обеих сторонах. Нагрузка небольшая: на ПК 1–2 % одного ядра, на телефоне 4–5 % во время воспроизведения. - **Сквозное шифрование** (QUIC/TLS) и проверка по секрету из QR-кода. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c41428e..fc2d68a 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "app.audiobridge" minSdk = 26 targetSdk = 35 - versionCode = 2 - versionName = "0.2.0" + versionCode = 3 + versionName = "0.3.0" ndk { abiFilters += "arm64-v8a" } diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro index 0a64679..ab7ac6e 100644 --- a/android/app/proguard-rules.pro +++ b/android/app/proguard-rules.pro @@ -1,5 +1,9 @@ # JNI entry points are resolved by name from libaudiobridge.so. -keep class app.audiobridge.NativeBridge { *; } -# Native code calls StatusListener.onStatus(String) on the registered listener. +# Native code calls onStatus(String), onRemoteMic(boolean) and onRemoteVolume(int) on the registered listener by name. -keep interface app.audiobridge.StatusListener { *; } --keep class * implements app.audiobridge.StatusListener { public void onStatus(java.lang.String); } +-keep class * implements app.audiobridge.StatusListener { + public void onStatus(java.lang.String); + public void onRemoteMic(boolean); + public void onRemoteVolume(int); +} diff --git a/android/app/src/main/java/app/audiobridge/AppState.kt b/android/app/src/main/java/app/audiobridge/AppState.kt index 8fe6289..c33b28c 100644 --- a/android/app/src/main/java/app/audiobridge/AppState.kt +++ b/android/app/src/main/java/app/audiobridge/AppState.kt @@ -92,7 +92,7 @@ class Prefs private constructor(private val sp: SharedPreferences) { object StatusHub { val status = MutableStateFlow(BridgeStatus.IDLE) - /** The service currently runs with the microphone FGS type (so native capture is allowed). */ + /** The service holds the microphone FGS type and RECORD_AUDIO is granted (mic ready); capture also needs the switch. */ val micForeground = MutableStateFlow(false) } diff --git a/android/app/src/main/java/app/audiobridge/BridgeService.kt b/android/app/src/main/java/app/audiobridge/BridgeService.kt index af64d6e..5d7fd2e 100644 --- a/android/app/src/main/java/app/audiobridge/BridgeService.kt +++ b/android/app/src/main/java/app/audiobridge/BridgeService.kt @@ -11,6 +11,8 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo +import android.database.ContentObserver +import android.media.AudioManager import android.net.ConnectivityManager import android.net.LinkProperties import android.net.Network @@ -20,22 +22,27 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.PowerManager +import android.provider.Settings import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import kotlin.math.roundToInt /** * Keeps the native client connected in the background. * * Starts with FGS type connectedDevice only (allowed from BOOT_COMPLETED and background restarts). The microphone * type is added only from a while-in-use context: the visible activity ([ACTION_FOREGROUND]) or the user tapping the - * notification action ([ACTION_ENABLE_MIC]). Native mic capture is allowed only while that type is held. + * notification action ([ACTION_ENABLE_MIC]). Once held it is kept whenever RECORD_AUDIO is granted, independent of the + * user's mic switch, so a PC can switch the mic on remotely in the background ([StatusListener.onRemoteMic]). Native + * mic capture needs both the switch and the type ([NativeBridge.setMicState]). */ class BridgeService : Service() { private val main = Handler(Looper.getMainLooper()) private lateinit var prefs: Prefs private lateinit var notifications: NotificationManager + private lateinit var audio: AudioManager /** Last JSON array handed to [NativeBridge.setPeers]. */ private var appliedPeers: String? = null @@ -49,6 +56,14 @@ class BridgeService : Service() { private var wakeLock: PowerManager.WakeLock? = null private var networkCallback: ConnectivityManager.NetworkCallback? = null + /** Last media volume percent handed to [NativeBridge.setVolume]. */ + private var reportedVolume: Int? = null + + /** Volume changes are persisted to Settings.System: the public change signal for STREAM_MUSIC. */ + private val volumeObserver = object : ContentObserver(main) { + override fun onChange(selfChange: Boolean) = reportVolume() + } + private val statusListener = object : StatusListener { override fun onStatus(json: String) { val status = try { @@ -60,6 +75,14 @@ class BridgeService : Service() { StatusHub.status.value = status main.post { onStatusChanged(status) } } + + override fun onRemoteMic(enabled: Boolean) { + main.post { applyRemoteMic(enabled) } + } + + override fun onRemoteVolume(percent: Int) { + main.post { applyRemoteVolume(percent) } + } } override fun onBind(intent: Intent?): IBinder? = null @@ -68,9 +91,12 @@ class BridgeService : Service() { super.onCreate() prefs = Prefs.get(this) notifications = getSystemService(NotificationManager::class.java) + audio = getSystemService(AudioManager::class.java) createChannel() Native.ensureInit(this) NativeBridge.setListener(statusListener) + contentResolver.registerContentObserver(Settings.System.CONTENT_URI, true, volumeObserver) + reportVolume() registerNetworkCallback() } @@ -108,14 +134,17 @@ class BridgeService : Service() { } override fun onDestroy() { + contentResolver.unregisterContentObserver(volumeObserver) NativeBridge.setListener(null) - NativeBridge.setMicAllowed(false) + NativeBridge.setMicState(false, false) NativeBridge.setPeers("[]") appliedPeers = null appliedMuted = null networkCallback?.let { getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(it) } networkCallback = null main.removeCallbacksAndMessages(null) + // Callbacks the native thread posts from now on must not touch the stopped service. + fgsType = NOT_FOREGROUND setStreamingLocks(false) StatusHub.status.value = BridgeStatus.IDLE StatusHub.micForeground.value = false @@ -130,7 +159,8 @@ class BridgeService : Service() { * the microphone type. Returns false if the service could not become foreground at all. */ private fun updateForeground(promote: Boolean): Boolean { - val wantMic = prefs.micEnabled.value && hasRecordAudio() + // Kept regardless of the user's switch: a PC may switch the mic on while the app is in the background. + val wantMic = hasRecordAudio() val holdsMic = fgsType != NOT_FOREGROUND && (fgsType and MIC_TYPE) == MIC_TYPE val desired = if (wantMic && (promote || holdsMic)) BASE_TYPE or MIC_TYPE else BASE_TYPE @@ -158,7 +188,7 @@ class BridgeService : Service() { } else { refreshNotification(force = true) } - applyMicAllowed() + applyMicState() return true } @@ -168,18 +198,27 @@ class BridgeService : Service() { shownNotificationKey = notificationKey(StatusHub.status.value, isMicType(type)) } + /** [type] includes the microphone FGS type and RECORD_AUDIO is granted: the phone may record (mic ready). */ private fun isMicType(type: Int): Boolean = - type != NOT_FOREGROUND && (type and MIC_TYPE) == MIC_TYPE && prefs.micEnabled.value && hasRecordAudio() + type != NOT_FOREGROUND && (type and MIC_TYPE) == MIC_TYPE && hasRecordAudio() - private fun applyMicAllowed() { - val allowed = isMicType(fgsType) - StatusHub.micForeground.value = allowed - NativeBridge.setMicAllowed(allowed) + private fun applyMicState() { + val ready = isMicType(fgsType) + StatusHub.micForeground.value = ready + NativeBridge.setMicState(prefs.micEnabled.value, ready) } private fun revokeMic() { StatusHub.micForeground.value = false - NativeBridge.setMicAllowed(false) + NativeBridge.setMicState(prefs.micEnabled.value, false) + } + + /** A PC switched the phone mic: same as the user's switch (the microphone type is never added from here). */ + private fun applyRemoteMic(on: Boolean) { + if (fgsType == NOT_FOREGROUND) return + Log.i(TAG, "mic switched ${if (on) "on" else "off"} by a PC") + prefs.setMicEnabled(on) + updateForeground(promote = false) } private fun onStatusChanged(status: BridgeStatus) { @@ -265,6 +304,47 @@ class BridgeService : Service() { // endregion + // region volume + + private fun streamMinVolume(): Int = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) audio.getStreamMinVolume(AudioManager.STREAM_MUSIC) else 0 + + /** Media (STREAM_MUSIC) volume in percent of its range; -1 if it cannot be read or changed. */ + private fun mediaVolumePercent(): Int { + val min = streamMinVolume() + val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + if (audio.isVolumeFixed || max <= min) return -1 + val index = audio.getStreamVolume(AudioManager.STREAM_MUSIC) + return ((index - min) * 100f / (max - min)).roundToInt().coerceIn(0, 100) + } + + /** Reports the media volume to native (and so to every PC) when it changed. */ + private fun reportVolume() { + val percent = mediaVolumePercent() + if (percent == reportedVolume) return + reportedVolume = percent + NativeBridge.setVolume(percent) + } + + /** A PC set the phone's media volume, in percent. */ + private fun applyRemoteVolume(percent: Int) { + if (fgsType == NOT_FOREGROUND) return + val min = streamMinVolume() + val max = audio.getStreamMaxVolume(AudioManager.STREAM_MUSIC) + val index = (min + (max - min) * percent.coerceIn(0, 100) / 100f).roundToInt() + try { + audio.setStreamVolume(AudioManager.STREAM_MUSIC, index, 0) + } catch (e: SecurityException) { + // Do Not Disturb policy may refuse volume changes. + Log.w(TAG, "media volume change refused", e) + return + } + // Settings.System is written with a delay; report the new level right away. + reportVolume() + } + + // endregion + // region notification private fun createChannel() { @@ -332,9 +412,11 @@ class BridgeService : Service() { .setPriority(NotificationCompat.PRIORITY_LOW) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + // The switch is on and the phone may record: offer "mic off"; otherwise "mic on". + val micOn = micReady && prefs.micEnabled.value when { - micReady && status.micCapturing -> builder.setSubText(getString(R.string.notif_mic_live)) - micReady -> builder.setSubText(getString(R.string.notif_mic_ready)) + micOn && status.micCapturing -> builder.setSubText(getString(R.string.notif_mic_live)) + micOn -> builder.setSubText(getString(R.string.notif_mic_ready)) else -> Unit } @@ -343,7 +425,7 @@ class BridgeService : Service() { } else { builder.addAction(R.drawable.ic_volume_off, getString(R.string.notif_action_mute_all), servicePendingIntent(ACTION_MUTE_ALL, 4)) } - if (micReady) { + if (micOn) { builder.addAction( R.drawable.ic_mic, getString(R.string.notif_action_mic_off), diff --git a/android/app/src/main/java/app/audiobridge/BridgeStatus.kt b/android/app/src/main/java/app/audiobridge/BridgeStatus.kt index cd653e0..9046a2e 100644 --- a/android/app/src/main/java/app/audiobridge/BridgeStatus.kt +++ b/android/app/src/main/java/app/audiobridge/BridgeStatus.kt @@ -48,6 +48,13 @@ data class PeerStatus( val pcAudioEnabled: Boolean = true, val micEnabled: Boolean = false, val micDemanded: Boolean = false, + /** The PC's own mic switch ([micEnabled] is the effective state). */ + val pcMic: Boolean = false, + /** The virtual mic is the Windows default recording device on that PC. */ + val micDefault: Boolean = false, + /** The PC's default playback device volume in percent (0..100); null while unknown. */ + val pcVolume: Int? = null, + val pcMuted: Boolean = false, val pcAudio: StreamStats = StreamStats(), val mic: StreamStats = StreamStats(), val error: String? = null, @@ -113,6 +120,10 @@ data class BridgeStatus( pcAudioEnabled = o.getBoolean("pcAudioEnabled"), micEnabled = o.getBoolean("micEnabled"), micDemanded = o.getBoolean("micDemanded"), + pcMic = o.getBoolean("pcMic"), + micDefault = o.getBoolean("micDefault"), + pcVolume = if (o.isNull("pcVolume")) null else o.getInt("pcVolume"), + pcMuted = o.getBoolean("pcMuted"), pcAudio = parseStream(o.getJSONObject("pcAudio")), mic = parseStream(o.getJSONObject("mic")), error = o.optStringOrNull("error"), diff --git a/android/app/src/main/java/app/audiobridge/MainActivity.kt b/android/app/src/main/java/app/audiobridge/MainActivity.kt index 96aec65..576d7ca 100644 --- a/android/app/src/main/java/app/audiobridge/MainActivity.kt +++ b/android/app/src/main/java/app/audiobridge/MainActivity.kt @@ -101,6 +101,7 @@ class MainActivity : ComponentActivity() { onMutePc = ::setPcMuted, onMuteAll = ::setAllMuted, onMicToggle = ::setMic, + onPcControl = ::controlPc, ) } } @@ -203,6 +204,15 @@ class MainActivity : ComponentActivity() { // endregion + // region remote control + + /** [action] is a `NativeBridge.PC_*` constant; native drops the request (logged) if that PC is not connected. */ + private fun controlPc(pcId: String, action: Int, value: Int) { + NativeBridge.controlPc(pcId, action, value) + } + + // endregion + // region setup checklist private fun hasPermission(permission: String) = diff --git a/android/app/src/main/java/app/audiobridge/NativeBridge.kt b/android/app/src/main/java/app/audiobridge/NativeBridge.kt index 8082ef7..59a813d 100644 --- a/android/app/src/main/java/app/audiobridge/NativeBridge.kt +++ b/android/app/src/main/java/app/audiobridge/NativeBridge.kt @@ -18,13 +18,35 @@ object NativeBridge { /** Sets the full set of PC ids whose audio is muted on the phone (JSON array of ids); `[]` unmutes all. */ external fun setMuted(idsJson: String) - /** True only when RECORD_AUDIO is granted, the service holds FGS type microphone and the user toggle is on. */ - external fun setMicAllowed(allowed: Boolean) + /** + * Phone mic state: [enabled] = the user's mic switch; [ready] = RECORD_AUDIO is granted and the service holds FGS + * type microphone. Native capture needs both; PCs see both. + */ + external fun setMicState(enabled: Boolean, ready: Boolean) + + /** Phone media volume in percent (0..100) reported to the PCs; -1 = unknown. */ + external fun setVolume(percent: Int) + + /** + * Remote control of the connected PC [peerId]: [action] is one of the `PC_*` constants, [value] is 0/1 for the + * switches and 0..100 for [PC_VOLUME]. Dropped (logged) if that PC is not connected. + */ + external fun controlPc(peerId: String, action: Int, value: Int) external fun networkChanged() external fun statusJson(): String - /** Native calls [StatusListener.onStatus] on each status change (debounced ≥250 ms) from a native thread. */ + /** + * Native calls [StatusListener.onStatus] on each status change (debounced ≥250 ms) and the remote-control + * callbacks as requests arrive, from a native thread. + */ external fun setListener(listener: StatusListener?) + + // [controlPc] actions; keep in sync with crates/android-native/src/controls.rs. + const val PC_AUDIO = 0 + const val PC_MIC = 1 + const val PC_MIC_DEFAULT = 2 + const val PC_VOLUME = 3 + const val PC_MUTE = 4 } diff --git a/android/app/src/main/java/app/audiobridge/StatusListener.kt b/android/app/src/main/java/app/audiobridge/StatusListener.kt index 0420fe5..534028b 100644 --- a/android/app/src/main/java/app/audiobridge/StatusListener.kt +++ b/android/app/src/main/java/app/audiobridge/StatusListener.kt @@ -1,6 +1,13 @@ package app.audiobridge -/** Receives statusJson snapshots from native code (called from a native thread). */ +/** Callbacks from native code, all made from one native thread (called by name: see proguard-rules.pro). */ interface StatusListener { + /** A statusJson snapshot. */ fun onStatus(json: String) + + /** A connected PC asks to switch the phone mic on or off. */ + fun onRemoteMic(enabled: Boolean) + + /** A connected PC asks to set the phone media volume, in percent (0..100). */ + fun onRemoteVolume(percent: Int) } diff --git a/android/app/src/main/java/app/audiobridge/ui/MainScreen.kt b/android/app/src/main/java/app/audiobridge/ui/MainScreen.kt index 82163ae..d36aa39 100644 --- a/android/app/src/main/java/app/audiobridge/ui/MainScreen.kt +++ b/android/app/src/main/java/app/audiobridge/ui/MainScreen.kt @@ -41,8 +41,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.IconToggleButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface @@ -52,9 +54,11 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment @@ -70,12 +74,14 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import app.audiobridge.BridgeStatus +import app.audiobridge.NativeBridge import app.audiobridge.PairedPc import app.audiobridge.PeerStatus import app.audiobridge.R import app.audiobridge.Reachability import app.audiobridge.pathLabel import kotlin.math.roundToInt +import kotlinx.coroutines.delay /** One missing background-operation prerequisite. */ data class SetupItem( @@ -114,6 +120,8 @@ fun MainScreen( onMutePc: (id: String, muted: Boolean) -> Unit, onMuteAll: (muted: Boolean) -> Unit, onMicToggle: (Boolean) -> Unit, + /** Remote control of a connected PC: `NativeBridge.PC_*` action and its value. */ + onPcControl: (pcId: String, action: Int, value: Int) -> Unit, ) { var removeId by rememberSaveable { mutableStateOf(null) } val rows = pcs.map { PcRow(it, status.peer(it.id)) } @@ -147,6 +155,7 @@ fun MainScreen( PcCard( row, onRemove = { removeId = row.pc.id }, + onControl = { action, value -> onPcControl(row.pc.id, action, value) }, onMute = { onMutePc(row.pc.id, !row.pc.muted) }, ) } @@ -385,7 +394,7 @@ private fun SectionHeader(title: String, count: Int) { @OptIn(ExperimentalLayoutApi::class) @Composable -private fun PcCard(row: PcRow, onRemove: () -> Unit, onMute: () -> Unit) { +private fun PcCard(row: PcRow, onRemove: () -> Unit, onMute: () -> Unit, onControl: (action: Int, value: Int) -> Unit) { val reach = row.reachability val color by animateColorAsState(reachabilityColor(reach), label = "pcColor") val peer = row.peer @@ -464,6 +473,8 @@ private fun PcCard(row: PcRow, onRemove: () -> Unit, onMute: () -> Unit) { } if (peer.micDemanded) MicBadge() } + Spacer(Modifier.height(12.dp)) + PcControls(peer, onControl, Modifier.padding(end = 12.dp)) } else { val error = peer?.error if (reach == Reachability.Offline && !error.isNullOrBlank()) { @@ -482,6 +493,112 @@ private fun PcCard(row: PcRow, onRemove: () -> Unit, onMute: () -> Unit) { } } +/** Remote controls of a connected PC: audio and mic switches, plus its volume once the PC reports it. */ +@Composable +private fun PcControls(peer: PeerStatus, onControl: (action: Int, value: Int) -> Unit, modifier: Modifier = Modifier) { + Surface( + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surfaceContainerHighest, + modifier = modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(vertical = 4.dp)) { + ControlSwitchRow( + title = stringResource(R.string.pc_control_audio), + subtitle = null, + checked = peer.pcAudioEnabled, + onToggle = { onControl(NativeBridge.PC_AUDIO, if (it) 1 else 0) }, + ) + ControlSwitchRow( + title = stringResource(R.string.pc_control_mic), + subtitle = stringResource( + if (peer.micDefault) R.string.pc_control_mic_default else R.string.pc_control_mic_make_default, + ), + checked = peer.pcMic, + onToggle = { onControl(NativeBridge.PC_MIC, if (it) 1 else 0) }, + ) + val volume = peer.pcVolume + if (volume != null) { + PcVolumeRow( + level = volume, + muted = peer.pcMuted, + onVolume = { onControl(NativeBridge.PC_VOLUME, it) }, + onMute = { onControl(NativeBridge.PC_MUTE, if (it) 1 else 0) }, + ) + } + } + } +} + +@Composable +private fun ControlSwitchRow(title: String, subtitle: String?, checked: Boolean, onToggle: (Boolean) -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable(value = checked, role = Role.Switch, onValueChange = onToggle) + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.bodyLarge) + if (subtitle != null) { + Text( + subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + Spacer(Modifier.width(12.dp)) + Switch(checked = checked, onCheckedChange = null) + } +} + +/** + * The PC volume: mute toggle and a 0..100 slider. While dragging, and until the PC reports the new level (at most + * 1 s), the slider shows the local value; a request goes out only when the rounded value changes. + */ +@Composable +private fun PcVolumeRow(level: Int, muted: Boolean, onVolume: (Int) -> Unit, onMute: (Boolean) -> Unit) { + var local by remember { mutableStateOf(null) } + var dragging by remember { mutableStateOf(false) } + LaunchedEffect(local, dragging, level) { + val held = local ?: return@LaunchedEffect + if (dragging) return@LaunchedEffect + if (held.roundToInt() != level) delay(1000) + local = null + } + Column(Modifier.padding(top = 8.dp)) { + Text( + stringResource(R.string.pc_control_volume), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Row( + modifier = Modifier.padding(start = 4.dp, end = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconToggleButton(checked = muted, onCheckedChange = onMute) { + Icon( + painterResource(if (muted) R.drawable.ic_volume_off else R.drawable.ic_volume_up), + contentDescription = stringResource(R.string.pc_control_mute), + ) + } + Slider( + value = local ?: level.toFloat(), + onValueChange = { value -> + val previous = (local ?: level.toFloat()).roundToInt() + dragging = true + local = value + if (value.roundToInt() != previous) onVolume(value.roundToInt()) + }, + onValueChangeFinished = { dragging = false }, + valueRange = 0f..100f, + modifier = Modifier.weight(1f), + ) + } + } +} + @Composable private fun Chip(text: String, emphasized: Boolean = false) { val scheme = MaterialTheme.colorScheme diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 2dbc06f..21e2922 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -64,6 +64,14 @@ Телефон перестанет подключаться к этому компьютеру. Чтобы вернуть его, снова отсканируйте QR-код. Отмена + + Звук компьютера + Микрофон на компьютере + Основной микрофон Windows + Включение сделает его основным микрофоном Windows + Громкость компьютера + Без звука + Микрофон телефона Микрофон сейчас используется на ПК diff --git a/android/app/src/test/java/app/audiobridge/BridgeStatusTest.kt b/android/app/src/test/java/app/audiobridge/BridgeStatusTest.kt index c20d418..ab535bb 100644 --- a/android/app/src/test/java/app/audiobridge/BridgeStatusTest.kt +++ b/android/app/src/test/java/app/audiobridge/BridgeStatusTest.kt @@ -17,10 +17,15 @@ class BridgeStatusTest { pcActive: Boolean = true, micEnabled: Boolean = true, micDemanded: Boolean = false, + pcMic: Boolean = true, + micDefault: Boolean = false, + pcVolume: String = "64", + pcMuted: Boolean = false, error: String = "null", ) = """ {"id":"$id","name":"$name","state":"$state","path":$path,"rttMs":$rtt,"pcAudioEnabled":true, - "micEnabled":$micEnabled,"micDemanded":$micDemanded, + "micEnabled":$micEnabled,"micDemanded":$micDemanded,"pcMic":$pcMic,"micDefault":$micDefault, + "pcVolume":$pcVolume,"pcMuted":$pcMuted, "pcAudio":{"active":$pcActive,"bufferMs":31.0,"underruns":2,"lost":7,"kbps":190.5}, "mic":{"active":false,"bufferMs":0,"underruns":0,"lost":0,"kbps":0}, "error":$error} @@ -50,6 +55,28 @@ class BridgeStatusTest { assertEquals(StreamStats(active = true, bufferMs = 31f, underruns = 2, lost = 7, kbps = 190.5f), p.pcAudio) assertFalse(p.mic.active) assertNull(p.error) + assertTrue(p.pcMic) + assertFalse(p.micDefault) + assertEquals(64, p.pcVolume) + assertFalse(p.pcMuted) + } + + @Test + fun parsesPcRemoteControls() { + val p = BridgeStatus.parse( + hub(peers = listOf(peer(micEnabled = false, micDefault = true, pcVolume = "37", pcMuted = true))), + ).peers[0] + // The PC's own switch stays on while the effective state is off. + assertFalse(p.micEnabled) + assertTrue(p.pcMic) + assertTrue(p.micDefault) + assertEquals(37, p.pcVolume) + assertTrue(p.pcMuted) + + val unknown = BridgeStatus.parse(hub(peers = listOf(peer(pcMic = false, pcVolume = "null")))).peers[0] + assertFalse(unknown.pcMic) + assertNull(unknown.pcVolume) + assertFalse(unknown.pcMuted) } @Test diff --git a/crates/android-native/src/android/engine.rs b/crates/android-native/src/android/engine.rs index 531056e..7e63c2d 100644 --- a/crates/android-native/src/android/engine.rs +++ b/crates/android-native/src/android/engine.rs @@ -9,7 +9,7 @@ use std::sync::{Arc, Mutex, OnceLock}; use std::time::{Duration, Instant}; use audiobridge_core::pairing::PairingInfo; -use audiobridge_core::session::{Hub, HubConfig, HubStatus}; +use audiobridge_core::session::{Hub, HubConfig, HubStatus, PcRequest, PhoneControls, PhoneRequest}; use jni::objects::GlobalRef; use jni::JavaVM; use tokio::runtime::Runtime; @@ -24,9 +24,15 @@ use crate::status::StatusView; pub enum Cmd { /// The full set of paired PCs (already deduplicated by peer id); empty = disconnect all. SetPeers(Vec), + /// The phone mic: `enabled` = the user's switch, `ready` = Kotlin may record now (RECORD_AUDIO + microphone + /// FGS type). Capture needs both. + MicState { enabled: bool, ready: bool }, + /// Phone media volume in percent; `None` while unknown. + Volume(Option), + /// Remote control of one paired PC. + ControlPc { peer_id: String, req: PcRequest }, /// Peer ids whose audio is muted on the phone (the full set; empty = none). SetMuted(Vec), - MicAllowed(bool), NetworkChanged, } @@ -87,7 +93,8 @@ pub fn init(vm: JavaVM, files_dir: &str, device_name: &str) -> Result<(), String retry_at: None, backoff: Backoff::default(), start_error: None, - mic_allowed: false, + phone: PhoneControls::default(), + requests: None, audio_demand: None, glitches: std::collections::HashMap::new(), }; @@ -138,7 +145,10 @@ struct Manager { retry_at: Option, backoff: Backoff, start_error: Option, - mic_allowed: bool, + /// Phone-side controls as reported by Kotlin; every PC is told about changes. + phone: PhoneControls, + /// Remote-control requests from PCs, taken from the running hub; forwarded to Kotlin, which applies them. + requests: Option>, /// Last `(pc_active, mic_wanted)` sent to the audio thread. audio_demand: Option<(bool, bool)>, /// Last seen `(underruns, lost)` of each PC's audio stream, to log glitches as they happen. @@ -160,6 +170,10 @@ impl Manager { } } () = self.kick.notified() => {} + req = next_request(&mut self.requests) => match req { + Some(req) => self.listener.remote(req), + None => self.requests = None, + }, () = sleep_until(self.retry_at) => { self.retry_at = None; self.try_start().await; @@ -184,6 +198,11 @@ impl Manager { self.try_start().await; } } + Cmd::MicState { enabled, ready } => { + if self.set_phone(PhoneControls { mic: enabled, mic_ready: ready, ..self.phone }) { + log::info!("phone mic: enabled={enabled} ready={ready}"); + } + } Cmd::SetMuted(ids) => { log::info!("muted PCs: {ids:?}"); self.muted = ids; @@ -191,12 +210,17 @@ impl Manager { hub.set_muted(self.muted.clone()); } } - Cmd::MicAllowed(allowed) => { - self.mic_allowed = allowed; - if let Some(hub) = &self.hub { - hub.set_mic_enabled(allowed); - } + Cmd::Volume(volume) => { + self.set_phone(PhoneControls { volume, ..self.phone }); } + Cmd::ControlPc { peer_id, req } => match &self.hub { + Some(hub) => { + if !hub.request_pc(&peer_id, req) { + log::info!("remote control {req:?} not sent: PC {peer_id} is not connected"); + } + } + None => log::info!("remote control {req:?} not sent: hub not running"), + }, Cmd::NetworkChanged => { if let Some(hub) = &self.hub { hub.network_changed(); @@ -212,6 +236,18 @@ impl Manager { self.peers.iter().map(|p| p.info.clone()).collect() } + /// Stores new phone-side controls and tells the hub (and so every PC); `false` if nothing changed. + fn set_phone(&mut self, phone: PhoneControls) -> bool { + if phone == self.phone { + return false; + } + self.phone = phone; + if let Some(hub) = &self.hub { + hub.set_phone_controls(phone); + } + true + } + async fn try_start(&mut self) { if self.peers.is_empty() || self.hub.is_some() { return; @@ -219,7 +255,8 @@ impl Manager { let cfg = HubConfig { data_dir: self.data_dir.clone(), device_name: self.device_name.clone() }; match Hub::start(cfg).await { Ok(hub) => { - hub.set_mic_enabled(self.mic_allowed); + hub.set_phone_controls(self.phone); + self.requests = hub.take_requests(); hub.set_muted(self.muted.clone()); hub.set_peers(self.peer_infos()); let _ = self.audio.send(Event::Attach { playout: hub.pc_audio_playout(), capture: hub.mic_capture() }); @@ -246,6 +283,7 @@ impl Manager { self.retry_at = None; self.start_error = None; self.status_rx = None; + self.requests = None; if let Some(hub) = self.hub.take() { hub.shutdown().await; log::info!("hub stopped"); @@ -259,7 +297,7 @@ impl Manager { let view = match (&self.hub, &mut self.status_rx) { (Some(_), Some(rx)) => { let status = rx.borrow_and_update(); - let demand = (status.pc_audio_active, mic_should_capture(&status, self.mic_allowed)); + let demand = (status.pc_audio_active, mic_should_capture(&status, self.phone.mic_allowed())); if self.audio_demand != Some(demand) { self.audio_demand = Some(demand); let _ = self.audio.send(Event::Update { pc_active: demand.0, mic_wanted: demand.1 }); @@ -282,7 +320,7 @@ impl Manager { StatusView::from_hub(&status, configured, capturing) } _ if !self.peers.is_empty() => { - StatusView::starting(configured, self.mic_allowed, self.start_error.as_deref()) + StatusView::starting(configured, self.phone.mic_allowed(), self.start_error.as_deref()) } _ => StatusView::idle(), }; @@ -302,6 +340,14 @@ async fn status_changed(rx: &mut Option>, closed: boo } } +/// The next remote-control request (`None` once the hub dropped its sender); never resolves without a receiver. +async fn next_request(rx: &mut Option>) -> Option { + match rx { + Some(rx) => rx.recv().await, + None => pending().await, + } +} + async fn sleep_until(at: Option) { match at { Some(at) => tokio::time::sleep_until(at.into()).await, diff --git a/crates/android-native/src/android/jni_api.rs b/crates/android-native/src/android/jni_api.rs index c096a3b..56397db 100644 --- a/crates/android-native/src/android/jni_api.rs +++ b/crates/android-native/src/android/jni_api.rs @@ -5,12 +5,12 @@ use std::panic::{catch_unwind, AssertUnwindSafe}; use audiobridge_core::pairing::PairingInfo; use jni::objects::{JObject, JString}; -use jni::sys::{jboolean, jstring, JNI_FALSE}; +use jni::sys::{jboolean, jint, jstring, JNI_FALSE}; use jni::JNIEnv; use super::engine::{self, Cmd}; use super::logging; -use crate::peers; +use crate::{controls, peers}; use crate::status::{PairingView, StatusView}; fn guard(name: &str, fallback: R, f: impl FnOnce() -> R) -> R { @@ -181,15 +181,52 @@ pub extern "system" fn Java_app_audiobridge_NativeBridge_setMuted<'l>( }) } +/// `enabled`: the user's mic switch; `ready`: RECORD_AUDIO is granted and the service holds the microphone FGS +/// type. Capture needs both; every PC sees both. #[no_mangle] -pub extern "system" fn Java_app_audiobridge_NativeBridge_setMicAllowed<'l>( +pub extern "system" fn Java_app_audiobridge_NativeBridge_setMicState<'l>( _env: JNIEnv<'l>, _this: JObject<'l>, - allowed: jboolean, + enabled: jboolean, + ready: jboolean, ) { - guard("setMicAllowed", (), || { - if let Some(engine) = engine_or_log("setMicAllowed") { - engine.send(Cmd::MicAllowed(allowed != JNI_FALSE)); + guard("setMicState", (), || { + if let Some(engine) = engine_or_log("setMicState") { + engine.send(Cmd::MicState { enabled: enabled != JNI_FALSE, ready: ready != JNI_FALSE }); + } + }) +} + +/// Phone media volume in percent reported to the PCs; negative = unknown, above 100 is clamped. +#[no_mangle] +pub extern "system" fn Java_app_audiobridge_NativeBridge_setVolume<'l>( + _env: JNIEnv<'l>, + _this: JObject<'l>, + percent: jint, +) { + guard("setVolume", (), || { + if let Some(engine) = engine_or_log("setVolume") { + engine.send(Cmd::Volume(controls::volume_percent(percent))); + } + }) +} + +/// Remote control of the PC `peerId`: `action` is a `NativeBridge.PC_*` constant, `value` 0/1 for switches and +/// `0..=100` for the volume (see [`controls::pc_request`]). Dropped (logged) if that PC is not connected. +#[no_mangle] +pub extern "system" fn Java_app_audiobridge_NativeBridge_controlPc<'l>( + mut env: JNIEnv<'l>, + _this: JObject<'l>, + peer_id: JString<'l>, + action: jint, + value: jint, +) { + guard("controlPc", (), || { + let Some(engine) = engine_or_log("controlPc") else { return }; + let Some(peer_id) = read_string(&mut env, &peer_id) else { return }; + match controls::pc_request(action, value) { + Some(req) => engine.send(Cmd::ControlPc { peer_id, req }), + None => log::warn!("controlPc: unknown action {action}"), } }) } diff --git a/crates/android-native/src/android/listener.rs b/crates/android-native/src/android/listener.rs index 361dcf1..0e5d66d 100644 --- a/crates/android-native/src/android/listener.rs +++ b/crates/android-native/src/android/listener.rs @@ -1,10 +1,12 @@ -//! Delivers status JSON to the Kotlin `StatusListener` from one dedicated native thread that is attached -//! to the JavaVM for its whole life. Calls are gated by [`NotifyGate`]: ≥250 ms apart, statistics-only -//! changes ≥2 s apart, nothing when the view is unchanged. +//! Delivers status JSON and remote-control requests to the Kotlin `StatusListener` from one dedicated native +//! thread that is attached to the JavaVM for its whole life. Status calls are gated by [`NotifyGate`]: ≥250 ms +//! apart, statistics-only changes ≥2 s apart, nothing when the view is unchanged. Remote-control requests are +//! delivered immediately and in order (`onRemoteMic` / `onRemoteVolume`). use std::sync::mpsc::{self, RecvTimeoutError, Sender}; use std::time::Instant; +use audiobridge_core::session::PhoneRequest; use jni::objects::{GlobalRef, JValue}; use jni::{JNIEnv, JavaVM}; @@ -13,6 +15,7 @@ use crate::status::{Gate, NotifyGate, StatusView}; enum Msg { View(StatusView), Listener(Option), + Remote(PhoneRequest), } #[derive(Clone)] @@ -47,7 +50,8 @@ impl Listener { Err(_) => break, }, }; - // Coalesce everything already queued; only the newest view matters. + // Coalesce everything already queued; only the newest view matters. Remote requests are never + // coalesced: each one goes to the listener that is current at that point of the queue. for m in msg.into_iter().chain(rx.try_iter()) { match m { Msg::View(v) => current = v, @@ -55,6 +59,10 @@ impl Listener { listener = l; gate.reset(); } + Msg::Remote(req) => match &listener { + Some(target) => deliver_remote(&mut env, target, req), + None => log::warn!("remote request {req:?} dropped: no listener"), + }, } } deadline = None; @@ -80,6 +88,11 @@ impl Listener { pub fn set(&self, listener: Option) { let _ = self.tx.send(Msg::Listener(listener)); } + + /// A PC asks to change a phone-side control; Kotlin applies it and reports the result. + pub fn remote(&self, req: PhoneRequest) { + let _ = self.tx.send(Msg::Remote(req)); + } } fn deliver(env: &mut JNIEnv, listener: &GlobalRef, json: &str) { @@ -89,10 +102,26 @@ fn deliver(env: &mut JNIEnv, listener: &GlobalRef, json: &str) { Ok(()) }); if let Err(e) = result { - if env.exception_check().unwrap_or(false) { - let _ = env.exception_describe(); - let _ = env.exception_clear(); - } - log::warn!("StatusListener.onStatus failed: {e}"); + call_failed(env, "onStatus", &e); + } +} + +/// `onRemoteMic(Z)V` / `onRemoteVolume(I)V`: void calls with primitive arguments create no local references. +fn deliver_remote(env: &mut JNIEnv, listener: &GlobalRef, req: PhoneRequest) { + let (method, sig, arg) = match req { + PhoneRequest::Mic(on) => ("onRemoteMic", "(Z)V", JValue::from(on)), + PhoneRequest::Volume(percent) => ("onRemoteVolume", "(I)V", JValue::Int(i32::from(percent))), + }; + if let Err(e) = env.call_method(listener, method, sig, &[arg]) { + call_failed(env, method, &e); + } +} + +/// Clears a pending Java exception (this thread keeps calling into Java) and logs the failure. +fn call_failed(env: &mut JNIEnv, method: &str, e: &jni::errors::Error) { + if env.exception_check().unwrap_or(false) { + let _ = env.exception_describe(); + let _ = env.exception_clear(); } + log::warn!("StatusListener.{method} failed: {e}"); } diff --git a/crates/android-native/src/controls.rs b/crates/android-native/src/controls.rs new file mode 100644 index 0000000..fbc9996 --- /dev/null +++ b/crates/android-native/src/controls.rs @@ -0,0 +1,92 @@ +//! Pure mapping of the remote-control JNI arguments (`NativeBridge.controlPc` / `NativeBridge.setVolume`) +//! to core types. + +use audiobridge_core::proto::MAX_LEVEL; +use audiobridge_core::session::PcRequest; + +// `controlPc` actions; keep in sync with the `NativeBridge.PC_*` constants. +pub const PC_AUDIO: i32 = 0; +pub const PC_MIC: i32 = 1; +pub const PC_MIC_DEFAULT: i32 = 2; +pub const PC_VOLUME: i32 = 3; +pub const PC_MUTE: i32 = 4; + +/// `controlPc(peerId, action, value)`: switches are on for any non-zero `value`, the volume is clamped to +/// `0..=100`. `None` for an unknown action. +pub fn pc_request(action: i32, value: i32) -> Option { + let on = value != 0; + Some(match action { + PC_AUDIO => PcRequest::Audio(on), + PC_MIC => PcRequest::Mic(on), + PC_MIC_DEFAULT => PcRequest::MicDefault(on), + PC_VOLUME => PcRequest::Volume(clamp_level(value)), + PC_MUTE => PcRequest::Mute(on), + _ => return None, + }) +} + +/// `setVolume(percent)`: a negative value means unknown (`None`); values above 100 are clamped. +pub fn volume_percent(percent: i32) -> Option { + (percent >= 0).then(|| clamp_level(percent)) +} + +fn clamp_level(v: i32) -> u8 { + // Lossless: the value is clamped to 0..=100 first. + v.clamp(0, i32::from(MAX_LEVEL)) as u8 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn switch_actions_map_to_requests() { + let cases = [ + (PC_AUDIO, 1, PcRequest::Audio(true)), + (PC_AUDIO, 0, PcRequest::Audio(false)), + (PC_MIC, 1, PcRequest::Mic(true)), + (PC_MIC, 0, PcRequest::Mic(false)), + (PC_MIC_DEFAULT, 1, PcRequest::MicDefault(true)), + (PC_MIC_DEFAULT, 0, PcRequest::MicDefault(false)), + (PC_MUTE, 1, PcRequest::Mute(true)), + (PC_MUTE, 0, PcRequest::Mute(false)), + // Any non-zero value switches on. + (PC_MIC, -1, PcRequest::Mic(true)), + (PC_MUTE, 7, PcRequest::Mute(true)), + ]; + for (action, value, req) in cases { + assert_eq!(pc_request(action, value), Some(req), "action {action} value {value}"); + } + } + + #[test] + fn volume_request_is_clamped() { + for (value, level) in [(0, 0), (42, 42), (100, 100), (101, 100), (i32::MAX, 100), (-1, 0), (i32::MIN, 0)] { + assert_eq!(pc_request(PC_VOLUME, value), Some(PcRequest::Volume(level)), "value {value}"); + } + } + + #[test] + fn unknown_action_is_none() { + for action in [-1, 5, i32::MIN, i32::MAX] { + assert_eq!(pc_request(action, 1), None, "action {action}"); + } + } + + #[test] + fn phone_volume_percent_maps_unknown_and_clamps() { + let cases = [ + (-1, None), + (-50, None), + (i32::MIN, None), + (0, Some(0)), + (55, Some(55)), + (100, Some(100)), + (101, Some(100)), + (i32::MAX, Some(100)), + ]; + for (percent, volume) in cases { + assert_eq!(volume_percent(percent), volume, "percent {percent}"); + } + } +} diff --git a/crates/android-native/src/lib.rs b/crates/android-native/src/lib.rs index 53b5791..4ecb4b2 100644 --- a/crates/android-native/src/lib.rs +++ b/crates/android-native/src/lib.rs @@ -1,8 +1,10 @@ //! `libaudiobridge.so`: JNI bridge between the Kotlin app (`app.audiobridge.NativeBridge`) and //! `audiobridge-core`, plus AAudio playback/capture. //! -//! `peers`, `policy` and `status` are pure and host-testable; everything touching JNI/AAudio is Android-only. +//! `controls`, `peers`, `policy` and `status` are pure and host-testable; everything touching JNI/AAudio is +//! Android-only. +pub mod controls; pub mod peers; pub mod policy; pub mod status; diff --git a/crates/android-native/src/status.rs b/crates/android-native/src/status.rs index 2c129c4..3b3724f 100644 --- a/crates/android-native/src/status.rs +++ b/crates/android-native/src/status.rs @@ -2,7 +2,12 @@ //! //! Schema (all keys always present): //! `{"state","micEnabled","micCapturing","micWanted","pcAudioActive","peers":[{"id","name","state","path","rttMs", -//! "pcAudioEnabled","micEnabled","micDemanded","pcAudio":{...},"mic":{...},"error"}]}` +//! "pcAudioEnabled","micEnabled","micDemanded","pcMic","micDefault","pcVolume","pcMuted","pcAudio":{...},"mic":{...}, +//! "error"}]}` +//! +//! Remote controls of each PC as it last reported them: `pcMic` is its own mic switch (`micEnabled` stays the +//! effective state), `micDefault` = its virtual mic is the Windows default recording device, `pcVolume` = its default +//! playback device volume in percent (`null` while unknown), `pcMuted` = that device is muted. use std::time::{Duration, Instant}; @@ -49,6 +54,13 @@ pub struct PeerView { pub pc_audio_enabled: bool, pub mic_enabled: bool, pub mic_demanded: bool, + /// The PC's own mic switch (`mic_enabled` is the effective state). + pub pc_mic: bool, + /// The virtual mic is the Windows default recording device on that PC. + pub mic_default: bool, + /// The PC's default playback device volume in percent; `None` while unknown. + pub pc_volume: Option, + pub pc_muted: bool, pub pc_audio: StreamView, pub mic: StreamView, pub error: Option, @@ -66,6 +78,10 @@ impl PeerView { pc_audio_enabled: s.pc_audio_enabled, mic_enabled: s.mic_enabled, mic_demanded: s.mic_demanded, + pc_mic: s.pc.mic, + mic_default: s.pc.mic_default, + pc_volume: s.pc.volume.map(|v| v.level), + pc_muted: s.pc.volume.is_some_and(|v| v.muted), pc_audio: StreamView::from_stats(&s.pc_audio), mic: StreamView::from_stats(&s.mic), error: s.last_error.clone(), @@ -83,6 +99,10 @@ impl PeerView { pc_audio_enabled: false, mic_enabled: false, mic_demanded: false, + pc_mic: false, + mic_default: false, + pc_volume: None, + pc_muted: false, pc_audio: StreamView::IDLE, mic: StreamView::IDLE, error, @@ -98,6 +118,10 @@ impl PeerView { && self.pc_audio_enabled == o.pc_audio_enabled && self.mic_enabled == o.mic_enabled && self.mic_demanded == o.mic_demanded + && self.pc_mic == o.pc_mic + && self.mic_default == o.mic_default + && self.pc_volume == o.pc_volume + && self.pc_muted == o.pc_muted && self.pc_audio.active == o.pc_audio.active && self.mic.active == o.mic.active && self.error == o.error @@ -290,7 +314,7 @@ impl NotifyGate { #[cfg(test)] mod tests { use super::*; - use audiobridge_core::session::PeerStatus; + use audiobridge_core::session::{PcControls, PeerStatus, Volume}; fn stats(active: bool, buffer_ms: f32, kbps: f32) -> StreamStats { StreamStats { active, buffer_ms, underruns: 3, lost_packets: 7, kbps } @@ -307,6 +331,8 @@ mod tests { mic_demanded: false, pc_audio: stats(true, 31.04, 190.46), mic: stats(false, 0.0, 0.0), + pc: PcControls { audio: true, mic: true, mic_default: false, volume: Some(Volume { level: 64, muted: false }) }, + phone: None, last_error: None, } } @@ -346,6 +372,10 @@ mod tests { "pcAudioEnabled": true, "micEnabled": true, "micDemanded": false, + "pcMic": true, + "micDefault": false, + "pcVolume": 64, + "pcMuted": false, "pcAudio": {"active": true, "bufferMs": 31.0, "underruns": 3, "lost": 7, "kbps": 190.5}, "mic": {"active": false, "bufferMs": 0.0, "underruns": 3, "lost": 7, "kbps": 0.0}, "error": null @@ -447,6 +477,41 @@ mod tests { assert_eq!(v["peers"][0]["pcAudio"]["bufferMs"], 0.0); } + #[test] + fn pc_remote_controls_reach_json() { + use serde_json::json; + + let mut reported = connected(Some("LYTEN")); + // The phone's switch is off: the effective `micEnabled` is off while the PC's own switch stays on. + reported.mic_enabled = false; + reported.pc = + PcControls { audio: true, mic: true, mic_default: true, volume: Some(Volume { level: 37, muted: true }) }; + let mut unknown_volume = connected(None); + unknown_volume.pc = PcControls { mic: false, volume: None, ..PcControls::default() }; + let status = hub(vec![("a", reported), ("b", unknown_volume)]); + // "c" is configured but the hub does not report it yet: shown as starting. + let configured = [("a", "DESKTOP"), ("b", "LAPTOP"), ("c", "NEW")]; + let v: serde_json::Value = + serde_json::from_str(&StatusView::from_hub(&status, configured, false).to_json()).unwrap(); + + let keys = ["micEnabled", "pcMic", "micDefault", "pcVolume", "pcMuted"]; + let controls: Vec> = v["peers"] + .as_array() + .unwrap() + .iter() + .map(|p| keys.iter().map(|k| p.get(*k).cloned().unwrap_or_else(|| panic!("missing {k}"))).collect()) + .collect(); + assert_eq!( + controls, + [ + vec![json!(false), json!(true), json!(true), json!(37), json!(true)], + // Unknown volume: null and never muted. + vec![json!(true), json!(false), json!(false), json!(null), json!(false)], + vec![json!(false), json!(false), json!(false), json!(null), json!(false)], + ] + ); + } + #[test] fn classify_separates_stats_from_meaning() { let base = view(vec![("a", connected(Some("LYTEN"))), ("b", connected(None))]); @@ -472,6 +537,11 @@ mod tests { |v| v.peers[1].pc_audio_enabled = false, |v| v.peers[1].mic_enabled = false, |v| v.peers[1].mic_demanded = true, + |v| v.peers[1].pc_mic = false, + |v| v.peers[1].mic_default = true, + |v| v.peers[1].pc_volume = Some(10), + |v| v.peers[1].pc_volume = None, + |v| v.peers[1].pc_muted = true, |v| v.peers[1].pc_audio.active = false, |v| v.peers[1].mic.active = true, |v| v.peers[1].error = Some("x".into()), diff --git a/crates/core/examples/probe.rs b/crates/core/examples/probe.rs index f8316ca..ca331cd 100644 --- a/crates/core/examples/probe.rs +++ b/crates/core/examples/probe.rs @@ -14,7 +14,7 @@ use std::time::{Duration, Instant}; use anyhow::{bail, Context, Result}; use audiobridge_core::pairing::PairingInfo; -use audiobridge_core::session::{ConnState, Hub, HubConfig, NetOptions, StreamStats}; +use audiobridge_core::session::{ConnState, Hub, HubConfig, NetOptions, PhoneControls, StreamStats}; use parking_lot::Mutex; const BLOCK: usize = 480; @@ -119,7 +119,8 @@ async fn main() -> Result<()> { ) .await?; hub.set_peers(vec![pairing]); - hub.set_mic_enabled(args.mic_sine.is_some()); + let mic = args.mic_sine.is_some(); + hub.set_phone_controls(PhoneControls { mic, mic_ready: mic, volume: None }); let stop = Arc::new(AtomicBool::new(false)); let meter = Arc::new(Mutex::new(Meter::default())); diff --git a/crates/core/src/proto.rs b/crates/core/src/proto.rs index 365747d..bb11b95 100644 --- a/crates/core/src/proto.rs +++ b/crates/core/src/proto.rs @@ -10,8 +10,8 @@ pub const SAMPLE_RATE: u32 = 48_000; pub const FRAME_SAMPLES: usize = 480; /// Preferred UDP port of the PC endpoint (IPv4). Falls back to a random port when busy. pub const DEFAULT_PORT: u16 = 47130; -/// Control protocol version carried in `Hello`. -pub const PROTOCOL_VERSION: u16 = 1; +/// Control protocol version carried in `Hello`. v2: remote control (state reports + requests). +pub const PROTOCOL_VERSION: u16 = 2; /// Largest Opus payload sent or accepted. The encoder is capped to it (≈ 512 kbit/s per 10 ms /// frame, far above the configured bitrates), so jitter-buffer slots stay small. @@ -117,6 +117,73 @@ impl DatagramHeader { } } +/// Highest volume level; levels are percent (`0..=100`). +pub const MAX_LEVEL: u8 = 100; + +/// A device volume: level in percent plus mute. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Volume { + pub level: u8, + pub muted: bool, +} + +/// PC-side controls. The PC owns them; the phone sees the last report of each PC. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PcControls { + /// PC audio is streamed to the phone. + pub audio: bool, + /// The PC accepts the phone mic (plays it into the virtual cable). + pub mic: bool, + /// The virtual mic ("CABLE Output") is the Windows default recording device. + pub mic_default: bool, + /// Default playback device volume; `None` while unknown. + pub volume: Option, +} + +impl Default for PcControls { + fn default() -> Self { + Self { audio: true, mic: true, mic_default: false, volume: None } + } +} + +/// Phone-side controls. The phone owns them; the PC sees the connected phone's last report. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct PhoneControls { + /// The user's mic switch on the phone. + pub mic: bool, + /// The phone may record right now (permission + Android's foreground-service mic access). + pub mic_ready: bool, + /// Media volume in percent; `None` while unknown. + pub volume: Option, +} + +impl PhoneControls { + /// The phone mic can be sent to PCs. + pub fn mic_allowed(&self) -> bool { + self.mic && self.mic_ready + } +} + +/// Phone → PC: change a PC-side control. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PcRequest { + Audio(bool), + /// One-switch mic: on = accept the phone mic and make the virtual mic the default + /// recording device; off = stop and restore the previous recording device. + Mic(bool), + /// Only the Windows default recording device part of [`PcRequest::Mic`]. + MicDefault(bool), + Volume(u8), + Mute(bool), +} + +/// PC → phone: change a phone-side control. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PhoneRequest { + Mic(bool), + Volume(u8), +} + /// Messages on the single bi-directional control stream (phone opens it). /// Framing: `len u16 LE | tag u8 | body`. #[derive(Clone, Debug, PartialEq, Eq)] @@ -126,27 +193,32 @@ pub enum ControlMsg { version: u16, secret: [u8; 16], device_name: String, - mic_allowed: bool, + phone: PhoneControls, }, - /// PC → phone, accepted. Carries the PC-side toggles. + /// PC → phone, accepted. Carries the PC-side controls. Welcome { pc_name: String, - pc_audio_enabled: bool, - mic_enabled: bool, + pc: PcControls, mic_demanded: bool, }, /// PC → phone, refused; the connection is closed afterwards. Reject { reason: String }, - /// PC → phone: PC-side stream toggles changed. - Toggles { pc_audio_enabled: bool, mic_enabled: bool }, + /// PC → phone: PC-side controls changed. + PcState(PcControls), /// PC → phone: whether a PC application is capturing the virtual microphone. MicDemand(bool), - /// Phone → PC: phone-side microphone permission/toggle changed. - MicAllowed(bool), + /// Phone → PC: phone-side controls changed. + PhoneState(PhoneControls), + /// Phone → PC: remote-control request. + SetPc(PcRequest), + /// PC → phone: remote-control request. + SetPhone(PhoneRequest), } const MAX_CONTROL_LEN: usize = 1024; const MAX_NAME_LEN: usize = 128; +/// Wire value of an unknown volume level. +const LEVEL_UNKNOWN: u8 = 0xFF; impl ControlMsg { /// Encodes the message including its length prefix. @@ -157,45 +229,58 @@ impl ControlMsg { version, secret, device_name, - mic_allowed, + phone, } => { body.push(1); body.extend_from_slice(&version.to_le_bytes()); body.extend_from_slice(secret); put_str(&mut body, device_name); - body.push(*mic_allowed as u8); + put_phone(&mut body, phone); } ControlMsg::Welcome { pc_name, - pc_audio_enabled, - mic_enabled, + pc, mic_demanded, } => { body.push(2); put_str(&mut body, pc_name); - body.push(*pc_audio_enabled as u8); - body.push(*mic_enabled as u8); + put_pc(&mut body, pc); body.push(*mic_demanded as u8); } ControlMsg::Reject { reason } => { body.push(3); put_str(&mut body, reason); } - ControlMsg::Toggles { - pc_audio_enabled, - mic_enabled, - } => { + ControlMsg::PcState(pc) => { body.push(4); - body.push(*pc_audio_enabled as u8); - body.push(*mic_enabled as u8); + put_pc(&mut body, pc); } ControlMsg::MicDemand(on) => { body.push(5); body.push(*on as u8); } - ControlMsg::MicAllowed(on) => { + ControlMsg::PhoneState(phone) => { body.push(6); - body.push(*on as u8); + put_phone(&mut body, phone); + } + ControlMsg::SetPc(req) => { + body.push(7); + let (kind, value) = match *req { + PcRequest::Audio(on) => (0, on as u8), + PcRequest::Mic(on) => (1, on as u8), + PcRequest::MicDefault(on) => (2, on as u8), + PcRequest::Volume(level) => (3, level.min(MAX_LEVEL)), + PcRequest::Mute(on) => (4, on as u8), + }; + body.extend_from_slice(&[kind, value]); + } + ControlMsg::SetPhone(req) => { + body.push(8); + let (kind, value) = match *req { + PhoneRequest::Mic(on) => (0, on as u8), + PhoneRequest::Volume(level) => (1, level.min(MAX_LEVEL)), + }; + body.extend_from_slice(&[kind, value]); } } let mut out = Vec::with_capacity(body.len() + 2); @@ -208,25 +293,42 @@ impl ControlMsg { pub fn decode(body: &[u8]) -> Result { let mut r = Reader(body); let msg = match r.u8()? { - 1 => ControlMsg::Hello { - version: u16::from_le_bytes(r.array()?), - secret: r.array()?, - device_name: r.string()?, - mic_allowed: r.bool()?, - }, + 1 => { + let version = u16::from_le_bytes(r.array()?); + let secret = r.array()?; + let device_name = r.string()?; + // Other versions carry other fields; keep only what the PC needs to reject them + // with a readable reason. + let phone = if version == PROTOCOL_VERSION { + r.phone()? + } else { + r.0 = &[]; + PhoneControls::default() + }; + ControlMsg::Hello { version, secret, device_name, phone } + } 2 => ControlMsg::Welcome { pc_name: r.string()?, - pc_audio_enabled: r.bool()?, - mic_enabled: r.bool()?, + pc: r.pc()?, mic_demanded: r.bool()?, }, 3 => ControlMsg::Reject { reason: r.string()? }, - 4 => ControlMsg::Toggles { - pc_audio_enabled: r.bool()?, - mic_enabled: r.bool()?, - }, + 4 => ControlMsg::PcState(r.pc()?), 5 => ControlMsg::MicDemand(r.bool()?), - 6 => ControlMsg::MicAllowed(r.bool()?), + 6 => ControlMsg::PhoneState(r.phone()?), + 7 => ControlMsg::SetPc(match r.u8()? { + 0 => PcRequest::Audio(r.bool()?), + 1 => PcRequest::Mic(r.bool()?), + 2 => PcRequest::MicDefault(r.bool()?), + 3 => PcRequest::Volume(r.level()?), + 4 => PcRequest::Mute(r.bool()?), + k => bail!("unknown PC request {k}"), + }), + 8 => ControlMsg::SetPhone(match r.u8()? { + 0 => PhoneRequest::Mic(r.bool()?), + 1 => PhoneRequest::Volume(r.level()?), + k => bail!("unknown phone request {k}"), + }), t => bail!("unknown control message tag {t}"), }; ensure!(r.0.is_empty(), "trailing bytes in control message"); @@ -269,6 +371,23 @@ fn put_str(out: &mut Vec, s: &str) { out.extend_from_slice(s.as_bytes()); } +fn put_level(out: &mut Vec, level: Option) { + out.push(level.map_or(LEVEL_UNKNOWN, |l| l.min(MAX_LEVEL))); +} + +/// `audio | mic | mic_default | level (0xFF unknown) | muted` (all `u8`). +fn put_pc(out: &mut Vec, pc: &PcControls) { + out.extend_from_slice(&[pc.audio as u8, pc.mic as u8, pc.mic_default as u8]); + put_level(out, pc.volume.map(|v| v.level)); + out.push(pc.volume.is_some_and(|v| v.muted) as u8); +} + +/// `mic | mic_ready | level (0xFF unknown)` (all `u8`). +fn put_phone(out: &mut Vec, phone: &PhoneControls) { + out.extend_from_slice(&[phone.mic as u8, phone.mic_ready as u8]); + put_level(out, phone.volume); +} + pub(crate) struct Reader<'a>(pub(crate) &'a [u8]); impl<'a> Reader<'a> { @@ -297,6 +416,28 @@ impl<'a> Reader<'a> { .context("invalid utf-8")? .to_owned()) } + fn level(&mut self) -> Result { + let v = self.u8()?; + ensure!(v <= MAX_LEVEL, "bad volume level {v}"); + Ok(v) + } + fn opt_level(&mut self) -> Result> { + match self.u8()? { + LEVEL_UNKNOWN => Ok(None), + v if v <= MAX_LEVEL => Ok(Some(v)), + v => bail!("bad volume level {v}"), + } + } + fn pc(&mut self) -> Result { + let (audio, mic, mic_default) = (self.bool()?, self.bool()?, self.bool()?); + let level = self.opt_level()?; + let muted = self.bool()?; + ensure!(level.is_some() || !muted, "mute without a volume"); + Ok(PcControls { audio, mic, mic_default, volume: level.map(|level| Volume { level, muted }) }) + } + fn phone(&mut self) -> Result { + Ok(PhoneControls { mic: self.bool()?, mic_ready: self.bool()?, volume: self.opt_level()? }) + } } #[cfg(test)] @@ -354,28 +495,40 @@ mod tests { #[test] fn control_roundtrip_and_garbage() { + let pc = PcControls { + audio: true, + mic: false, + mic_default: true, + volume: Some(Volume { level: 42, muted: true }), + }; + let phone = PhoneControls { mic: true, mic_ready: false, volume: Some(100) }; let msgs = [ ControlMsg::Hello { version: PROTOCOL_VERSION, secret: [7; 16], device_name: "POCO F5".into(), - mic_allowed: true, + phone, }, ControlMsg::Welcome { pc_name: "LYTEN".into(), - pc_audio_enabled: true, - mic_enabled: false, + pc, mic_demanded: true, }, ControlMsg::Reject { reason: "bad secret".into(), }, - ControlMsg::Toggles { - pc_audio_enabled: false, - mic_enabled: true, - }, + ControlMsg::PcState(PcControls::default()), + ControlMsg::PcState(pc), ControlMsg::MicDemand(true), - ControlMsg::MicAllowed(false), + ControlMsg::PhoneState(PhoneControls::default()), + ControlMsg::PhoneState(phone), + ControlMsg::SetPc(PcRequest::Audio(false)), + ControlMsg::SetPc(PcRequest::Mic(true)), + ControlMsg::SetPc(PcRequest::MicDefault(false)), + ControlMsg::SetPc(PcRequest::Volume(0)), + ControlMsg::SetPc(PcRequest::Mute(true)), + ControlMsg::SetPhone(PhoneRequest::Mic(false)), + ControlMsg::SetPhone(PhoneRequest::Volume(100)), ]; for m in msgs { let enc = m.encode(); @@ -390,5 +543,25 @@ mod tests { } assert!(ControlMsg::decode(&[99]).is_err()); assert!(ControlMsg::decode(&[5, 2]).is_err()); + // volume levels above 100 and unknown request kinds are rejected + assert!(ControlMsg::decode(&[7, 3, 101]).is_err()); + assert!(ControlMsg::decode(&[7, 9, 0]).is_err()); + assert!(ControlMsg::decode(&[8, 1, 0xFF]).is_err()); + assert!(ControlMsg::decode(&[6, 1, 1, 101]).is_err()); + // mute without a known volume is not canonical + assert!(ControlMsg::decode(&[4, 1, 1, 0, 0xFF, 1]).is_err()); + assert_eq!(ControlMsg::decode(&[4, 1, 1, 0, 0xFF, 0]).unwrap(), ControlMsg::PcState(PcControls::default())); + } + + #[test] + fn hello_of_another_version_still_decodes_for_a_readable_reject() { + // v1: tag | version | secret | name | mic_allowed + let mut v1 = vec![1, 1, 0]; + v1.extend_from_slice(&[7; 16]); + v1.extend_from_slice(&[2, b'P', b'5', 1]); + let ControlMsg::Hello { version, device_name, phone, .. } = ControlMsg::decode(&v1).unwrap() else { + panic!("not a Hello"); + }; + assert_eq!((version, device_name.as_str(), phone), (1, "P5", PhoneControls::default())); } } diff --git a/crates/core/src/session/hub.rs b/crates/core/src/session/hub.rs index 38cc789..e2010ba 100644 --- a/crates/core/src/session/hub.rs +++ b/crates/core/src/session/hub.rs @@ -9,20 +9,21 @@ use anyhow::{bail, Context, Result}; use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt}; use iroh::Endpoint; use parking_lot::Mutex; -use tokio::sync::watch; +use tokio::sync::{mpsc, watch}; use tokio_util::sync::CancellationToken; use tracing::Instrument; use tokio::task::JoinHandle; use super::{ - clear_connection_status, net, recv_media, stats_loop, ConnState, Dir, HubConfig, HubStatus, - Media, NetOptions, PeerStatus, Status, StatusCell, + clear_connection_status, net, recv_media, spawn_control_reader, stats_loop, ConnState, Dir, HubConfig, + HubStatus, Media, NetOptions, PeerStatus, Status, StatusCell, }; use crate::audio::tx::TxStats; use crate::audio::{CaptureHandle, IncomingStream, OutgoingStream, PlayoutHandle, RxSlot}; use crate::pairing::PairingInfo; use crate::proto::{ - ControlMsg, DatagramHeader, PacketKind, StreamId, ALPN, DATAGRAM_HEADER_LEN, PROTOCOL_VERSION, + ControlMsg, DatagramHeader, PacketKind, PcControls, PcRequest, PhoneControls, PhoneRequest, StreamId, ALPN, + DATAGRAM_HEADER_LEN, PROTOCOL_VERSION, }; /// Maximum number of simultaneously paired PCs. @@ -49,7 +50,10 @@ struct Shared { endpoint: Endpoint, device_name: String, status: Arc>, - mic_allowed: watch::Sender, + /// Phone-side controls, reported to every PC. + phone: watch::Sender, + /// Requests from PCs, for the app to apply. + requests: mpsc::UnboundedSender, /// Incremented by `network_changed()`; every peer task watches it. net_changed: watch::Sender, pc_audio: IncomingStream, @@ -61,11 +65,10 @@ struct Shared { runtime: tokio::runtime::Handle, } -/// PC-side toggles as last reported by the PC. +/// PC-side state as last reported by the PC. #[derive(Clone, Copy, Debug)] struct Remote { - pc_audio: bool, - mic: bool, + pc: PcControls, mic_demand: bool, } @@ -76,6 +79,8 @@ struct Peer { info: Mutex, status: StatusCell, remote: Mutex>, + /// Requests for this PC while connected (written by the session's control loop). + to_pc: Mutex>>, mic_stats: Arc, /// Notified when this PC's audio stream becomes active (starts uplink pacing). audio_started: tokio::sync::Notify, @@ -96,6 +101,7 @@ struct PeerEntry { pub struct Hub { shared: Arc, peers: Mutex>, + requests: Mutex>>, } impl Hub { @@ -106,15 +112,18 @@ impl Hub { pub async fn start_with_options(cfg: HubConfig, opts: NetOptions) -> Result { let key = net::client_identity(&cfg.data_dir)?; let endpoint = net::bind_client(key, &opts).await?; + let phone = PhoneControls { mic: true, mic_ready: true, volume: None }; let status = HubStatus { - mic_enabled: true, + mic_enabled: phone.mic_allowed(), ..HubStatus::default() }; + let (requests_tx, requests) = mpsc::unbounded_channel(); let shared = Arc::new(Shared { endpoint, device_name: cfg.device_name, status: Arc::new(watch::channel(status).0), - mic_allowed: watch::channel(true).0, + phone: watch::channel(phone).0, + requests: requests_tx, net_changed: watch::channel(0).0, pc_audio: IncomingStream::new_mixed(StreamId::PcAudio, SLOTS)?, mic: OutgoingStream::new(StreamId::Mic, MIC_BITRATE, MIC_COMPLEXITY)?, @@ -127,6 +136,7 @@ impl Hub { Ok(Hub { shared, peers: Mutex::new(Vec::new()), + requests: Mutex::new(Some(requests)), }) } @@ -186,6 +196,7 @@ impl Hub { }, info: Mutex::new(info), remote: Mutex::new(None), + to_pc: Mutex::new(None), mic_stats: Arc::new(TxStats::default()), audio_started: tokio::sync::Notify::new(), pace_sent: std::sync::atomic::AtomicU64::new(0), @@ -276,14 +287,16 @@ impl Hub { self.shared.status.subscribe() } - /// Phone-side mic permission/toggle (applies to all PCs). - pub fn set_mic_enabled(&self, on: bool) { - self.shared.mic_allowed.send_if_modified(|v| std::mem::replace(v, on) != on); + /// Phone-side controls (apply to all PCs): the mic is sent only if + /// [`PhoneControls::mic_allowed`]; every PC is told the new values. + pub fn set_phone_controls(&self, phone: PhoneControls) { + self.shared.phone.send_if_modified(|v| std::mem::replace(v, phone) != phone); + let allowed = phone.mic_allowed(); self.shared.status.send_if_modified(|h| { - if h.mic_enabled == on { + if h.mic_enabled == allowed { return false; } - h.mic_enabled = on; + h.mic_enabled = allowed; true }); for e in self.peers.lock().iter() { @@ -291,6 +304,22 @@ impl Hub { } } + /// Sends a remote-control request to the PC `peer_id`; `false` if it is not connected. + pub fn request_pc(&self, peer_id: &str, req: PcRequest) -> bool { + let peers = self.peers.lock(); + let Some(e) = peers.iter().find(|e| e.id == peer_id) else { + return false; + }; + let sent = e.peer.to_pc.lock().as_ref().is_some_and(|tx| tx.send(req).is_ok()); + sent + } + + /// Remote-control requests from PCs. The hub does not apply them; the app does (and + /// reports the result through [`Hub::set_phone_controls`]). `None` after the first call. + pub fn take_requests(&self) -> Option> { + self.requests.lock().take() + } + /// Call on connectivity changes: every PC retries immediately and iroh re-probes paths. pub fn network_changed(&self) { self.shared.net_changed.send_modify(|v| *v = v.wrapping_add(1)); @@ -348,11 +377,11 @@ impl Peer { } fn refresh_toggles(&self) { - let allowed = *self.hub.mic_allowed.borrow(); + let allowed = self.hub.phone.borrow().mic_allowed(); let remote = *self.remote.lock(); - let (pc_audio, mic, demand, connected) = match remote { - Some(r) => (r.pc_audio, allowed && r.mic, r.mic_demand, true), - None => (true, allowed, false, false), + let (pc, mic, demand, connected) = match remote { + Some(r) => (r.pc, allowed && r.pc.mic, r.mic_demand, true), + None => (PcControls::default(), allowed, false, false), }; self.hub .mic @@ -360,9 +389,10 @@ impl Peer { .shared .set_enabled(self.slot, connected && mic && demand); self.status.update(|s| { - s.pc_audio_enabled = pc_audio; + s.pc_audio_enabled = pc.audio; s.mic_enabled = mic; s.mic_demanded = demand; + s.pc = pc; }); } @@ -402,9 +432,10 @@ impl Peer { backoff = BACKOFF_MIN; retry_now = true; } - Some(Ok((conn, send, recv, pc_name))) => { + Some(Ok(session)) => { let started = Instant::now(); - let reason = self.run_session(&conn, send, recv, pc_name).await; + let conn = session.conn.clone(); + let reason = self.run_session(session).await; if self.cancel.is_cancelled() { conn.close(VarInt::from_u32(0), b"shutdown"); return; @@ -432,7 +463,7 @@ impl Peer { } /// Dials the PC and performs the Hello/Welcome handshake. - async fn connect_once(&self) -> Result<(Connection, SendStream, RecvStream, String)> { + async fn connect_once(&self) -> Result { let info = self.info.lock().clone(); let conn = tokio::time::timeout( CONNECT_TIMEOUT, @@ -443,35 +474,33 @@ impl Peer { .context("connect")?; let handshake = async { let (mut send, mut recv) = conn.open_bi().await?; - let mic_allowed = *self.hub.mic_allowed.borrow(); + let phone = *self.hub.phone.borrow(); ControlMsg::Hello { version: PROTOCOL_VERSION, secret: *info.secret(), device_name: self.hub.device_name.clone(), - mic_allowed, + phone, } .write_to(&mut send) .await?; match ControlMsg::read_from(&mut recv).await? { ControlMsg::Welcome { pc_name, - pc_audio_enabled, - mic_enabled, + pc, mic_demanded, } => { *self.remote.lock() = Some(Remote { - pc_audio: pc_audio_enabled, - mic: mic_enabled, + pc, mic_demand: mic_demanded, }); - Ok((send, recv, pc_name)) + Ok((send, recv, pc_name, phone)) } ControlMsg::Reject { reason } => bail!("rejected by PC: {reason}"), other => bail!("unexpected handshake reply {other:?}"), } }; match tokio::time::timeout(HELLO_TIMEOUT, handshake).await { - Ok(Ok((send, recv, pc_name))) => Ok((conn, send, recv, pc_name)), + Ok(Ok((send, recv, pc_name, sent))) => Ok(Session { conn, send, recv, pc_name, sent }), Ok(Err(err)) => { conn.close(VarInt::from_u32(0), b"handshake failed"); Err(err) @@ -484,7 +513,9 @@ impl Peer { } /// Runs one connected session; returns the reason it ended. - async fn run_session(&self, conn: &Connection, send: SendStream, recv: RecvStream, pc_name: String) -> String { + async fn run_session(&self, session: Session) -> String { + let Session { conn, send, recv, pc_name, sent } = session; + let conn = &conn; tracing::info!("connected to '{pc_name}'"); self.rx().feeder.lock().reset(); self.hub @@ -492,6 +523,8 @@ impl Peer { .tx .shared .set_target(self.slot, Some(conn.clone()), &self.mic_stats); + let (to_pc, to_pc_rx) = mpsc::unbounded_channel(); + *self.to_pc.lock() = Some(to_pc); self.status.update(|s| { clear_connection_status(s); s.state = ConnState::Connected; @@ -510,7 +543,7 @@ impl Peer { let reason = tokio::select! { _ = self.cancel.cancelled() => "removed".to_owned(), e = conn.closed() => format!("connection closed: {e}"), - r = self.control_loop(send, recv) => match r { + r = self.control_loop(send, recv, sent, to_pc_rx) => match r { Ok(()) => "control stream closed".to_owned(), Err(err) => format!("control stream: {err:#}"), }, @@ -518,6 +551,7 @@ impl Peer { _ = stats_loop(conn, &media, &self.status) => "stats ended".to_owned(), _ = self.pace_loop(conn) => "pacing ended".to_owned(), }; + *self.to_pc.lock() = None; self.hub.mic.tx.shared.set_target(self.slot, None, &self.mic_stats); *self.remote.lock() = None; conn.close(VarInt::from_u32(0), b"bye"); @@ -564,34 +598,47 @@ impl Peer { } } - async fn control_loop(&self, mut send: SendStream, mut recv: RecvStream) -> Result<()> { - let mut allowed_rx = self.hub.mic_allowed.subscribe(); - let mut sent = *allowed_rx.borrow_and_update(); - // the value may have changed between Hello and now - ControlMsg::MicAllowed(sent).write_to(&mut send).await?; + async fn control_loop( + &self, + mut send: SendStream, + recv: RecvStream, + mut sent: PhoneControls, + mut to_pc: mpsc::UnboundedReceiver, + ) -> Result<()> { + let mut incoming = spawn_control_reader(recv); + let mut phone_rx = self.hub.phone.subscribe(); + // the controls may have changed between Hello and now + phone_rx.mark_changed(); loop { tokio::select! { - msg = ControlMsg::read_from(&mut recv) => { - let msg = msg?; - { - let mut remote = self.remote.lock(); - let r = remote.as_mut().context("not connected")?; - match msg { - ControlMsg::Toggles { pc_audio_enabled, mic_enabled } => { - r.pc_audio = pc_audio_enabled; - r.mic = mic_enabled; + msg = incoming.recv() => { + let Some(msg) = msg else { return Ok(()) }; + match msg { + ControlMsg::SetPhone(req) => { + tracing::info!("PC request: {req:?}"); + let _ = self.hub.requests.send(req); + continue; + } + msg => { + let mut remote = self.remote.lock(); + let r = remote.as_mut().context("not connected")?; + match msg { + ControlMsg::PcState(pc) => r.pc = pc, + ControlMsg::MicDemand(on) => r.mic_demand = on, + other => tracing::debug!("unexpected control message {other:?}"), } - ControlMsg::MicDemand(on) => r.mic_demand = on, - other => tracing::debug!("unexpected control message {other:?}"), } } self.refresh_toggles(); } - r = allowed_rx.changed() => { + Some(req) = to_pc.recv() => { + ControlMsg::SetPc(req).write_to(&mut send).await?; + } + r = phone_rx.changed() => { r?; - let now = *allowed_rx.borrow_and_update(); + let now = *phone_rx.borrow_and_update(); if now != sent { - ControlMsg::MicAllowed(now).write_to(&mut send).await?; + ControlMsg::PhoneState(now).write_to(&mut send).await?; sent = now; } } @@ -600,6 +647,16 @@ impl Peer { } } +/// A handshaken connection to a PC. +struct Session { + conn: Connection, + send: SendStream, + recv: RecvStream, + pc_name: String, + /// Phone controls carried by the `Hello`. + sent: PhoneControls, +} + #[cfg(test)] mod tests { use std::sync::atomic::{AtomicBool, Ordering}; diff --git a/crates/core/src/session/mod.rs b/crates/core/src/session/mod.rs index 059a826..475e06c 100644 --- a/crates/core/src/session/mod.rs +++ b/crates/core/src/session/mod.rs @@ -9,14 +9,15 @@ use std::sync::atomic::Ordering; use std::sync::Arc; use std::time::{Duration, Instant}; -use iroh::endpoint::Connection; +use iroh::endpoint::{Connection, RecvStream}; use iroh::TransportAddr; -use tokio::sync::watch; +use tokio::sync::{mpsc, watch}; use crate::audio::tx::TxStats; use crate::audio::RxSlot; -use crate::proto::{DatagramHeader, PacketKind, StreamId}; +use crate::proto::{ControlMsg, DatagramHeader, PacketKind, StreamId}; +pub use crate::proto::{PcControls, PcRequest, PhoneControls, PhoneRequest, Volume}; pub use hub::{Hub, MAX_PEERS}; pub use server::Server; @@ -65,6 +66,11 @@ pub struct Status { pub mic_demanded: bool, pub pc_audio: StreamStats, pub mic: StreamStats, + /// PC side: its own controls. Phone side: the controls this PC last reported. + pub pc: PcControls, + /// PC side: the connected phone's last reported controls (`None` without a phone). + /// Always `None` on the phone side. + pub phone: Option, pub last_error: Option, } @@ -80,6 +86,8 @@ impl Status { mic_demanded: false, pc_audio: StreamStats::default(), mic: StreamStats::default(), + pc: PcControls::default(), + phone: None, last_error: None, } } @@ -403,6 +411,33 @@ fn clear_connection_status(s: &mut Status) { } } +/// Capacity of the decoded control-message queue of one connection. +const CONTROL_QUEUE: usize = 32; + +/// Reads framed control messages into a channel until the stream ends or fails (the channel +/// then closes). Control loops poll the channel instead of `read_from` directly, so a +/// `select!` branch firing mid-frame never drops half a message. The task ends by itself +/// when the connection is closed. +fn spawn_control_reader(mut recv: RecvStream) -> mpsc::Receiver { + let (tx, rx) = mpsc::channel(CONTROL_QUEUE); + tokio::spawn(async move { + loop { + match ControlMsg::read_from(&mut recv).await { + Ok(msg) => { + if tx.send(msg).await.is_err() { + return; + } + } + Err(err) => { + tracing::debug!("control stream ended: {err:#}"); + return; + } + } + } + }); + rx +} + /// Constant-time equality for the pairing secret. fn secret_eq(a: &[u8; 16], b: &[u8; 16]) -> bool { a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 diff --git a/crates/core/src/session/server.rs b/crates/core/src/session/server.rs index df2bf62..5b78122 100644 --- a/crates/core/src/session/server.rs +++ b/crates/core/src/session/server.rs @@ -1,6 +1,6 @@ //! PC side: accepts one active phone, streams PC audio, receives the phone mic. -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -8,18 +8,20 @@ use anyhow::{bail, Result}; use parking_lot::Mutex; use iroh::endpoint::{Connection, RecvStream, SendStream, VarInt}; use iroh::{Endpoint, Watcher}; -use tokio::sync::watch; +use tokio::sync::{mpsc, watch}; use tokio::task::JoinHandle; use tokio_util::sync::CancellationToken; use super::{ - clear_connection_status, net, recv_media, secret_eq, stats_loop, ConnState, Dir, Media, - NetOptions, ServerConfig, Status, StatusCell, + clear_connection_status, net, recv_media, secret_eq, spawn_control_reader, stats_loop, ConnState, Dir, + Media, NetOptions, ServerConfig, Status, StatusCell, }; use crate::audio::tx::TxStats; use crate::audio::{CaptureHandle, IncomingStream, OutgoingStream, PlayoutHandle}; use crate::pairing::PairingInfo; -use crate::proto::{ControlMsg, StreamId, PROTOCOL_VERSION}; +use crate::proto::{ + ControlMsg, PcControls, PcRequest, PhoneControls, PhoneRequest, StreamId, Volume, PROTOCOL_VERSION, +}; const PC_AUDIO_BITRATE: i32 = 192_000; const PC_AUDIO_COMPLEXITY: i32 = 9; @@ -33,8 +35,7 @@ const PHONE: usize = 0; #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct Toggles { - pc_audio: bool, - mic: bool, + pc: PcControls, mic_demand: bool, } @@ -42,6 +43,8 @@ struct ActiveConn { id: u64, conn: Connection, cancel: CancellationToken, + /// Requests for this phone, written by its control loop. + to_phone: mpsc::UnboundedSender, } pub(super) struct Inner { @@ -51,8 +54,10 @@ pub(super) struct Inner { status_tx: Arc>, status: StatusCell, toggles: watch::Sender, - /// Phone-side mic permission of the connected phone. - phone_mic_allowed: AtomicBool, + /// Controls of the connected phone (`None` without a phone). + phone: Mutex>, + /// Requests from the phone, for the app to apply. + requests: mpsc::UnboundedSender, pc_audio: OutgoingStream, pc_audio_stats: Arc, pub(super) mic: IncomingStream, @@ -65,6 +70,7 @@ pub(super) struct Inner { pub struct Server { pub(super) inner: Arc, pairing: watch::Receiver>, + requests: Mutex>>, tasks: Vec>, } @@ -79,10 +85,10 @@ impl Server { let status_tx = Arc::new(watch::channel(Status::new()).0); let status = StatusCell::Single(status_tx.clone()); let toggles = Toggles { - pc_audio: true, - mic: true, + pc: PcControls::default(), mic_demand: false, }; + let (requests_tx, requests) = mpsc::unbounded_channel(); let inner = Arc::new(Inner { endpoint, secret, @@ -90,7 +96,8 @@ impl Server { status_tx, status, toggles: watch::channel(toggles).0, - phone_mic_allowed: AtomicBool::new(true), + phone: Mutex::new(None), + requests: requests_tx, pc_audio: OutgoingStream::new(StreamId::PcAudio, PC_AUDIO_BITRATE, PC_AUDIO_COMPLEXITY)?, pc_audio_stats: Arc::new(TxStats::default()), mic: IncomingStream::new(StreamId::Mic)?, @@ -114,6 +121,7 @@ impl Server { Ok(Server { inner, pairing, + requests: Mutex::new(Some(requests)), tasks, }) } @@ -128,21 +136,40 @@ impl Server { } pub fn set_pc_audio_enabled(&self, on: bool) { - self.inner.toggles.send_if_modified(|t| std::mem::replace(&mut t.pc_audio, on) != on); - self.inner.refresh_status_toggles(); + self.inner.set_toggles(|t| t.pc.audio = on); } pub fn set_mic_enabled(&self, on: bool) { - self.inner.toggles.send_if_modified(|t| std::mem::replace(&mut t.mic, on) != on); - self.inner.refresh_status_toggles(); + self.inner.set_toggles(|t| t.pc.mic = on); } /// Whether a PC application is capturing the virtual microphone. pub fn set_mic_demand(&self, demanded: bool) { - self.inner - .toggles - .send_if_modified(|t| std::mem::replace(&mut t.mic_demand, demanded) != demanded); - self.inner.refresh_status_toggles(); + self.inner.set_toggles(|t| t.mic_demand = demanded); + } + + /// Whether the virtual mic is the default recording device (reported to the phone). + pub fn set_mic_default(&self, on: bool) { + self.inner.set_toggles(|t| t.pc.mic_default = on); + } + + /// Default playback device volume (reported to the phone); `None` while unknown. + pub fn set_volume(&self, volume: Option) { + self.inner.set_toggles(|t| t.pc.volume = volume); + } + + /// Remote-control requests from the phone. The server does not apply them; the app does + /// (and reports the result through the setters above). Returns `None` after the first call. + pub fn take_requests(&self) -> Option> { + self.requests.lock().take() + } + + /// Sends a remote-control request to the connected phone; `false` without a phone. + pub fn request_phone(&self, req: PhoneRequest) -> bool { + match self.inner.active.lock().as_ref() { + Some(a) => a.to_phone.send(req).is_ok(), + None => false, + } } /// Stereo PC-audio capture handle (can be taken again after the previous one is dropped). @@ -223,15 +250,27 @@ impl Inner { } } + fn set_toggles(&self, f: impl FnOnce(&mut Toggles)) { + self.toggles.send_if_modified(|t| { + let before = *t; + f(t); + *t != before + }); + self.refresh_status_toggles(); + } + fn refresh_status_toggles(&self) { let t = *self.toggles.borrow(); let connected = self.active.lock().is_some(); - let phone_ok = !connected || self.phone_mic_allowed.load(Ordering::Acquire); - self.pc_audio.tx.shared.set_enabled(PHONE, connected && t.pc_audio); + let phone = if connected { *self.phone.lock() } else { None }; + let phone_ok = phone.is_none_or(|p| p.mic_allowed()); + self.pc_audio.tx.shared.set_enabled(PHONE, connected && t.pc.audio); self.status.update(|s| { - s.pc_audio_enabled = t.pc_audio; - s.mic_enabled = t.mic && phone_ok; + s.pc_audio_enabled = t.pc.audio; + s.mic_enabled = t.pc.mic && phone_ok; s.mic_demanded = t.mic_demand; + s.pc = t.pc; + s.phone = phone; }); } @@ -260,9 +299,7 @@ impl Inner { } }; match tokio::time::timeout(HANDSHAKE_TIMEOUT, this.authenticate(&conn)).await { - Ok(Ok(Some((name, mic_allowed, send, recv)))) => { - this.install(conn, name, mic_allowed, send, recv) - } + Ok(Ok(Some(hs))) => this.install(conn, hs), Ok(Ok(None)) => {} Ok(Err(err)) => tracing::info!("phone handshake failed: {err:#}"), Err(_) => tracing::info!("phone handshake timed out"), @@ -272,22 +309,21 @@ impl Inner { } /// Reads `Hello`; answers `Welcome` or `Reject`. `None` means rejected. - async fn authenticate( - &self, - conn: &Connection, - ) -> Result> { + async fn authenticate(&self, conn: &Connection) -> Result> { let (mut send, mut recv) = conn.accept_bi().await?; let ControlMsg::Hello { version, secret, device_name, - mic_allowed, + phone, } = ControlMsg::read_from(&mut recv).await? else { bail!("expected Hello"); }; let reason = if version != PROTOCOL_VERSION { - Some(format!("unsupported protocol version {version}")) + Some(format!( + "unsupported protocol version {version} (PC: {PROTOCOL_VERSION}); update AudioBridge on both devices" + )) } else if !secret_eq(&secret, &self.secret) { Some("invalid pairing secret".to_owned()) } else { @@ -302,26 +338,26 @@ impl Inner { conn.close(VarInt::from_u32(CLOSE_REJECTED), b"rejected"); return Ok(None); } - let t = *self.toggles.borrow(); + let sent = *self.toggles.borrow(); ControlMsg::Welcome { pc_name: self.pc_name.clone(), - pc_audio_enabled: t.pc_audio, - mic_enabled: t.mic, - mic_demanded: t.mic_demand, + pc: sent.pc, + mic_demanded: sent.mic_demand, } .write_to(&mut send) .await?; - Ok(Some((device_name, mic_allowed, send, recv))) + Ok(Some(Handshake { name: device_name, phone, sent, send, recv })) } /// Makes `conn` the active phone connection, replacing any previous one. - fn install(self: &Arc, conn: Connection, name: String, mic_allowed: bool, send: SendStream, recv: RecvStream) { + fn install(self: &Arc, conn: Connection, hs: Handshake) { if self.cancel.is_cancelled() { conn.close(VarInt::from_u32(CLOSE_SHUTDOWN), b"shutdown"); return; } let id = self.next_id.fetch_add(1, Ordering::Relaxed); let cancel = self.cancel.child_token(); + let (to_phone, to_phone_rx) = mpsc::unbounded_channel(); { let mut active = self.active.lock(); if let Some(old) = active.take() { @@ -333,10 +369,11 @@ impl Inner { id, conn: conn.clone(), cancel: cancel.clone(), + to_phone, }); + *self.phone.lock() = Some(hs.phone); } - tracing::info!("phone '{name}' connected ({})", conn.remote_id()); - self.phone_mic_allowed.store(mic_allowed, Ordering::Release); + tracing::info!("phone '{}' connected ({}): {:?}", hs.name, conn.remote_id(), hs.phone); self.mic.slot(0).feeder.lock().reset(); self.pc_audio .tx @@ -345,17 +382,19 @@ impl Inner { self.status.update(|s| { clear_connection_status(s); s.state = ConnState::Connected; - s.peer_name = Some(name); + s.peer_name = Some(hs.name); s.last_error = None; }); self.refresh_status_toggles(); let this = self.clone(); + let session = Session { id, sent: hs.sent, send: hs.send, recv: hs.recv, to_phone: to_phone_rx }; tokio::spawn(async move { - this.run_connection(&conn, send, recv, &cancel).await; + this.run_connection(&conn, session, &cancel).await; let still_active = { let mut active = this.active.lock(); if active.as_ref().is_some_and(|a| a.id == id) { *active = None; + *this.phone.lock() = None; true } else { false @@ -377,7 +416,7 @@ impl Inner { }); } - async fn run_connection(&self, conn: &Connection, send: SendStream, recv: RecvStream, cancel: &CancellationToken) { + async fn run_connection(&self, conn: &Connection, session: Session, cancel: &CancellationToken) { let media = Media { tx: &self.pc_audio_stats, out_dir: Dir::PcAudio, @@ -389,7 +428,7 @@ impl Inner { tokio::select! { _ = cancel.cancelled() => {} _ = conn.closed() => {} - r = self.control_loop(send, recv) => { + r = self.control_loop(session) => { if let Err(err) = r { tracing::debug!("control stream ended: {err:#}"); } @@ -399,25 +438,40 @@ impl Inner { } } - async fn control_loop(&self, mut send: SendStream, mut recv: RecvStream) -> Result<()> { + async fn control_loop(&self, session: Session) -> Result<()> { + let Session { id, mut sent, mut send, recv, mut to_phone } = session; + let mut incoming = spawn_control_reader(recv); let mut toggles_rx = self.toggles.subscribe(); - let mut sent = *toggles_rx.borrow_and_update(); + // the toggles may have changed between Welcome and now + toggles_rx.mark_changed(); loop { tokio::select! { - msg = ControlMsg::read_from(&mut recv) => match msg? { - ControlMsg::MicAllowed(on) => { - self.phone_mic_allowed.store(on, Ordering::Release); + msg = incoming.recv() => match msg { + None => return Ok(()), + Some(ControlMsg::PhoneState(phone)) => { + { + let active = self.active.lock(); + if active.as_ref().is_some_and(|a| a.id == id) { + *self.phone.lock() = Some(phone); + } + } + tracing::debug!("phone controls: {phone:?}"); self.refresh_status_toggles(); } - other => tracing::debug!("unexpected control message {other:?}"), + Some(ControlMsg::SetPc(req)) => { + tracing::info!("phone request: {req:?}"); + let _ = self.requests.send(req); + } + Some(other) => tracing::debug!("unexpected control message {other:?}"), }, + Some(req) = to_phone.recv() => { + ControlMsg::SetPhone(req).write_to(&mut send).await?; + } r = toggles_rx.changed() => { r?; let t = *toggles_rx.borrow_and_update(); - if (t.pc_audio, t.mic) != (sent.pc_audio, sent.mic) { - ControlMsg::Toggles { pc_audio_enabled: t.pc_audio, mic_enabled: t.mic } - .write_to(&mut send) - .await?; + if t.pc != sent.pc { + ControlMsg::PcState(t.pc).write_to(&mut send).await?; } if t.mic_demand != sent.mic_demand { ControlMsg::MicDemand(t.mic_demand).write_to(&mut send).await?; @@ -428,3 +482,22 @@ impl Inner { } } } + +/// Result of an accepted `Hello`. +struct Handshake { + name: String, + phone: PhoneControls, + /// Toggles carried by the `Welcome`. + sent: Toggles, + send: SendStream, + recv: RecvStream, +} + +/// What the control loop of one connection owns. +struct Session { + id: u64, + sent: Toggles, + send: SendStream, + recv: RecvStream, + to_phone: mpsc::UnboundedReceiver, +} diff --git a/crates/core/tests/integration.rs b/crates/core/tests/integration.rs index fbab080..c36190c 100644 --- a/crates/core/tests/integration.rs +++ b/crates/core/tests/integration.rs @@ -5,7 +5,8 @@ use std::time::{Duration, Instant}; use audiobridge_core::audio::{CaptureHandle, PlayoutHandle}; use audiobridge_core::pairing::PairingInfo; use audiobridge_core::session::{ - ConnState, Hub, HubConfig, HubStatus, NetOptions, PathKind, Server, ServerConfig, Status, + ConnState, Hub, HubConfig, HubStatus, NetOptions, PathKind, PcRequest, PhoneControls, PhoneRequest, Server, + ServerConfig, Status, Volume, }; use tokio::sync::watch; @@ -611,6 +612,82 @@ async fn wrong_secret_is_rejected() { server.shutdown().await; } +async fn next(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> T { + tokio::time::timeout(Duration::from_secs(5), rx.recv()) + .await + .expect("request timed out") + .expect("request channel closed") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn remote_control_in_both_directions() { + init_logs(); + let sdir = tempfile::tempdir().unwrap(); + let cdir = tempfile::tempdir().unwrap(); + let server = start_server(sdir.path(), "TEST-PC", NetOptions::local_only()).await; + let mut pc_requests = server.take_requests().expect("requests"); + assert!(server.take_requests().is_none(), "requests are handed out once"); + let pairing = pairing_of(&server); + let id = pairing.peer_id(); + + // state reported before the phone connects arrives in the handshake + server.set_volume(Some(Volume { level: 30, muted: false })); + let hub = Hub::start_with_options( + HubConfig { data_dir: cdir.path().to_path_buf(), device_name: "Test Phone".into() }, + NetOptions::local_only(), + ) + .await + .unwrap(); + let mut phone_requests = hub.take_requests().expect("requests"); + let mut phone = PhoneControls { mic: false, mic_ready: true, volume: Some(50) }; + hub.set_phone_controls(phone); + assert!(!hub.request_pc(&id, PcRequest::Audio(false)), "not connected yet"); + assert!(!server.request_phone(PhoneRequest::Mic(true)), "no phone yet"); + hub.set_peers(vec![pairing]); + + let mut hs = hub.status(); + let mut ss = server.status(); + wait_for(&mut hs, Duration::from_secs(10), "connected", |h| { + peer(h, &id).is_some_and(|s| s.state == ConnState::Connected && s.pc.volume.is_some_and(|v| v.level == 30)) + }) + .await; + let s = wait_for(&mut ss, Duration::from_secs(5), "phone controls", |s| s.phone == Some(phone)).await; + assert!(!s.mic_enabled, "phone mic is off, so the effective mic is off"); + + // PC -> phone: request reaches the hub; the app applies it and the result flows back + assert!(server.request_phone(PhoneRequest::Mic(true))); + assert!(server.request_phone(PhoneRequest::Volume(80))); + assert_eq!(next(&mut phone_requests).await, PhoneRequest::Mic(true)); + assert_eq!(next(&mut phone_requests).await, PhoneRequest::Volume(80)); + phone = PhoneControls { mic: true, mic_ready: true, volume: Some(80) }; + hub.set_phone_controls(phone); + let s = wait_for(&mut ss, Duration::from_secs(5), "phone mic on", |s| s.phone == Some(phone)).await; + assert!(s.mic_enabled); + + // phone -> PC: requests arrive in order; applied state flows back to the phone + for req in [PcRequest::Audio(false), PcRequest::Mic(true), PcRequest::Volume(55), PcRequest::Mute(true)] { + assert!(hub.request_pc(&id, req)); + } + for want in [PcRequest::Audio(false), PcRequest::Mic(true), PcRequest::Volume(55), PcRequest::Mute(true)] { + assert_eq!(next(&mut pc_requests).await, want); + } + server.set_pc_audio_enabled(false); + server.set_mic_default(true); + server.set_volume(Some(Volume { level: 55, muted: true })); + let h = wait_for(&mut hs, Duration::from_secs(5), "PC controls", |h| { + peer(h, &id).is_some_and(|s| { + !s.pc.audio && s.pc.mic_default && s.pc.volume == Some(Volume { level: 55, muted: true }) + }) + }) + .await; + assert!(!peer(&h, &id).unwrap().pc_audio_enabled); + assert_eq!(peer(&h, &id).unwrap().phone, None, "phone side never fills `phone`"); + + hub.shutdown().await; + wait_for(&mut ss, Duration::from_secs(10), "phone gone", |s| s.phone.is_none()).await; + server.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn reconnects_after_server_restart() { init_logs(); diff --git a/crates/desktop/Cargo.toml b/crates/desktop/Cargo.toml index 64c70ae..922e35b 100644 --- a/crates/desktop/Cargo.toml +++ b/crates/desktop/Cargo.toml @@ -30,6 +30,7 @@ features = [ "Win32_Graphics_Gdi", "Win32_Graphics_Dwm", "Win32_Media_Audio", + "Win32_Media_Audio_Endpoints", "Win32_Media_KernelStreaming", "Win32_Media_Multimedia", "Win32_Security", diff --git a/crates/desktop/src/audio/devices.rs b/crates/desktop/src/audio/devices.rs index 0b1a0ad..4c9690d 100644 --- a/crates/desktop/src/audio/devices.rs +++ b/crates/desktop/src/audio/devices.rs @@ -14,9 +14,9 @@ pub struct Endpoint { pub device: IMMDevice, } -/// An active playback endpoint, as listed for the "restore default device" fix. +/// An active endpoint (playback or recording), as listed for the default-device fixes. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct RenderInfo { +pub struct EndpointInfo { pub id: String, pub name: String, pub is_cable: bool, @@ -30,19 +30,35 @@ pub struct DeviceSummary { /// The default playback device is a VB-CABLE input (loopback would feed the mic back). pub default_is_cable: bool, pub cable_render_id: Option, - pub cable_capture_present: bool, - pub render_endpoints: Vec, + pub render_endpoints: Vec, + pub default_capture_id: Option, + pub default_capture_name: Option, + /// The default recording device is the VB-CABLE capture endpoint, i.e. our virtual mic. + pub default_capture_is_cable: bool, + /// The capture endpoint apps record the phone mic from ("CABLE Output"). + pub cable_capture_id: Option, + pub capture_endpoints: Vec, } impl DeviceSummary { /// The playback device to restore when VB-CABLE took over the default: the remembered one /// if it is still active, otherwise the first active non-CABLE device. - pub fn restore_candidate(&self, remembered: Option<&str>) -> Option<&RenderInfo> { - let usable = |r: &&RenderInfo| !r.is_cable; - remembered - .and_then(|id| self.render_endpoints.iter().filter(usable).find(|r| r.id == id)) - .or_else(|| self.render_endpoints.iter().find(usable)) + pub fn render_restore_candidate(&self, remembered: Option<&str>) -> Option<&EndpointInfo> { + restore_candidate(&self.render_endpoints, remembered) } + + /// The recording device to restore when the virtual mic stops being the default: the + /// remembered one if it is still active, otherwise the first active non-CABLE device. + pub fn capture_restore_candidate(&self, remembered: Option<&str>) -> Option<&EndpointInfo> { + restore_candidate(&self.capture_endpoints, remembered) + } +} + +fn restore_candidate<'a>(endpoints: &'a [EndpointInfo], remembered: Option<&str>) -> Option<&'a EndpointInfo> { + let usable = |r: &&EndpointInfo| !r.is_cable; + remembered + .and_then(|id| endpoints.iter().filter(usable).find(|r| r.id == id)) + .or_else(|| endpoints.iter().find(usable)) } fn take_pwstr(p: PWSTR) -> String { @@ -84,6 +100,11 @@ pub fn default_render(e: &IMMDeviceEnumerator) -> Option { unsafe { e.GetDefaultAudioEndpoint(eRender, eConsole).ok().and_then(endpoint) } } +fn default_capture(e: &IMMDeviceEnumerator) -> Option { + // SAFETY: COM call on a valid enumerator. + unsafe { e.GetDefaultAudioEndpoint(eCapture, eConsole).ok().and_then(endpoint) } +} + fn contains(hay: &str, needle: &str) -> bool { hay.to_lowercase().contains(&needle.to_lowercase()) } @@ -94,6 +115,12 @@ pub fn is_cable_render_name(name: &str) -> bool { contains(name, "VB-Audio Virtual Cable") || contains(name, "CABLE In") } +/// Any VB-CABLE recording endpoint ("CABLE Output (VB-Audio Virtual Cable)"): the virtual mic, +/// never a device to restore as the user's own microphone. +fn is_cable_capture_name(name: &str) -> bool { + contains(name, "CABLE Output") || contains(name, "VB-Audio Virtual Cable") +} + fn pick_cable(endpoints: Vec, preferred: &str) -> Option { let mut fallback = None; for ep in endpoints { @@ -117,18 +144,31 @@ pub fn cable_capture(e: &IMMDeviceEnumerator) -> Option { pick_cable(list(e, eCapture), "CABLE Output") } +fn infos(endpoints: &[Endpoint], is_cable: fn(&str) -> bool) -> Vec { + endpoints + .iter() + .map(|ep| EndpointInfo { id: ep.id.clone(), name: ep.name.clone(), is_cable: is_cable(&ep.name) }) + .collect() +} + pub fn summarize(e: &IMMDeviceEnumerator) -> DeviceSummary { let def = default_render(e); let renders = list(e, eRender); + let def_capture = default_capture(e); + let captures = list(e, eCapture); + let render_endpoints = infos(&renders, is_cable_render_name); + let capture_endpoints = infos(&captures, is_cable_capture_name); + let cable_capture_id = pick_cable(captures, "CABLE Output").map(|c| c.id); DeviceSummary { default_is_cable: def.as_ref().is_some_and(|d| is_cable_render_name(&d.name)), default_render_id: def.as_ref().map(|d| d.id.clone()), default_render_name: def.map(|d| d.name), - render_endpoints: renders - .iter() - .map(|r| RenderInfo { id: r.id.clone(), name: r.name.clone(), is_cable: is_cable_render_name(&r.name) }) - .collect(), + render_endpoints, cable_render_id: pick_cable(renders, "CABLE Input").map(|c| c.id), - cable_capture_present: cable_capture(e).is_some(), + default_capture_is_cable: def_capture.as_ref().is_some_and(|d| cable_capture_id.as_ref() == Some(&d.id)), + default_capture_id: def_capture.as_ref().map(|d| d.id.clone()), + default_capture_name: def_capture.map(|d| d.name), + cable_capture_id, + capture_endpoints, } } diff --git a/crates/desktop/src/audio/mod.rs b/crates/desktop/src/audio/mod.rs index ee81241..14d45a6 100644 --- a/crates/desktop/src/audio/mod.rs +++ b/crates/desktop/src/audio/mod.rs @@ -1,5 +1,6 @@ //! Audio engine: a supervisor thread that opens/closes WASAPI streams from session status -//! and device notifications. It blocks with no timeout while no phone is connected. +//! and device notifications, and follows the default playback device's volume. It blocks with +//! no timeout while no phone is connected; every event arrives as a channel message. pub(crate) mod com; mod demand; @@ -8,6 +9,7 @@ mod format; mod loopback; mod policy; mod render; +mod volume; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; use std::sync::Arc; @@ -16,16 +18,18 @@ use std::time::{Duration, Instant}; use anyhow::Result; use audiobridge_core::audio::{CaptureHandle, PlayoutHandle}; +use audiobridge_core::session::Volume; use windows::core::PCWSTR; use windows::Win32::Foundation::PROPERTYKEY; use windows::Win32::Media::Audio::{ - eConsole, eRender, EDataFlow, ERole, IMMNotificationClient, IMMNotificationClient_Impl, DEVICE_STATE, + eCapture, eConsole, eRender, EDataFlow, ERole, IMMNotificationClient, IMMNotificationClient_Impl, DEVICE_STATE, }; use self::com::{Com, Event}; use self::demand::DemandProbe; pub use self::devices::DeviceSummary; -pub use self::policy::set_default_render; +pub use self::policy::set_default_endpoint; +use self::volume::VolumeControl; /// What the session wants from the audio side, derived from `Status`. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -38,12 +42,20 @@ pub struct Desired { pub enum AudioMsg { Desired(Desired), DevicesChanged, + /// The default playback device's volume or mute changed (from any source). + VolumeChanged, + /// Set the default playback device's volume, percent. + SetVolume(u8), + /// Mute/unmute the default playback device. + SetMute(bool), Shutdown, } pub struct Hooks { pub on_devices: Box, pub on_demand: Box, + /// Default playback device volume, called on change only (`None` without a default device). + pub on_volume: Box) + Send>, } const TICK: Duration = Duration::from_secs(1); @@ -93,7 +105,8 @@ impl IMMNotificationClient_Impl for Notifier_Impl { Ok(()) } fn OnDefaultDeviceChanged(&self, flow: EDataFlow, role: ERole, _id: &PCWSTR) -> windows::core::Result<()> { - if flow == eRender && role == eConsole { + // Playback: loopback source and volume; recording: whether the virtual mic is the default. + if (flow == eRender || flow == eCapture) && role == eConsole { let _ = self.tx.send(AudioMsg::DevicesChanged); } Ok(()) @@ -218,6 +231,7 @@ fn supervise( return; } }; + let volume_tx = notifier_tx.clone(); let notifier: IMMNotificationClient = Notifier { tx: notifier_tx }.into(); // SAFETY: COM call on a valid enumerator; unregistered below before drop. if let Err(e) = unsafe { enumerator.RegisterEndpointNotificationCallback(¬ifier) } { @@ -227,6 +241,9 @@ fn supervise( let mut devices = devices::summarize(&enumerator); (hooks.on_devices)(&devices); tracing::info!("audio devices: {devices:?}"); + let mut volume = VolumeControl::new(volume_tx); + volume.follow(&enumerator, devices.default_render_id.as_ref()); + report_volume(&mut volume, &hooks); let mut desired = Desired::default(); let mut probe = DemandProbe::default(); @@ -242,6 +259,9 @@ fn supervise( rx.recv().map_err(|_| RecvTimeoutError::Disconnected) }; let mut devices_dirty = false; + let mut volume_dirty = false; + let mut set_level = None; + let mut set_mute = None; let mut msgs = match first { Ok(m) => vec![m], Err(RecvTimeoutError::Timeout) => Vec::new(), @@ -252,6 +272,10 @@ fn supervise( match m { AudioMsg::Desired(d) => desired = d, AudioMsg::DevicesChanged => devices_dirty = true, + AudioMsg::VolumeChanged => volume_dirty = true, + // Only the latest request matters (a dragged slider sends many). + AudioMsg::SetVolume(level) => set_level = Some(level), + AudioMsg::SetMute(m) => set_mute = Some(m), AudioMsg::Shutdown => break 'outer, } } @@ -263,6 +287,19 @@ fn supervise( (hooks.on_devices)(&devices); probe.reset(); } + volume.follow(&enumerator, devices.default_render_id.as_ref()); + volume_dirty = true; + } + if let Some(level) = set_level { + volume.set_level(level); + volume_dirty = true; + } + if let Some(m) = set_mute { + volume.set_mute(m); + volume_dirty = true; + } + if volume_dirty { + report_volume(&mut volume, &hooks); } let want_pc = desired.connected && desired.pc_audio @@ -286,8 +323,18 @@ fn supervise( } pc.stop(); mic.stop(); + // Unregisters the volume callback while COM is still initialised. + drop(volume); // SAFETY: matches the registration above. unsafe { let _ = enumerator.UnregisterEndpointNotificationCallback(¬ifier); } } + +/// Hands the default playback device volume to the hook if it changed since the last report. +fn report_volume(volume: &mut VolumeControl, hooks: &Hooks) { + if let Some(v) = volume.take_change() { + tracing::debug!("playback volume: {v:?}"); + (hooks.on_volume)(v); + } +} diff --git a/crates/desktop/src/audio/policy.rs b/crates/desktop/src/audio/policy.rs index f449257..e956890 100644 --- a/crates/desktop/src/audio/policy.rs +++ b/crates/desktop/src/audio/policy.rs @@ -1,5 +1,5 @@ -//! Setting the default playback device. Windows has no public API for this; every tool -//! (including the Sound control panel) uses the undocumented but long-stable `IPolicyConfig` +//! Setting the default playback/recording device. Windows has no public API for this; every +//! tool (including the Sound control panel) uses the undocumented but long-stable `IPolicyConfig` //! (CLSID_PolicyConfigClient, Windows 7+ layout). // Vtable methods keep their COM names. #![allow(non_snake_case)] @@ -31,8 +31,9 @@ unsafe trait IPolicyConfig: windows_core::IUnknown { fn SetEndpointVisibility(&self, id: PCWSTR, visible: i32) -> HRESULT; } -/// Makes `endpoint_id` the default playback device for all roles (console, multimedia, communications). -pub fn set_default_render(endpoint_id: &str) -> Result<()> { +/// Makes `endpoint_id` the default device of its direction (playback or recording) for all roles +/// (console, multimedia, communications). +pub fn set_default_endpoint(endpoint_id: &str) -> Result<()> { let _com = Com::init(); let id = HSTRING::from(endpoint_id); // SAFETY: COM is initialised on this thread; `id` outlives the calls. diff --git a/crates/desktop/src/audio/volume.rs b/crates/desktop/src/audio/volume.rs new file mode 100644 index 0000000..716d3ff --- /dev/null +++ b/crates/desktop/src/audio/volume.rs @@ -0,0 +1,140 @@ +//! Volume of the default playback device: read and set through `IAudioEndpointVolume`. Windows +//! pushes every change (ours, the volume flyout, media keys) to `IAudioEndpointVolumeCallback`, +//! which only wakes the supervisor; nothing is polled. + +use std::sync::mpsc::Sender; + +use audiobridge_core::proto::MAX_LEVEL; +use audiobridge_core::session::Volume; +use windows::core::HSTRING; +use windows::Win32::Media::Audio::Endpoints::{ + IAudioEndpointVolume, IAudioEndpointVolumeCallback, IAudioEndpointVolumeCallback_Impl, +}; +use windows::Win32::Media::Audio::{IMMDeviceEnumerator, AUDIO_VOLUME_NOTIFICATION_DATA}; +use windows::Win32::System::Com::CLSCTX_ALL; + +use super::AudioMsg; + +#[windows_core::implement(IAudioEndpointVolumeCallback)] +struct VolumeNotifier { + tx: Sender, +} + +impl IAudioEndpointVolumeCallback_Impl for VolumeNotifier_Impl { + fn OnNotify(&self, _data: *mut AUDIO_VOLUME_NOTIFICATION_DATA) -> windows::core::Result<()> { + let _ = self.tx.send(AudioMsg::VolumeChanged); + Ok(()) + } +} + +/// The volume control of one playback endpoint with a registered change callback. +struct Watch { + volume: IAudioEndpointVolume, + callback: IAudioEndpointVolumeCallback, +} + +impl Watch { + fn open(e: &IMMDeviceEnumerator, device_id: &str, tx: Sender) -> windows::core::Result { + let id = HSTRING::from(device_id); + // SAFETY: COM calls on a valid enumerator/device; COM is initialised on this thread. + let volume: IAudioEndpointVolume = unsafe { e.GetDevice(&id)?.Activate(CLSCTX_ALL, None)? }; + let callback: IAudioEndpointVolumeCallback = VolumeNotifier { tx }.into(); + // SAFETY: COM call on a valid interface; unregistered in Drop, and `callback` is kept + // alive until then. + unsafe { volume.RegisterControlChangeNotify(&callback)? }; + Ok(Self { volume, callback }) + } + + fn read(&self) -> windows::core::Result { + // SAFETY: COM calls on a valid interface. + let (scalar, muted) = unsafe { (self.volume.GetMasterVolumeLevelScalar()?, self.volume.GetMute()?) }; + let level = (scalar.clamp(0.0, 1.0) * f32::from(MAX_LEVEL)).round() as u8; + Ok(Volume { level, muted: muted.as_bool() }) + } +} + +impl Drop for Watch { + fn drop(&mut self) { + // SAFETY: matches the registration in `open`; the callback is still alive. + unsafe { + let _ = self.volume.UnregisterControlChangeNotify(&self.callback); + } + } +} + +/// Follows the default playback device for the supervisor and applies volume requests to it. +pub struct VolumeControl { + tx: Sender, + /// The device the watch belongs to (or failed to open for). + device_id: Option, + watch: Option, + /// The last value handed out by [`VolumeControl::take_change`]. + reported: Option>, +} + +impl VolumeControl { + /// `tx` receives `AudioMsg::VolumeChanged` whenever the watched volume changes. + pub fn new(tx: Sender) -> Self { + Self { tx, device_id: None, watch: None, reported: None } + } + + /// Moves the change callback to `default_render_id` if the default device changed (or the + /// last attempt to open it failed). Call on startup and after device changes. + pub fn follow(&mut self, e: &IMMDeviceEnumerator, default_render_id: Option<&String>) { + if self.device_id.as_ref() == default_render_id && (self.watch.is_some() || default_render_id.is_none()) { + return; + } + // Dropping the old watch unregisters its callback. + self.watch = None; + self.device_id = default_render_id.cloned(); + if let Some(id) = default_render_id { + match Watch::open(e, id, self.tx.clone()) { + Ok(w) => self.watch = Some(w), + Err(err) => tracing::warn!("volume control of the default playback device unavailable: {err}"), + } + } + } + + pub fn set_level(&self, level: u8) { + let Some(w) = &self.watch else { + tracing::warn!("volume request ignored: no default playback device"); + return; + }; + let scalar = f32::from(level.min(MAX_LEVEL)) / f32::from(MAX_LEVEL); + // SAFETY: COM call on a valid interface; a null event context is allowed. + if let Err(e) = unsafe { w.volume.SetMasterVolumeLevelScalar(scalar, std::ptr::null()) } { + tracing::warn!("setting the playback volume failed: {e}"); + } + } + + pub fn set_mute(&self, muted: bool) { + let Some(w) = &self.watch else { + tracing::warn!("mute request ignored: no default playback device"); + return; + }; + // SAFETY: COM call on a valid interface; a null event context is allowed. + if let Err(e) = unsafe { w.volume.SetMute(muted, std::ptr::null()) } { + tracing::warn!("setting the playback mute failed: {e}"); + } + } + + /// Reads the current volume (`None` without a usable default device) and returns it if it + /// differs from the last returned value. + pub fn take_change(&mut self) -> Option> { + let now = match self.watch.as_ref().map(Watch::read) { + Some(Ok(v)) => Some(v), + Some(Err(e)) => { + // Typically an invalidated endpoint; the next device change reopens it. + tracing::warn!("reading the playback volume failed: {e}"); + self.watch = None; + None + } + None => None, + }; + if self.reported == Some(now) { + return None; + } + self.reported = Some(now); + Some(now) + } +} diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs index b23f349..b9828c0 100644 --- a/crates/desktop/src/main.rs +++ b/crates/desktop/src/main.rs @@ -122,6 +122,7 @@ fn run(primary: instance::Primary, background: bool, data_dir: &Path) -> Result< server.set_mic_enabled(settings.mic_enabled); let capture = server.pc_audio_capture(); let playout = server.mic_playout(); + let requests = server.take_requests(); let (main_tx, main_rx) = mpsc::channel(); let sh = shared::init(data_dir.to_path_buf(), pc_name, server, settings, main_tx); @@ -132,11 +133,20 @@ fn run(primary: instance::Primary, background: bool, data_dir: &Path) -> Result< Hooks { on_devices: Box::new(|d| shared().set_devices(d)), on_demand: Box::new(|v| shared().set_mic_demand(v)), + on_volume: Box::new(|v| shared().set_pc_volume_state(v)), }, ) .context("audio thread")?; sh.set_audio_sender(audio.sender()); sh.spawn_watchers(&rt, audio.sender()); + // Remote control from the phone: the server only forwards requests; the app applies them. + if let Some(mut requests) = requests { + rt.spawn(async move { + while let Some(req) = requests.recv().await { + shared().apply_request(req); + } + }); + } let tray = tray::spawn().context("tray icon")?; primary.listen_for_show(|| shared().show_window()); primary.listen_for_quit(|| shared().request_exit()); diff --git a/crates/desktop/src/settings.rs b/crates/desktop/src/settings.rs index 67c1c3a..354cc6e 100644 --- a/crates/desktop/src/settings.rs +++ b/crates/desktop/src/settings.rs @@ -13,11 +13,20 @@ pub struct Settings { /// Endpoint id of the last default playback device that was not VB-CABLE, so a CABLE /// hijack of the default device (common right after installing VB-CABLE) can be undone. pub last_default_render: Option, + /// Endpoint id of the last default recording device that was not VB-CABLE, restored when the + /// virtual mic stops being the default microphone. + pub last_default_capture: Option, } impl Default for Settings { fn default() -> Self { - Self { autostart: true, pc_audio_enabled: true, mic_enabled: true, last_default_render: None } + Self { + autostart: true, + pc_audio_enabled: true, + mic_enabled: true, + last_default_render: None, + last_default_capture: None, + } } } diff --git a/crates/desktop/src/shared.rs b/crates/desktop/src/shared.rs index 2809aab..efe87d2 100644 --- a/crates/desktop/src/shared.rs +++ b/crates/desktop/src/shared.rs @@ -6,14 +6,15 @@ use std::sync::mpsc::Sender; use std::sync::{Mutex, OnceLock}; use audiobridge_core::pairing::PairingInfo; -use audiobridge_core::session::{ConnState, Server, Status}; +use audiobridge_core::proto::MAX_LEVEL; +use audiobridge_core::session::{ConnState, PcRequest, PhoneRequest, Server, Status, Volume}; use tokio::sync::watch; use windows::Win32::Foundation::{HWND, LPARAM, WPARAM}; use windows::Win32::UI::WindowsAndMessaging::{ IsIconic, PostMessageW, SetForegroundWindow, ShowWindow, SW_RESTORE, SW_SHOW, }; -use crate::audio::{set_default_render, AudioMsg, Desired, DeviceSummary}; +use crate::audio::{set_default_endpoint, AudioMsg, Desired, DeviceSummary}; use crate::cable_install::InstallState; use crate::settings::Settings; use crate::{autostart, tray}; @@ -111,14 +112,102 @@ impl Shared { self.repaint(); } - pub fn set_pc_audio(&self, on: bool) { + /// "Звук ПК": streams PC audio; switching it on also undoes a VB-CABLE takeover of the + /// default playback device (loopback pauses while CABLE is the default). + pub fn set_pc_audio(&'static self, on: bool) { self.with_server(|s| s.set_pc_audio_enabled(on)); self.update_settings(|s| s.pc_audio_enabled = on); + let d = self.devices(); + if on && d.default_is_cable { + let remembered = self.settings().last_default_render; + match d.render_restore_candidate(remembered.as_deref()) { + Some(target) => self.restore_default_render(target.id.clone()), + None => tracing::warn!("default playback device is VB-CABLE and no other playback device is active"), + } + } } - pub fn set_mic(&self, on: bool) { + /// One-button "Микрофон": on = the PC accepts the phone mic, the phone mic is switched on + /// remotely and the virtual mic becomes the default recording device; off = the PC stops + /// accepting it and the previous recording device is restored. The phone mic itself stays + /// on (other PCs may use it; the phone stops capturing when no PC wants it). + pub fn set_mic(&'static self, on: bool) { self.with_server(|s| s.set_mic_enabled(on)); self.update_settings(|s| s.mic_enabled = on); + if on { + let phone = self.status.borrow().phone; + if phone.is_some_and(|p| !p.mic) { + self.set_phone_mic(true); + } + } + self.set_mic_default(on); + } + + /// Makes the virtual mic ("CABLE Output") the Windows default recording device, or restores + /// the previous recording device if it is the default. + pub fn set_mic_default(&'static self, on: bool) { + let d = self.devices(); + if on { + match &d.cable_capture_id { + Some(_) if d.default_capture_is_cable => {} + Some(id) => self.switch_default_device(id.clone(), "recording"), + None => tracing::warn!("cannot make the virtual mic the default recording device: no VB-CABLE"), + } + } else if d.default_capture_is_cable { + let remembered = self.settings().last_default_capture; + match d.capture_restore_candidate(remembered.as_deref()) { + Some(target) => self.switch_default_device(target.id.clone(), "recording"), + None => tracing::warn!("the virtual mic is the default recording device and there is nothing to restore"), + } + } + } + + /// Default playback device volume, percent. + pub fn set_volume(&self, level: u8) { + self.send_audio(AudioMsg::SetVolume(level.min(MAX_LEVEL))); + } + + /// Mutes/unmutes the default playback device. + pub fn set_mute(&self, muted: bool) { + self.send_audio(AudioMsg::SetMute(muted)); + } + + /// Default playback device volume as read by the audio engine (reported to the phone). + pub fn set_pc_volume_state(&self, v: Option) { + self.with_server(|s| s.set_volume(v)); + } + + /// Switches the connected phone's mic. + pub fn set_phone_mic(&self, on: bool) { + self.request_phone(PhoneRequest::Mic(on)); + } + + /// Sets the connected phone's media volume, percent. + pub fn set_phone_volume(&self, level: u8) { + self.request_phone(PhoneRequest::Volume(level.min(MAX_LEVEL))); + } + + fn request_phone(&self, req: PhoneRequest) { + let mut sent = false; + self.with_server(|s| sent = s.request_phone(req)); + if !sent { + tracing::info!("phone request {req:?} dropped: no phone connected"); + } + } + + /// Applies a remote-control request from the phone; results flow back through `Status`. + pub fn apply_request(&'static self, req: PcRequest) { + match req { + PcRequest::Volume(_) => tracing::debug!("phone request: {req:?}"), + _ => tracing::info!("phone request: {req:?}"), + } + match req { + PcRequest::Audio(on) => self.set_pc_audio(on), + PcRequest::Mic(on) => self.set_mic(on), + PcRequest::MicDefault(on) => self.set_mic_default(on), + PcRequest::Volume(level) => self.set_volume(level), + PcRequest::Mute(muted) => self.set_mute(muted), + } } pub fn set_autostart(&self, on: bool) { @@ -139,13 +228,29 @@ impl Shared { pub fn set_devices(&self, d: &DeviceSummary) { *lock(&self.devices) = d.clone(); - if !d.default_is_cable { - if let Some(id) = &d.default_render_id { - if lock(&self.settings).last_default_render.as_ref() != Some(id) { - let id = id.clone(); - self.update_settings(|s| s.last_default_render = Some(id)); + self.with_server(|s| s.set_mic_default(d.default_capture_is_cable)); + // Remember the user's own devices so a VB-CABLE default can be undone later. + let render = d.default_render_id.as_ref().filter(|_| !d.default_is_cable); + let capture = d + .default_capture_id + .as_ref() + .filter(|id| d.capture_endpoints.iter().any(|c| c.id == **id && !c.is_cable)); + let (render, capture) = { + let s = lock(&self.settings); + ( + render.filter(|id| s.last_default_render.as_ref() != Some(*id)).cloned(), + capture.filter(|id| s.last_default_capture.as_ref() != Some(*id)).cloned(), + ) + }; + if render.is_some() || capture.is_some() { + self.update_settings(|s| { + if let Some(id) = render { + s.last_default_render = Some(id); } - } + if let Some(id) = capture { + s.last_default_capture = Some(id); + } + }); } self.repaint(); } @@ -154,19 +259,29 @@ impl Shared { *lock(&self.audio_tx) = Some(tx); } - /// Re-reads the device list (e.g. after the VB-CABLE installer exits). - pub fn rescan_devices(&self) { + fn send_audio(&self, msg: AudioMsg) { if let Some(tx) = lock(&self.audio_tx).as_ref() { - let _ = tx.send(AudioMsg::DevicesChanged); + let _ = tx.send(msg); } } + /// Re-reads the device list (e.g. after the VB-CABLE installer exits). + pub fn rescan_devices(&self) { + self.send_audio(AudioMsg::DevicesChanged); + } + /// Makes `id` the default playback device again (undoes a VB-CABLE takeover). pub fn restore_default_render(&'static self, id: String) { + self.switch_default_device(id, "playback"); + } + + /// Makes `id` the default device of its direction (`what`: "playback"/"recording", for logs) + /// on a helper thread, since the COM calls can block, then re-reads the device list. + fn switch_default_device(&'static self, id: String, what: &'static str) { let spawned = std::thread::Builder::new().name("set-default-device".into()).spawn(move || { - match set_default_render(&id) { - Ok(()) => tracing::info!("default playback device restored to {id}"), - Err(e) => tracing::warn!("restoring the default playback device failed: {e:#}"), + match set_default_endpoint(&id) { + Ok(()) => tracing::info!("default {what} device set to {id}"), + Err(e) => tracing::warn!("setting the default {what} device failed: {e:#}"), } self.rescan_devices(); }); diff --git a/crates/desktop/src/ui.rs b/crates/desktop/src/ui.rs index 7c6e5fe..9719dc4 100644 --- a/crates/desktop/src/ui.rs +++ b/crates/desktop/src/ui.rs @@ -347,6 +347,8 @@ fn switch_row(ui: &mut Ui, title: &str, subtitle: Option<&str>, on: bool) -> boo let mut clicked = false; ui.horizontal(|ui| { ui.vertical(|ui| { + // leave room for the switch, so long subtitles wrap instead of pushing it out + ui.set_max_width((ui.available_width() - 56.0).max(0.0)); ui.spacing_mut().item_spacing.y = 2.0; ui.label(RichText::new(title).size(14.5).color(TEXT)); if let Some(sub) = subtitle { @@ -383,6 +385,7 @@ fn wide_button(ui: &mut Ui, text: &str, icon: Option, show_qr: bool, + volume_edit: VolumeEdit, } impl eframe::App for UiApp { @@ -414,7 +417,7 @@ impl eframe::App for UiApp { if !connected || self.show_qr { self.qr_card(ui, pairing.as_deref(), connected, &status); } else { - connected_cards(ui, &status, &settings, &devices); + connected_cards(ui, &status, &settings, &devices, &mut self.volume_edit); if wide_button(ui, "Показать QR-код", Some(icon_qr)) { self.show_qr = true; } @@ -479,15 +482,28 @@ fn tile(ui: &mut Ui, rect: Rect, t: &TileSpec) -> bool { p.text(pos2(inner.left(), inner.top() + 62.0), Align2::LEFT_CENTER, t.title, semibold(15.5), TEXT); p.text(pos2(inner.left(), inner.top() + 82.0), Align2::LEFT_CENTER, t.route, FontId::proportional(12.5), MUTED); let sy = inner.bottom() - 6.0; - p.circle_filled(pos2(inner.left() + 4.0, sy), 3.5, t.state_color); - p.text(pos2(inner.left() + 14.0, sy), Align2::LEFT_CENTER, t.state, FontId::proportional(12.5), t.state_color); + // A long state wraps (it may use half of the right padding); it grows upwards, and the dot + // marks its first row. + let wrap = inner.width() - 14.0 + PAD / 2.0; + let galley = p.layout(t.state.to_owned(), FontId::proportional(12.5), t.state_color, wrap); + let rows = galley.rows.len().max(1); + let row_h = galley.size().y / rows as f32; + let top = sy + row_h / 2.0 - galley.size().y + if rows > 1 { 6.0 } else { 0.0 }; + p.circle_filled(pos2(inner.left() + 4.0, top + row_h / 2.0), 3.5, t.state_color); + p.galley(pos2(inner.left() + 14.0, top), galley, t.state_color); let sw = Rect::from_min_size(pos2(inner.right() - 44.0, inner.top() + 7.0), vec2(44.0, 26.0)); // a child UI that does not move the parent's cursor (the row was allocated by the caller) toggle(&mut ui.new_child(UiBuilder::new().max_rect(sw)), t.on) } -fn connected_cards(ui: &mut Ui, status: &Status, settings: &Settings, devices: &DeviceSummary) { +fn connected_cards( + ui: &mut Ui, + status: &Status, + settings: &Settings, + devices: &DeviceSummary, + edit: &mut VolumeEdit, +) { let sh = shared(); // the phone @@ -524,10 +540,15 @@ fn connected_cards(ui: &mut Ui, status: &Status, settings: &Settings, devices: & } else { ("Тишина", MUTED) }; + let phone = status.phone; let (mic_state, mic_color) = if devices.cable_render_id.is_none() { ("Нужен VB-CABLE", AMBER) } else if !settings.mic_enabled { ("Выключено", MUTED) + } else if phone.is_some_and(|p| !p.mic) { + ("Выключен на телефоне", AMBER) + } else if phone.is_some_and(|p| !p.mic_ready) { + ("Откройте приложение на телефоне", AMBER) } else if status.mic.active { ("Передаётся", GREEN) } else if status.mic_demanded { @@ -565,6 +586,8 @@ fn connected_cards(ui: &mut Ui, status: &Status, settings: &Settings, devices: & sh.set_mic(!settings.mic_enabled); } + controls_card(ui, status, devices, edit); + // numbers card(ui, CARD, CARD_STROKE, |ui| { // the playout buffer lives on the receiving side; here: link, outgoing rate and codec @@ -586,6 +609,125 @@ fn connected_cards(ui: &mut Ui, status: &Status, settings: &Settings, devices: & }); } +/// Local slider values while a volume slider is held: reports lag behind the drag. +#[derive(Default)] +struct VolumeEdit { + pc: Option, + phone: Option, +} + +/// Detailed remote controls next to the one-button tiles. +fn controls_card(ui: &mut Ui, status: &Status, devices: &DeviceSummary, edit: &mut VolumeEdit) { + let sh = shared(); + card(ui, CARD, CARD_STROKE, |ui| { + ui.label(RichText::new("Управление").font(semibold(15.5)).color(TEXT)); + + let pc_volume = status.pc.volume; + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 4.0; + ui.label(RichText::new("Громкость компьютера").size(14.5).color(TEXT)); + ui.horizontal(|ui| { + let muted = pc_volume.is_some_and(|v| v.muted); + if mute_button(ui, pc_volume.is_some(), muted) { + sh.set_mute(!muted); + } + if let Some(level) = level_slider(ui, pc_volume.map(|v| v.level), &mut edit.pc) { + sh.set_volume(level); + } + }); + }); + + match status.phone { + Some(phone) => { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 4.0; + ui.horizontal(|ui| { + ui.label(RichText::new("Громкость телефона").size(14.5).color(TEXT)); + if phone.volume.is_none() { + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + ui.label(RichText::new("нет данных").size(12.5).color(MUTED)); + }); + } + }); + if phone.volume.is_some() { + ui.horizontal(|ui| { + if let Some(level) = level_slider(ui, phone.volume, &mut edit.phone) { + sh.set_phone_volume(level); + } + }); + } + }); + let sub = if phone.mic_ready { + "Переключатель микрофона на телефоне" + } else { + "Откройте AudioBridge на телефоне один раз: без этого Android не даёт записывать в фоне" + }; + if switch_row(ui, "Микрофон телефона", Some(sub), phone.mic) { + sh.set_phone_mic(!phone.mic); + } + } + None => edit.phone = None, + } + + let on = devices.default_capture_is_cable; + let has_cable = devices.cable_capture_id.is_some(); + let sub = if has_cable { "CABLE Output — основной микрофон Windows" } else { "Нужен VB-CABLE" }; + if switch_row(ui, "Микрофон по умолчанию", Some(sub), on) && has_cable { + sh.set_mic_default(!on); + } + }); +} + +/// Speaker button of the PC volume row: MUTED with a slash while muted. Returns true when clicked. +fn mute_button(ui: &mut Ui, enabled: bool, muted: bool) -> bool { + let sense = if enabled { Sense::click() } else { Sense::hover() }; + let (rect, resp) = ui.allocate_exact_size(vec2(32.0, 32.0), sense); + let p = ui.painter(); + if enabled && resp.hovered() { + p.rect_filled(rect, CornerRadius::same(8), CARD_HOVER); + } + let color = if !enabled { + MUTED.gamma_multiply(0.5) + } else if muted { + MUTED + } else { + TEXT + }; + let icon = rect.shrink(5.0); + icon_speaker(p, icon, color); + if muted { + let inset = icon.width() * 0.12; + let slash = [icon.left_top() + vec2(inset, inset), icon.right_bottom() - vec2(inset, inset)]; + p.line_segment(slash, Stroke::new(icon.width() * 0.08, color)); + } + enabled && resp.on_hover_cursor(egui::CursorIcon::PointingHand).clicked() +} + +/// A 0..=100 slider filling the rest of the row, then a right-aligned "NN %". Disabled while +/// `level` is unknown. `edit` keeps the local value while the slider is held, so lagging +/// reports don't make the handle jump. Returns the new level when its rounded value changed. +fn level_slider(ui: &mut Ui, level: Option, edit: &mut Option) -> Option { + const VALUE_W: f32 = 44.0; + let enabled = level.is_some(); + let mut value = edit.unwrap_or_else(|| f32::from(level.unwrap_or(0))); + let before = value.round() as u8; + ui.spacing_mut().slider_width = (ui.available_width() - VALUE_W - ui.spacing().item_spacing.x).max(40.0); + let v = ui.visuals_mut(); + v.selection.bg_fill = TEAL; + v.widgets.inactive.bg_fill = TOGGLE_OFF; + v.widgets.inactive.fg_stroke = Stroke::new(2.0, Color32::WHITE); + v.widgets.hovered.bg_fill = Color32::WHITE; + v.widgets.active.bg_fill = Color32::WHITE; + let slider = egui::Slider::new(&mut value, 0.0..=100.0).show_value(false).trailing_fill(true); + let resp = ui.add_enabled(enabled, slider); + *edit = (resp.dragged() || resp.is_pointer_button_down_on()).then_some(value); + let now = value.round() as u8; + let (rect, _) = ui.allocate_exact_size(vec2(VALUE_W, 20.0), Sense::hover()); + let (text, color) = if enabled { (format!("{now} %"), TEXT) } else { ("—".to_owned(), MUTED) }; + ui.painter().text(rect.right_center(), Align2::RIGHT_CENTER, text, FontId::proportional(13.5), color); + (enabled && resp.changed() && now != before).then_some(now) +} + impl UiApp { fn qr_texture(&mut self, ctx: &egui::Context, uri: &str) -> Option<&TextureHandle> { if self.qr.as_ref().is_none_or(|(u, _)| u != uri) { @@ -688,7 +830,7 @@ fn warnings(ui: &mut Ui, devices: &DeviceSummary, settings: &Settings) { .size(13.0) .color(TEXT), ); - match devices.restore_candidate(settings.last_default_render.as_deref()) { + match devices.render_restore_candidate(settings.last_default_render.as_deref()) { Some(target) => { if ui.button(RichText::new(format!("Вернуть «{}»", target.name)).size(13.5)).clicked() { sh.restore_default_render(target.id.clone());