diff --git a/AGENTS.md b/AGENTS.md index c3591df..3a77e01 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,9 +19,10 @@ 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 (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. +- **Control (protocol v3):** a single bi-stream carrying `ControlMsg` frames (`len u16 | tag | body`): Hello{secret, PhoneControls} → Welcome{PcControls}/Reject, PcState, MicDemand, PhoneState, SetPc, SetPhone, PcMedia (v3). 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. +- **Headphone buttons → PC media (v3):** the earbuds turn taps into AVRCP commands; Android's Bluetooth stack dispatches play/pause/next/previous as media key events to the media session of the app that played audio last. `HeadsetControl.kt` (owned by `BridgeService`) keeps a `MediaSession` while the feature is on and exactly one PC is the target (`MediaTarget` in `MediaKeys.kt`: the chosen PC while connected, else the unmuted connected PC that plays / played or was controlled last / the only one; ambiguous → none, never fan-out). Without a target the session is released, so the keys go to other players. Its `PlaybackState` mirrors the target's `PcMedia`, which is also what the earbuds use to choose "play" or "pause". A press (first ACTION_DOWN) becomes `PcRequest::Media(MediaCommand)` via `controlPc(id, PC_MEDIA, code)`; a 3 s timed wake lock covers the screen-off send. The PC applies it to the Windows media session (GSMTC, `crates/desktop/src/media.rs`; SendInput media keys only when no session exists or the call fails) and reports `PcMedia` (playback, app, title, artist) through `Server::set_media`. Volume taps are NOT media keys: AVRCP volume up/down is dropped by `MediaSessionService` (not `isMediaSessionKey`) and absolute volume sets the phone's STREAM_MUSIC directly, so apps can't see or remap them; don't add a system-volume observer to fake it. "Один наушник" remaps only what arrives: the play/pause group = 2 taps, next/previous = 3 taps. - **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. @@ -31,6 +32,7 @@ PC (Server) WASAPI render "CABLE Input" ◄─ PlayoutHandle (jitter buffer, de - **Phone-side mute:** `Hub::set_muted(ids)` (Kotlin `NativeBridge.setMuted`, persisted as `muted` per entry in `paired_pcs`). A muted PC stays connected, keeps the mic and keeps streaming. Its feeder still tracks activity but stops queueing audio, so a closed (unread) output never builds a stale backlog; one Silence marker ends the stream, and the mixer ramps it out over 10 ms. Unmuting resets the feeder: fresh stream, no replayed audio. A muted PC doesn't count toward `pc_audio_active`, so the output closes and the wake locks are released, and it gets no Pace packets. It's local to the phone: no wire/protocol change, so it works with any PC version. - **Status:** `tokio::sync::watch` (`Status` / `HubStatus`) via `StatusCell::update` → `send_if_modified`. Stats refresh at 2 Hz with change thresholds, so only real changes wake watchers. Keep this "notify on change" pattern; don't add polling. - **Desktop audio supervisor:** + - `service.rs` owns the service lifetime on `service-supervisor`, independently of the blocking UI loop. Global `settings.json.service_enabled` defaults to true for old files; off joins audio/media workers, cancels and joins relays, shuts down the server endpoint and restores non-CABLE default devices. Restart uses the same identity directory and cached pairing QR. Generation-tagged remote/device commands cannot leak across restart. - 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". @@ -46,13 +48,14 @@ PC (Server) WASAPI render "CABLE Input" ◄─ PlayoutHandle (jitter buffer, de - `audio/`: WASAPI loopback/render, devices, demand, `policy.rs` (IPolicyConfig default-device restore). - `ui.rs` (eframe), `tray.rs`, `selfinstall.rs`, `autostart.rs`, `instance.rs`, `cable_install.rs`. - The window uses the brand gradient (indigo `#5B4BFF` → teal `#19C3D0`, the same as the Android launcher icon) and draws its icons with the painter. It repaints on status changes; the only timer (`request_repaint_after`, 40 ms) drives the level bars while PC audio streams and the window is open. Child widgets placed at an absolute rect use `ui.new_child(..)`: `scope_builder` moves the parent cursor and makes later cards overlap. + - Close destroys the window, not the service. `WindowState` coalesces show requests while creation is pending; tray UP/DBLCLK/UP must not enqueue multiple windows that open after successive closes. Status repaint never opens a window. - `crates/android-native/src/` - - Pure, host-testable: `status.rs` (statusJson v2 + `NotifyGate`), `policy.rs`, `peers.rs`. + - Pure, host-testable: `status.rs` (statusJson v2 + `NotifyGate`), `controls.rs` (`controlPc` actions), `policy.rs`, `peers.rs`. - `android/` (cfg android only): `jni_api.rs`, `engine.rs` (tokio + Hub manager), `controller.rs` (`ab-audio` thread), `aaudio.rs`, `listener.rs`, `logging.rs`. - `android/app/src/main/java/app/audiobridge/` - `NativeBridge.kt` / `StatusListener.kt`: the JNI surface. - - `BridgeService.kt`: the foreground service. - - `BootReceiver.kt`, `AppState.kt` (prefs, StatusHub), `BridgeStatus.kt` (statusJson parser, `PairedPc`), `ui/`. + - `BridgeService.kt`: the foreground service. `HeadsetControl.kt`: the media session for headphone buttons; `MediaKeys.kt`: its pure logic (key mapping, target choice), JVM-tested. + - `BootReceiver.kt`, `AppState.kt` (prefs incl. headset settings, StatusHub), `BridgeStatus.kt` (statusJson parser, `PairedPc`), `ui/` (`HeadsetCard.kt`: headphone-button settings). - `scripts/install-desktop.ps1`: build plus self-install of the desktop app. ## Development Commands @@ -65,6 +68,7 @@ powershell -File scripts\install-desktop.ps1 [-SkipBuild] [-NoFirewall] cargo ndk -t arm64-v8a -o android/app/src/main/jniLibs build --release -p audiobridge-android cargo ndk -t arm64-v8a clippy -p audiobridge-android --all-targets -- -D warnings # Android clippy needs cargo-ndk (cc needs NDK clang) ``` +- Debug-only isolation: `AUDIOBRIDGE_DEV_DIR=` gives a separate identity, logs and instance mutex/events; seed `settings.json` with `"autostart": false` before testing beside the installed app. Release builds ignore this variable. - **APK:** run Gradle from inside `android/` (PowerShell: `Set-Location android; .\gradlew.bat :app:assembleDebug`). It runs `buildRustLib` (cargo-ndk) automatically; add `-PskipRust` to reuse the `.so` already in `jniLibs`. Also: `:app:testDebugUnitTest`, `:app:lintDebug`. - **Manual E2E without a phone or PC:** - Phone emulator against a running PC app: `cargo run -p audiobridge-core --example probe -- [--seconds N] [--mic-sine HZ] [--local]`. @@ -84,12 +88,15 @@ 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`, incl. the per-PC remote controls `pcMic`, `micDefault`, `pcVolume`, `pcMuted`) ↔ `BridgeStatus.kt` (which throws on unknown states). + - The statusJson v2 schema (`android-native/src/status.rs`, incl. the per-PC remote controls `pcMic`, `micDefault`, `pcVolume`, `pcMuted` and `media`) ↔ `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. + - `controlPc` actions `PC_*` and media codes `MEDIA_*` (`NativeBridge.kt`) ↔ `controls.rs` ↔ `MediaCommand::code` (never renumber). - Core `Server` API ↔ `crates/desktop`; core `ControlMsg` ↔ `PROTOCOL_VERSION` (both apps must be updated together). + - Persisted headset settings keys and `TapAction.pref` values (`AppState.kt`, `MediaKeys.kt`): never rename. - **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` (also flipped remotely by a PC), `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`, `service_enabled` (the global switch, missing = on), headphone buttons: `headset_enabled`, `headset_single_earbud`, `headset_double_tap`, `headset_triple_tap`, `headset_target`). +- **Global switch (phone):** `Prefs.serviceEnabled` (written with `commit`). Off stops `BridgeService` (native `setPeers("[]")` → hub stopped, no notification, no media session); every start path (`MainActivity.startBridge`, `BootReceiver`, a sticky restart in `onStartCommand`) checks `Prefs.shouldRun()`. Pairings and settings stay. It never tells PCs to switch off: they just see the phone offline. - **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). @@ -129,13 +136,14 @@ 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, 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. + - `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, remote control in both directions and media commands (only the addressed PC, exactly once, in order; `PcMedia` reaches the phone and is cleared on disconnect). 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. - - Desktop: a single resampler test. + - android-native: host tests for the statusJson v2 schema, `NotifyGate` debounce, `controlPc` mapping, policies and peer parsing. + - Android: JVM tests `BridgeStatusTest`, `PairedPcTest` and `MediaKeysTest` (key mapping incl. single-earbud, one press = one command, target choice, settings persistence) (`isReturnDefaultValues = false`); there is no instrumented test. + - Desktop: resampler, persisted service default/roundtrip, show-request coalescing, and service-generation request gating tests. - **What's not tested:** WASAPI, AAudio, JNI, the UI and the service. Verify those on real hardware: - PC audio: play a tone on the PC → the phone UI shows "Звук" / buffer. - Mic: `ffmpeg -f dshow -i audio="CABLE Output (VB-Audio Virtual Cable)" -t 8 -af volumedetect -f null -` should report non-silent levels while the phone mic is allowed. - Autostart: reboot the phone → the PC log shows `phone 'POCO F5' connected`. + - Headphone buttons: play music on the PC through AudioBridge, screen off, double/triple tap → the PC log shows `media command …` and the player reacts; the app's "Последняя команда" line shows what the phone received (`adb logcat -s AudioBridge:*` → `media command N -> PC`). - **Clippy:** `-D warnings` is the working bar for all crates. GitHub CI (Windows) runs clippy plus the core lib/rt_alloc, desktop and android-native host tests; the timing-sensitive integration tests and Android/Gradle checks run locally only. Android lint must stay at 0 errors. diff --git a/Cargo.lock b/Cargo.lock index c2ada2e..1af8761 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,7 +196,7 @@ dependencies = [ [[package]] name = "audiobridge-android" -version = "0.3.0" +version = "0.4.0" dependencies = [ "android_logger", "audiobridge-core", @@ -213,7 +213,7 @@ dependencies = [ [[package]] name = "audiobridge-core" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "audiobridge-opus-sys", @@ -233,7 +233,7 @@ dependencies = [ [[package]] name = "audiobridge-desktop" -version = "0.3.0" +version = "0.4.0" dependencies = [ "anyhow", "audiobridge-core", @@ -252,7 +252,7 @@ dependencies = [ [[package]] name = "audiobridge-opus-sys" -version = "0.3.0" +version = "0.4.0" dependencies = [ "cc", ] diff --git a/Cargo.toml b/Cargo.toml index a78f356..e130e79 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.3.0" +version = "0.4.0" edition = "2021" license = "MIT" repository = "https://github.com/Lyten02/AudioBridge" diff --git a/README.md b/README.md index d22ae25..be1ae19 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ Pair once by scanning a QR code. After that both apps run in the background, sta - **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. +- **Headphone buttons control the music on the PC.** With the earbuds connected to the phone, double tap pauses/resumes and triple tap skips tracks in the PC's player (Yandex Music, Spotify, a browser — anything in the Windows media overlay), also with the phone screen off. With several PCs, the buttons control exactly one: the one playing, or the one you pick. A "Single earbud" mode lets you choose the 2- and 3-tap actions so either earbud alone can do everything. Volume taps keep changing the phone volume (what you hear): Android doesn't tell apps about them, so they can't be remapped. - **Background and autostart** on both sides. Low CPU: about 1–2 % of one core on the PC, 4–5 % on the phone while playing. +- **PC service on/off:** «Выключить AudioBridge» in the window or tray disconnects this PC, stops audio and media integration, and restores non-CABLE default devices where available. «Включить AudioBridge» restores the saved feature settings and pairing without rescanning. Off stays off after restarting Windows; other PCs and the phone's switches are untouched. Closing the window only returns it to the tray; reopen it with the tray icon or another launch. - **End-to-end encrypted** (QUIC/TLS) and authenticated by the pairing secret from the QR code. ## Requirements diff --git a/README.ru.md b/README.ru.md index e27c7b1..7dcbf7f 100644 --- a/README.ru.md +++ b/README.ru.md @@ -28,7 +28,9 @@ AudioBridge передаёт весь звук компьютера на Android - **Работает между разными сетями.** P2P через [iroh](https://iroh.computer) QUIC: локальная сеть, пробой NAT, Tailscale, а в крайнем случае зашифрованный relay. Телефону и ПК не обязательно быть в одном Wi-Fi. - **Несколько компьютеров сразу.** До 8 ПК (стационарный, ноутбук, …): их звук смешивается, а микрофон уходит только туда, где он нужен. Звук отдельного ПК или всех сразу можно выключить одной кнопкой прямо с телефона, связь при этом не рвётся. - **Полный пульт с обеих сторон.** С ПК: громкость компьютера и телефона, микрофон телефона, выбор микрофона Windows. С телефона: звук и микрофон компьютера, его громкость и mute. Кнопка «Микрофон» на ПК делает всё сразу: включает микрофон на телефоне и ставит `CABLE Output` микрофоном Windows по умолчанию; выключение возвращает прежний микрофон. +- **Кнопки наушников управляют музыкой на ПК.** Наушники подключены к телефону: двойное касание ставит на паузу и продолжает, тройное переключает треки в плеере на ПК (Яндекс Музыка, Spotify, браузер — всё, что видно в медиапанели Windows), в том числе с выключенным экраном телефона. Если ПК несколько, кнопки управляют ровно одним: тем, где играет музыка, или выбранным. Режим «Один наушник» позволяет выбрать действия для 2 и 3 касаний, чтобы любой наушник в одиночку умел всё. Касание громкости по-прежнему меняет громкость телефона (то, что слышно): Android не сообщает о нём приложениям, поэтому переназначить его нельзя. - **Фон и автозапуск** на обеих сторонах. Нагрузка небольшая: на ПК 1–2 % одного ядра, на телефоне 4–5 % во время воспроизведения. +- **Выключатель сервиса на ПК:** «Выключить AudioBridge» в окне или трее отключает этот ПК от телефона, останавливает звук и управление медиаплеерами, возвращает прежние устройства Windows вместо CABLE, если они доступны. «Включить AudioBridge» восстанавливает настройки и прежнее сопряжение без повторного сканирования QR. Выключенное состояние сохраняется после перезагрузки Windows; телефон и другие ПК не выключаются. Крестик только закрывает окно в трей; открыть его можно значком в трее или повторным запуском. - **Сквозное шифрование** (QUIC/TLS) и проверка по секрету из QR-кода. ## Что нужно diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index fc2d68a..8fac6d9 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 = 3 - versionName = "0.3.0" + versionCode = 4 + versionName = "0.4.0" ndk { abiFilters += "arm64-v8a" } diff --git a/android/app/src/main/java/app/audiobridge/AppState.kt b/android/app/src/main/java/app/audiobridge/AppState.kt index c33b28c..6639d60 100644 --- a/android/app/src/main/java/app/audiobridge/AppState.kt +++ b/android/app/src/main/java/app/audiobridge/AppState.kt @@ -20,6 +20,17 @@ class Prefs private constructor(private val sp: SharedPreferences) { private val _autostartDone = MutableStateFlow(sp.getBoolean(KEY_AUTOSTART_DONE, false)) val autostartDone: StateFlow = _autostartDone.asStateFlow() + private val _headset = MutableStateFlow(readHeadset(sp)) + val headset: StateFlow = _headset.asStateFlow() + + private val _serviceEnabled = MutableStateFlow(readServiceEnabled(sp)) + + /** The user's global AudioBridge switch; off = the service must not run (boot, updates, app start included). */ + val serviceEnabled: StateFlow = _serviceEnabled.asStateFlow() + + /** The background service should run: switched on and at least one PC is paired. */ + fun shouldRun(): Boolean = shouldRun(_serviceEnabled.value, _pcs.value.size) + private fun readPcs(): List { val legacyUri = sp.getString(KEY_LEGACY_URI, null) ?: return PairedPc.decodeList(sp.getString(KEY_PCS, null)) // v1 stored a single PC; fold it into the list once. @@ -70,12 +81,60 @@ class Prefs private constructor(private val sp: SharedPreferences) { _autostartDone.value = done } + @Synchronized + fun setHeadset(settings: HeadsetSettings) { + sp.edit { writeHeadset(this, settings) } + _headset.value = settings + } + + /** Written synchronously: a reboot right after switching off must not start the service again. */ + @Synchronized + fun setServiceEnabled(on: Boolean) { + sp.edit(commit = true) { writeServiceEnabled(this, on) } + _serviceEnabled.value = on + } + companion object { private const val KEY_PCS = "paired_pcs" private const val KEY_LEGACY_URI = "pairing_uri" private const val KEY_LEGACY_PC_NAME = "pc_name" private const val KEY_MIC = "mic_enabled" private const val KEY_AUTOSTART_DONE = "autostart_done" + private const val KEY_HEADSET_ENABLED = "headset_enabled" + private const val KEY_HEADSET_SINGLE = "headset_single_earbud" + private const val KEY_HEADSET_DOUBLE = "headset_double_tap" + private const val KEY_HEADSET_TRIPLE = "headset_triple_tap" + private const val KEY_HEADSET_TARGET = "headset_target" + private const val KEY_SERVICE_ENABLED = "service_enabled" + + /** Missing (installs from before the switch existed) = on. */ + fun readServiceEnabled(sp: SharedPreferences): Boolean = sp.getBoolean(KEY_SERVICE_ENABLED, true) + + fun writeServiceEnabled(editor: SharedPreferences.Editor, on: Boolean) { + editor.putBoolean(KEY_SERVICE_ENABLED, on) + } + + fun shouldRun(serviceEnabled: Boolean, pairedPcs: Int): Boolean = serviceEnabled && pairedPcs > 0 + + /** Reads the headphone-button settings; missing or unknown values fall back to the defaults. */ + fun readHeadset(sp: SharedPreferences): HeadsetSettings { + val d = HeadsetSettings() + return HeadsetSettings( + enabled = sp.getBoolean(KEY_HEADSET_ENABLED, d.enabled), + singleEarbud = sp.getBoolean(KEY_HEADSET_SINGLE, d.singleEarbud), + doubleTap = TapAction.fromPref(sp.getString(KEY_HEADSET_DOUBLE, null), d.doubleTap), + tripleTap = TapAction.fromPref(sp.getString(KEY_HEADSET_TRIPLE, null), d.tripleTap), + targetId = sp.getString(KEY_HEADSET_TARGET, null), + ) + } + + fun writeHeadset(editor: SharedPreferences.Editor, s: HeadsetSettings) { + editor.putBoolean(KEY_HEADSET_ENABLED, s.enabled) + editor.putBoolean(KEY_HEADSET_SINGLE, s.singleEarbud) + editor.putString(KEY_HEADSET_DOUBLE, s.doubleTap.pref) + editor.putString(KEY_HEADSET_TRIPLE, s.tripleTap.pref) + if (s.targetId == null) editor.remove(KEY_HEADSET_TARGET) else editor.putString(KEY_HEADSET_TARGET, s.targetId) + } @Volatile private var instance: Prefs? = null @@ -94,6 +153,12 @@ object StatusHub { /** The service holds the microphone FGS type and RECORD_AUDIO is granted (mic ready); capture also needs the switch. */ val micForeground = MutableStateFlow(false) + + /** The PC the headphone buttons control right now (null: none), resolved by the service. */ + val mediaTargetId = MutableStateFlow(null) + + /** The last headphone command the service handled, for the settings screen. */ + val lastMediaKey = MutableStateFlow(null) } object Native { diff --git a/android/app/src/main/java/app/audiobridge/BootReceiver.kt b/android/app/src/main/java/app/audiobridge/BootReceiver.kt index 4aff936..f4161bf 100644 --- a/android/app/src/main/java/app/audiobridge/BootReceiver.kt +++ b/android/app/src/main/java/app/audiobridge/BootReceiver.kt @@ -5,7 +5,7 @@ import android.content.Context import android.content.Intent import android.util.Log -/** Restarts the bridge after boot or an app update, if the phone is paired. */ +/** Restarts the bridge after boot or an app update, if the phone is paired and the user has not switched it off. */ class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { when (intent.action) { @@ -15,7 +15,7 @@ class BootReceiver : BroadcastReceiver() { -> Unit else -> return } - if (Prefs.get(context).pcs.value.isEmpty()) return + if (!Prefs.get(context).shouldRun()) return try { BridgeService.start(context, fromForeground = false) } catch (e: RuntimeException) { diff --git a/android/app/src/main/java/app/audiobridge/BridgeService.kt b/android/app/src/main/java/app/audiobridge/BridgeService.kt index 5d7fd2e..fc52c38 100644 --- a/android/app/src/main/java/app/audiobridge/BridgeService.kt +++ b/android/app/src/main/java/app/audiobridge/BridgeService.kt @@ -28,6 +28,10 @@ import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import kotlin.math.roundToInt +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch /** * Keeps the native client connected in the background. @@ -37,12 +41,17 @@ import kotlin.math.roundToInt * 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]). + * + * It also owns [HeadsetControl], the media session that forwards headphone buttons to a PC. That needs no extra FGS + * type or permission: media keys are delivered to the session by the system, also with the screen off. */ class BridgeService : Service() { private val main = Handler(Looper.getMainLooper()) private lateinit var prefs: Prefs private lateinit var notifications: NotificationManager private lateinit var audio: AudioManager + private lateinit var headset: HeadsetControl + private val scope = MainScope() /** Last JSON array handed to [NativeBridge.setPeers]. */ private var appliedPeers: String? = null @@ -98,6 +107,9 @@ class BridgeService : Service() { contentResolver.registerContentObserver(Settings.System.CONTENT_URI, true, volumeObserver) reportVolume() registerNetworkCallback() + headset = HeadsetControl(this, audio) + // Settings and the PC list change from the activity; status changes come through onStatusChanged. + scope.launch { combine(prefs.headset, prefs.pcs) { _, _ -> }.collect { refreshHeadset() } } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -114,7 +126,9 @@ class BridgeService : Service() { } val pcs = prefs.pcs.value - if (pcs.isEmpty()) { + // Switched off by the user (or nothing paired): a sticky restart or a late start must not keep it running. + // The foreground start above is still required before stopping (startForegroundService contract). + if (!prefs.shouldRun()) { stopSelf() return START_NOT_STICKY } @@ -134,6 +148,9 @@ class BridgeService : Service() { } override fun onDestroy() { + scope.cancel() + headset.release() + StatusHub.mediaTargetId.value = null contentResolver.unregisterContentObserver(volumeObserver) NativeBridge.setListener(null) NativeBridge.setMicState(false, false) @@ -225,6 +242,11 @@ class BridgeService : Service() { if (fgsType == NOT_FOREGROUND) return setStreamingLocks(status.isStreaming) refreshNotification(force = false) + refreshHeadset() + } + + private fun refreshHeadset() { + headset.update(StatusHub.status.value, prefs.pcs.value, prefs.headset.value) } // region locks diff --git a/android/app/src/main/java/app/audiobridge/BridgeStatus.kt b/android/app/src/main/java/app/audiobridge/BridgeStatus.kt index 9046a2e..a5a7444 100644 --- a/android/app/src/main/java/app/audiobridge/BridgeStatus.kt +++ b/android/app/src/main/java/app/audiobridge/BridgeStatus.kt @@ -27,6 +27,27 @@ enum class PathKind(val wire: String) { } } +/** Playback state of a PC's current media session (statusJson `peers[].media.playback`). */ +enum class PcPlayback(val wire: String) { + /** No media session on the PC, an older PC app, or disconnected. */ + None("none"), + Stopped("stopped"), + Paused("paused"), + Playing("playing"); + + companion object { + fun fromWire(value: String): PcPlayback? = entries.firstOrNull { it.wire == value } + } +} + +/** What a PC's current media session plays; strings are empty while unknown. */ +data class PcMedia( + val playback: PcPlayback = PcPlayback.None, + val app: String = "", + val title: String = "", + val artist: String = "", +) + /** What the UI and the notification show for a connection. */ enum class Reachability { Connecting, Connected, Offline } @@ -55,6 +76,8 @@ data class PeerStatus( /** The PC's default playback device volume in percent (0..100); null while unknown. */ val pcVolume: Int? = null, val pcMuted: Boolean = false, + /** The PC's current media session; headphone buttons control it. */ + val media: PcMedia = PcMedia(), val pcAudio: StreamStats = StreamStats(), val mic: StreamStats = StreamStats(), val error: String? = null, @@ -124,12 +147,23 @@ data class BridgeStatus( micDefault = o.getBoolean("micDefault"), pcVolume = if (o.isNull("pcVolume")) null else o.getInt("pcVolume"), pcMuted = o.getBoolean("pcMuted"), + media = parseMedia(o.getJSONObject("media")), pcAudio = parseStream(o.getJSONObject("pcAudio")), mic = parseStream(o.getJSONObject("mic")), error = o.optStringOrNull("error"), ) } + private fun parseMedia(o: JSONObject): PcMedia { + val wire = o.getString("playback") + return PcMedia( + playback = PcPlayback.fromWire(wire) ?: throw IllegalArgumentException("unknown playback: $wire"), + app = o.getString("app"), + title = o.getString("title"), + artist = o.getString("artist"), + ) + } + private fun parseStream(o: JSONObject) = StreamStats( active = o.getBoolean("active"), bufferMs = o.getDouble("bufferMs").toFloat(), diff --git a/android/app/src/main/java/app/audiobridge/HeadsetControl.kt b/android/app/src/main/java/app/audiobridge/HeadsetControl.kt new file mode 100644 index 0000000..9243373 --- /dev/null +++ b/android/app/src/main/java/app/audiobridge/HeadsetControl.kt @@ -0,0 +1,179 @@ +package app.audiobridge + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.media.AudioManager +import android.media.MediaMetadata +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.PowerManager +import android.os.SystemClock +import android.util.Log +import android.view.KeyEvent + +/** One handled headphone command, shown in the settings so the user can see what the phone receives. */ +data class MediaKeyLog(val group: KeyGroup?, val action: KeyAction, val pcName: String?, val atElapsedMs: Long) + +/** + * Headphone buttons → the music on a PC, through a [MediaSession] owned by [BridgeService] (main thread only). + * + * Android delivers headset media keys (AVRCP play/pause/next/previous) to the media session of the app that played + * audio last, so while PC audio plays through AudioBridge the keys come here, also with the screen off. The session + * exists only while the feature is on and exactly one PC is the target ([MediaTarget]); without a target it is + * released, so the keys go to other players as usual. Its playback state mirrors the target PC, which also tells the + * earbuds whether their next double tap is "pause" or "play". + */ +class HeadsetControl(private val context: Context, private val audio: AudioManager) { + private val target = MediaTarget() + private val main = Handler(Looper.getMainLooper()) + private var session: MediaSession? = null + private var settings = HeadsetSettings() + private var current: PeerStatus? = null + private var publishedPlaying: Boolean? = null + private var publishedMeta: List? = null + + private val wakeLock: PowerManager.WakeLock by lazy { + context.getSystemService(PowerManager::class.java) + .newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "AudioBridge:media-key") + .apply { setReferenceCounted(false) } + } + + private val callback = object : MediaSession.Callback() { + override fun onMediaButtonEvent(mediaButtonIntent: Intent): Boolean { + val event = keyEvent(mediaButtonIntent) ?: return super.onMediaButtonEvent(mediaButtonIntent) + // Fully handled here: the default implementation would turn a double HEADSETHOOK press into "next". + val action = MediaKeys.actionFor(event.keyCode, settings) + ?: return super.onMediaButtonEvent(mediaButtonIntent) + if (MediaKeys.isPress(event.action, event.repeatCount)) { + perform(action, MediaKeys.groupOf(event.keyCode)) + } + return true + } + + // Transport controls (system media controls, Bluetooth "addressed player" play): literal, never remapped. + override fun onPlay() = perform(KeyAction.Pc(NativeBridge.MEDIA_PLAY), null) + override fun onPause() = perform(KeyAction.Pc(NativeBridge.MEDIA_PAUSE), null) + override fun onStop() = perform(KeyAction.Pc(NativeBridge.MEDIA_PAUSE), null) + override fun onSkipToNext() = perform(KeyAction.Pc(NativeBridge.MEDIA_NEXT), null) + override fun onSkipToPrevious() = perform(KeyAction.Pc(NativeBridge.MEDIA_PREVIOUS), null) + } + + /** Re-resolves the target and updates (or creates/releases) the session. */ + fun update(status: BridgeStatus, pcs: List, settings: HeadsetSettings) { + this.settings = settings + target.observe(status) + val t = if (settings.enabled) target.resolve(status, pcs, settings.targetId) else null + current = t + StatusHub.mediaTargetId.value = t?.id + if (t == null) { + release() + return + } + val s = session ?: createSession() + val playing = sessionPlaying(t) + if (playing != publishedPlaying) { + s.setPlaybackState( + PlaybackState.Builder() + .setActions(ACTIONS) + .setState( + if (playing) PlaybackState.STATE_PLAYING else PlaybackState.STATE_PAUSED, + PlaybackState.PLAYBACK_POSITION_UNKNOWN, + if (playing) 1f else 0f, + ) + .build(), + ) + publishedPlaying = playing + } + val title = t.media.title.ifBlank { t.name } + val album = if (t.media.app.isBlank()) t.name else context.getString(R.string.headset_media_source, t.media.app, t.name) + val meta = listOf(title, t.media.artist, album) + if (meta != publishedMeta) { + s.setMetadata( + MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, title) + .putString(MediaMetadata.METADATA_KEY_ARTIST, t.media.artist) + .putString(MediaMetadata.METADATA_KEY_ALBUM, album) + .build(), + ) + publishedMeta = meta + } + if (!s.isActive) s.isActive = true + } + + fun release() { + session?.let { + it.isActive = false + it.release() + Log.i(TAG, "media session released") + } + session = null + publishedPlaying = null + publishedMeta = null + } + + private fun createSession(): MediaSession { + val s = MediaSession(context, "AudioBridge") + s.setCallback(callback, main) + s.setSessionActivity( + PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ), + ) + session = s + Log.i(TAG, "media session created") + return s + } + + private fun perform(action: KeyAction, group: KeyGroup?) { + val t = current + when (action) { + is KeyAction.Pc -> { + if (t == null) { + Log.i(TAG, "media command ${action.command} dropped: no target PC") + return + } + // The system's dispatch wake lock ends when this callback returns; the QUIC write happens right + // after on the native runtime. Keep the CPU up briefly so a screen-off tap is not delayed. + wakeLock.acquire(WAKE_MS) + NativeBridge.controlPc(t.id, NativeBridge.PC_MEDIA, action.command) + target.touch(t.id) + Log.i(TAG, "media command ${action.command} -> ${t.name}") + } + is KeyAction.PhoneVolume -> try { + audio.adjustStreamVolume( + AudioManager.STREAM_MUSIC, + if (action.up) AudioManager.ADJUST_RAISE else AudioManager.ADJUST_LOWER, + AudioManager.FLAG_SHOW_UI, + ) + } catch (e: SecurityException) { + // Do Not Disturb policy may refuse volume changes. + Log.w(TAG, "media volume change refused", e) + } + KeyAction.Ignore -> Unit + } + StatusHub.lastMediaKey.value = MediaKeyLog(group, action, t?.name, SystemClock.elapsedRealtime()) + } + + private fun keyEvent(intent: Intent): KeyEvent? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT) + } + + private companion object { + const val TAG = "AudioBridge" + const val WAKE_MS = 3_000L + const val ACTIONS = PlaybackState.ACTION_PLAY or PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_PLAY_PAUSE or PlaybackState.ACTION_SKIP_TO_NEXT or + PlaybackState.ACTION_SKIP_TO_PREVIOUS or PlaybackState.ACTION_STOP + } +} diff --git a/android/app/src/main/java/app/audiobridge/MainActivity.kt b/android/app/src/main/java/app/audiobridge/MainActivity.kt index 576d7ca..958a94f 100644 --- a/android/app/src/main/java/app/audiobridge/MainActivity.kt +++ b/android/app/src/main/java/app/audiobridge/MainActivity.kt @@ -81,6 +81,10 @@ class MainActivity : ComponentActivity() { val micForeground by StatusHub.micForeground.collectAsStateWithLifecycle() val micEnabled by prefs.micEnabled.collectAsStateWithLifecycle() val autostartDone by prefs.autostartDone.collectAsStateWithLifecycle() + val headset by prefs.headset.collectAsStateWithLifecycle() + val headsetTarget by StatusHub.mediaTargetId.collectAsStateWithLifecycle() + val lastMediaKey by StatusHub.lastMediaKey.collectAsStateWithLifecycle() + val serviceEnabled by prefs.serviceEnabled.collectAsStateWithLifecycle() val version = systemStateVersion.intValue val micPermission = remember(version) { hasPermission(Manifest.permission.RECORD_AUDIO) } @@ -102,6 +106,13 @@ class MainActivity : ComponentActivity() { onMuteAll = ::setAllMuted, onMicToggle = ::setMic, onPcControl = ::controlPc, + headset = headset, + headsetTargetId = headsetTarget, + lastMediaKey = lastMediaKey, + // The service observes the settings and applies them right away. + onHeadsetChange = prefs::setHeadset, + serviceEnabled = serviceEnabled, + onServiceToggle = ::setServiceEnabled, ) } } @@ -166,7 +177,7 @@ class MainActivity : ComponentActivity() { private fun removePc(id: String) { prefs.removePc(id) - if (prefs.pcs.value.isEmpty()) BridgeService.stop(this) else startBridge() + if (!prefs.shouldRun()) BridgeService.stop(this) else startBridge() } private fun setPcMuted(id: String, muted: Boolean) { @@ -179,9 +190,12 @@ class MainActivity : ComponentActivity() { startBridge() } - /** (Re)starts the service so it picks up the current PC list and, being visible, may add the mic FGS type. */ + /** + * (Re)starts the service so it picks up the current PC list and, being visible, may add the mic FGS type. Does + * nothing while the user has switched AudioBridge off or no PC is paired. + */ private fun startBridge() { - if (prefs.pcs.value.isEmpty()) return + if (!prefs.shouldRun()) return try { BridgeService.start(this, fromForeground = true) } catch (e: RuntimeException) { @@ -189,6 +203,12 @@ class MainActivity : ComponentActivity() { } } + /** The global switch: off really stops the service (connections, audio, notification); pairings stay. */ + private fun setServiceEnabled(on: Boolean) { + prefs.setServiceEnabled(on) + if (on) startBridge() else BridgeService.stop(this) + } + // endregion // region mic diff --git a/android/app/src/main/java/app/audiobridge/MediaKeys.kt b/android/app/src/main/java/app/audiobridge/MediaKeys.kt new file mode 100644 index 0000000..bd3f076 --- /dev/null +++ b/android/app/src/main/java/app/audiobridge/MediaKeys.kt @@ -0,0 +1,162 @@ +package app.audiobridge + +import android.view.KeyEvent + +/** + * Headphone buttons → PC media control: the pure part (no Android services), unit-tested on the JVM. + * + * What the phone can actually see: the earbuds turn taps into AVRCP commands in their firmware, and Android's + * Bluetooth stack hands play/pause/next/previous to the media session of the app that played audio last as media + * key events. Tap counts and which earbud was tapped never reach apps. Volume taps don't arrive as key events at + * all: AVRCP volume up/down is not a media-session key, and absolute volume changes the phone's media volume + * directly. So the 1-tap action (volume) cannot be remapped; 2 taps (play/pause) and 3 taps (next/previous) can. + */ + +/** A user-selectable action for the single-earbud mode. [pref] is the persisted value: never rename. */ +enum class TapAction(val pref: String) { + PlayPause("play_pause"), + Next("next"), + Previous("previous"), + VolumeUp("volume_up"), + VolumeDown("volume_down"), + Nothing("none"); + + companion object { + fun fromPref(value: String?, default: TapAction): TapAction = entries.firstOrNull { it.pref == value } ?: default + } +} + +/** Persisted headphone-button settings. */ +data class HeadsetSettings( + /** Headphone buttons control the music on a PC (the media session exists only while this is on). */ + val enabled: Boolean = true, + /** + * Single-earbud mode: the same actions on either earbud. Play/pause (2 taps) and next/previous (3 taps, left or + * right) are treated as "2 taps" and "3 taps" and run [doubleTap] / [tripleTap]. + */ + val singleEarbud: Boolean = false, + val doubleTap: TapAction = TapAction.PlayPause, + val tripleTap: TapAction = TapAction.Next, + /** The PC to control; null = automatic (see [MediaTarget]). */ + val targetId: String? = null, +) + +/** What to do with one headphone command. */ +sealed interface KeyAction { + /** Send a `NativeBridge.MEDIA_*` command to the target PC. */ + data class Pc(val command: Int) : KeyAction + + /** Step the phone's media volume (what you hear in the earbuds) up or down. */ + data class PhoneVolume(val up: Boolean) : KeyAction + + /** A recognized command the user mapped to "nothing": consumed, no effect. */ + data object Ignore : KeyAction +} + +/** The command group a media key belongs to, as far as the earbuds' usual layout goes. */ +enum class KeyGroup { + /** 2 taps on either earbud: the earbuds send play, pause or play/pause depending on what they think plays. */ + PlayPause, + + /** 3 taps: next on one earbud, previous on the other. */ + Track, +} + +object MediaKeys { + fun groupOf(keyCode: Int): KeyGroup? = when (keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, + KeyEvent.KEYCODE_HEADSETHOOK -> KeyGroup.PlayPause + KeyEvent.KEYCODE_MEDIA_NEXT, KeyEvent.KEYCODE_MEDIA_PREVIOUS -> KeyGroup.Track + else -> null + } + + /** + * Maps a headphone media key to an action; null = not a key we handle (left to the framework). + * + * Normal mode keeps the command's meaning: play → play, pause → pause (the earbuds pick them from the state we + * publish, which mirrors the PC), next → next. Single-earbud mode maps by [KeyGroup]. + */ + fun actionFor(keyCode: Int, settings: HeadsetSettings): KeyAction? { + val group = groupOf(keyCode) ?: return null + if (settings.singleEarbud) { + return when (group) { + KeyGroup.PlayPause -> actionOf(settings.doubleTap) + KeyGroup.Track -> actionOf(settings.tripleTap) + } + } + return KeyAction.Pc( + when (keyCode) { + KeyEvent.KEYCODE_MEDIA_PLAY -> NativeBridge.MEDIA_PLAY + KeyEvent.KEYCODE_MEDIA_PAUSE -> NativeBridge.MEDIA_PAUSE + KeyEvent.KEYCODE_MEDIA_NEXT -> NativeBridge.MEDIA_NEXT + KeyEvent.KEYCODE_MEDIA_PREVIOUS -> NativeBridge.MEDIA_PREVIOUS + else -> NativeBridge.MEDIA_PLAY_PAUSE + }, + ) + } + + fun actionOf(tap: TapAction): KeyAction = when (tap) { + TapAction.PlayPause -> KeyAction.Pc(NativeBridge.MEDIA_PLAY_PAUSE) + TapAction.Next -> KeyAction.Pc(NativeBridge.MEDIA_NEXT) + TapAction.Previous -> KeyAction.Pc(NativeBridge.MEDIA_PREVIOUS) + TapAction.VolumeUp -> KeyAction.PhoneVolume(up = true) + TapAction.VolumeDown -> KeyAction.PhoneVolume(up = false) + TapAction.Nothing -> KeyAction.Ignore + } + + /** + * Every press arrives as ACTION_DOWN + ACTION_UP (a held key repeats DOWN). Acting on the first DOWN only makes + * one press exactly one command. + */ + fun isPress(action: Int, repeatCount: Int): Boolean = action == KeyEvent.ACTION_DOWN && repeatCount == 0 +} + +/** + * Which PC the headphone buttons control. Exactly one PC or none: commands never fan out. + * + * A PC chosen in the settings is used only while it is connected. Automatic: among connected PCs that are not muted + * on the phone, the one playing (if several play, the one that started playing last); if none plays, the PC that + * was last playing or controlled; otherwise the only connected one. Anything else is ambiguous: no target. + */ +class MediaTarget { + /** PC ids, most recently started playing or controlled last. */ + private val recent = ArrayList() + private val playing = HashSet() + + /** Records PCs that started playing since the previous status. */ + fun observe(status: BridgeStatus) { + val now = status.peers + .filter { it.state == PeerState.Connected && it.media.playback == PcPlayback.Playing } + .map { it.id } + .toSet() + for (id in now - playing) touch(id) + playing.clear() + playing.addAll(now) + } + + /** The user controlled [id]: it stays the target when nothing plays. */ + fun touch(id: String) { + recent.remove(id) + recent.add(id) + } + + fun resolve(status: BridgeStatus, pcs: List, targetId: String?): PeerStatus? { + val connected = status.peers.filter { it.state == PeerState.Connected } + if (targetId != null && pcs.any { it.id == targetId }) return connected.firstOrNull { it.id == targetId } + val muted = pcs.filter { it.muted }.map { it.id }.toSet() + val candidates = connected.filter { it.id !in muted } + val nowPlaying = candidates.filter { it.media.playback == PcPlayback.Playing } + if (nowPlaying.size == 1) return nowPlaying[0] + if (nowPlaying.size > 1) return nowPlaying.maxBy { recent.indexOf(it.id) } + for (id in recent.asReversed()) candidates.firstOrNull { it.id == id }?.let { return it } + return candidates.singleOrNull() + } +} + +/** The state our media session publishes for [target] (mirrors the PC so the earbuds send the right command). */ +fun sessionPlaying(target: PeerStatus): Boolean = when (target.media.playback) { + PcPlayback.Playing -> true + PcPlayback.Paused, PcPlayback.Stopped -> false + // No media state (no media session on the PC or an old PC app): judge by the audio that arrives. + PcPlayback.None -> target.pcAudio.active +} diff --git a/android/app/src/main/java/app/audiobridge/NativeBridge.kt b/android/app/src/main/java/app/audiobridge/NativeBridge.kt index 59a813d..0650d5d 100644 --- a/android/app/src/main/java/app/audiobridge/NativeBridge.kt +++ b/android/app/src/main/java/app/audiobridge/NativeBridge.kt @@ -29,7 +29,8 @@ object NativeBridge { /** * 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. + * switches, 0..100 for [PC_VOLUME] and a `MEDIA_*` command for [PC_MEDIA]. Dropped (logged) if that PC is not + * connected. */ external fun controlPc(peerId: String, action: Int, value: Int) @@ -49,4 +50,13 @@ object NativeBridge { const val PC_MIC_DEFAULT = 2 const val PC_VOLUME = 3 const val PC_MUTE = 4 + const val PC_MEDIA = 5 + + // [PC_MEDIA] commands; keep in sync with `MediaCommand::code` in crates/core/src/proto.rs. + const val MEDIA_PLAY = 0 + const val MEDIA_PAUSE = 1 + /** Pause if the PC plays, otherwise play (decided by the PC from its real state). */ + const val MEDIA_PLAY_PAUSE = 2 + const val MEDIA_NEXT = 3 + const val MEDIA_PREVIOUS = 4 } diff --git a/android/app/src/main/java/app/audiobridge/ui/HeadsetCard.kt b/android/app/src/main/java/app/audiobridge/ui/HeadsetCard.kt new file mode 100644 index 0000000..f84e93d --- /dev/null +++ b/android/app/src/main/java/app/audiobridge/ui/HeadsetCard.kt @@ -0,0 +1,258 @@ +package app.audiobridge.ui + +import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import app.audiobridge.BridgeStatus +import app.audiobridge.HeadsetSettings +import app.audiobridge.KeyAction +import app.audiobridge.MediaKeyLog +import app.audiobridge.NativeBridge +import app.audiobridge.PairedPc +import app.audiobridge.PcPlayback +import app.audiobridge.PeerState +import app.audiobridge.R +import app.audiobridge.TapAction + +/** Headphone buttons → PC media control: on/off, which PC, and the single-earbud mapping. */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun HeadsetCard( + settings: HeadsetSettings, + pcs: List, + status: BridgeStatus, + targetId: String?, + lastKey: MediaKeyLog?, + onChange: (HeadsetSettings) -> Unit, +) { + val target = targetId?.let { status.peer(it) } + val subtitle = when { + !settings.enabled -> stringResource(R.string.headset_off) + target == null && status.connectedPeers.size > 1 -> stringResource(R.string.headset_no_target_many) + target == null -> stringResource(R.string.headset_no_target) + target.media.app.isNotBlank() -> stringResource( + R.string.headset_target_media, + target.name, + target.media.app + when (target.media.playback) { + PcPlayback.Playing -> " · " + stringResource(R.string.headset_playing) + PcPlayback.Paused -> " · " + stringResource(R.string.headset_paused) + else -> "" + }, + ) + else -> stringResource(R.string.headset_target, target.name) + } + val active = settings.enabled && target != null + Card( + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable(value = settings.enabled, role = Role.Switch, onValueChange = { onChange(settings.copy(enabled = it)) }) + .padding(horizontal = 20.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + Modifier.size(48.dp).clip(CircleShape).background( + if (active) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.secondaryContainer, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + painterResource(R.drawable.ic_headphones), + contentDescription = null, + tint = if (active) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + Spacer(Modifier.width(16.dp)) + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.headset_title), style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(2.dp)) + Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Spacer(Modifier.width(12.dp)) + Switch(checked = settings.enabled, onCheckedChange = null) + } + if (!settings.enabled) return@Card + Column(Modifier.padding(start = 20.dp, end = 20.dp, bottom = 18.dp)) { + if (pcs.size > 1) { + Text(stringResource(R.string.headset_pc_choice), style = MaterialTheme.typography.labelLarge) + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + val chosen = settings.targetId?.takeIf { id -> pcs.any { it.id == id } } + FilterChip( + selected = chosen == null, + onClick = { onChange(settings.copy(targetId = null)) }, + label = { Text(stringResource(R.string.headset_pc_auto)) }, + ) + for (pc in pcs) { + val online = status.peer(pc.id)?.state == PeerState.Connected + FilterChip( + selected = chosen == pc.id, + onClick = { onChange(settings.copy(targetId = pc.id)) }, + label = { Text(pc.name) }, + enabled = online || chosen == pc.id, + ) + } + } + if (settings.targetId == null) Note(stringResource(R.string.headset_pc_auto_hint)) + Spacer(Modifier.height(8.dp)) + } + if (!settings.singleEarbud) Note(stringResource(R.string.headset_default_layout)) + HorizontalDivider(Modifier.padding(vertical = 8.dp)) + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable( + value = settings.singleEarbud, + role = Role.Switch, + onValueChange = { onChange(settings.copy(singleEarbud = it)) }, + ) + .padding(vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(stringResource(R.string.headset_single), style = MaterialTheme.typography.bodyLarge) + Text( + stringResource(R.string.headset_single_desc), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(12.dp)) + Switch(checked = settings.singleEarbud, onCheckedChange = null) + } + if (settings.singleEarbud) { + TapRow(stringResource(R.string.headset_tap_1)) { + Text( + stringResource(R.string.headset_tap_1_fixed), + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.End, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TapRow(stringResource(R.string.headset_tap_2)) { + TapPicker(settings.doubleTap) { onChange(settings.copy(doubleTap = it)) } + } + TapRow(stringResource(R.string.headset_tap_3)) { + TapPicker(settings.tripleTap) { onChange(settings.copy(tripleTap = it)) } + } + Note(stringResource(R.string.headset_limits)) + } + Spacer(Modifier.height(8.dp)) + Text(lastKeyText(lastKey), style = MaterialTheme.typography.bodyMedium) + Note(stringResource(R.string.headset_android_note)) + } + } +} + +@Composable +private fun Note(text: String) { + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 4.dp), + ) +} + +@Composable +private fun TapRow(title: String, value: @Composable () -> Unit) { + Row(Modifier.fillMaxWidth().padding(vertical = 2.dp), verticalAlignment = Alignment.CenterVertically) { + Text(title, style = MaterialTheme.typography.bodyLarge, maxLines = 1, softWrap = false) + Spacer(Modifier.width(12.dp)) + Box(Modifier.weight(1f), contentAlignment = Alignment.CenterEnd) { value() } + } +} + +@Composable +private fun TapPicker(value: TapAction, onPick: (TapAction) -> Unit) { + var open by remember { mutableStateOf(false) } + Box { + TextButton(onClick = { open = true }) { Text(stringResource(tapLabel(value))) } + DropdownMenu(expanded = open, onDismissRequest = { open = false }) { + for (a in TapAction.entries) { + DropdownMenuItem( + text = { Text(stringResource(tapLabel(a))) }, + onClick = { + open = false + onPick(a) + }, + ) + } + } + } +} + +@StringRes +fun tapLabel(a: TapAction): Int = when (a) { + TapAction.PlayPause -> R.string.tap_play_pause + TapAction.Next -> R.string.tap_next + TapAction.Previous -> R.string.tap_previous + TapAction.VolumeUp -> R.string.tap_volume_up + TapAction.VolumeDown -> R.string.tap_volume_down + TapAction.Nothing -> R.string.tap_nothing +} + +@StringRes +private fun actionLabel(a: KeyAction): Int = when (a) { + is KeyAction.Pc -> when (a.command) { + NativeBridge.MEDIA_PLAY -> R.string.cmd_play + NativeBridge.MEDIA_PAUSE -> R.string.cmd_pause + NativeBridge.MEDIA_NEXT -> R.string.tap_next + NativeBridge.MEDIA_PREVIOUS -> R.string.tap_previous + else -> R.string.tap_play_pause + } + is KeyAction.PhoneVolume -> if (a.up) R.string.tap_volume_up else R.string.tap_volume_down + KeyAction.Ignore -> R.string.tap_nothing +} + +@Composable +private fun lastKeyText(log: MediaKeyLog?): String { + if (log == null) return stringResource(R.string.headset_last_none) + val what = stringResource(actionLabel(log.action)) + return if (log.action is KeyAction.Pc && log.pcName != null) { + stringResource(R.string.headset_last_to, what, log.pcName) + } else { + stringResource(R.string.headset_last, what) + } +} 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 d36aa39..faf34d5 100644 --- a/android/app/src/main/java/app/audiobridge/ui/MainScreen.kt +++ b/android/app/src/main/java/app/audiobridge/ui/MainScreen.kt @@ -70,10 +70,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics 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.HeadsetSettings +import app.audiobridge.MediaKeyLog import app.audiobridge.NativeBridge import app.audiobridge.PairedPc import app.audiobridge.PeerStatus @@ -122,6 +126,12 @@ fun MainScreen( onMicToggle: (Boolean) -> Unit, /** Remote control of a connected PC: `NativeBridge.PC_*` action and its value. */ onPcControl: (pcId: String, action: Int, value: Int) -> Unit, + headset: HeadsetSettings, + headsetTargetId: String?, + lastMediaKey: MediaKeyLog?, + onHeadsetChange: (HeadsetSettings) -> Unit, + serviceEnabled: Boolean, + onServiceToggle: (Boolean) -> Unit, ) { var removeId by rememberSaveable { mutableStateOf(null) } val rows = pcs.map { PcRow(it, status.peer(it.id)) } @@ -133,6 +143,22 @@ fun MainScreen( TopAppBar( title = { Text(stringResource(R.string.app_name), style = MaterialTheme.typography.titleLarge) }, colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + actions = { + if (rows.isNotEmpty()) { + val desc = stringResource(R.string.service_switch_desc) + Text( + stringResource(if (serviceEnabled) R.string.service_on else R.string.service_off), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(8.dp)) + Switch( + checked = serviceEnabled, + onCheckedChange = onServiceToggle, + modifier = Modifier.padding(end = 12.dp).semantics { contentDescription = desc }, + ) + } + }, ) }, ) { padding -> @@ -146,9 +172,12 @@ fun MainScreen( ) { if (rows.isEmpty()) { Onboarding(onAddPc) + } else if (!serviceEnabled) { + ServiceOffCard(pcCount = rows.size, onTurnOn = { onServiceToggle(true) }) } else { OverviewCard(rows, status, onMuteAll) MicCard(rows, status, mic, onMicToggle) + HeadsetCard(headset, pcs, status, headsetTargetId, lastMediaKey, onHeadsetChange) SectionHeader(stringResource(R.string.pcs_title), rows.size) rows.forEach { row -> key(row.pc.id) { @@ -189,6 +218,28 @@ fun MainScreen( } } +/** AudioBridge is switched off: nothing runs, pairings and settings are kept. */ +@Composable +private fun ServiceOffCard(pcCount: Int, onTurnOn: () -> Unit) { + Card( + shape = RoundedCornerShape(28.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text(stringResource(R.string.service_off_title), style = MaterialTheme.typography.headlineSmall) + Text( + stringResource(R.string.service_off_body, pcCount), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onTurnOn, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.service_turn_on)) + } + } + } +} + @Composable private fun Onboarding(onScan: () -> Unit) { Column( diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 21e2922..e2243e7 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -82,6 +82,48 @@ Без разрешения микрофон не работает Настройки + + Включено + Выключено + AudioBridge на телефоне + AudioBridge выключен + Телефон не подключается к компьютерам, звук и микрофон не передаются, фоновой службы и уведомления нет. После перезагрузки AudioBridge тоже не включится сам. Компьютеры (%1$d) и настройки сохранены — компьютеры просто увидят, что телефон не в сети. + Включить AudioBridge + + + Кнопки наушников управляют ПК + Выключено: кнопки работают как обычно на телефоне + Нет компьютера для управления + Несколько ПК: выберите, каким управлять, или включите музыку на одном + Управляют: %1$s + Управляют: %1$s · %2$s + играет + пауза + %1$s на %2$s + Какой компьютер + Авто + Авто: тот, где играет музыка; если нигде — последний, которым управляли + Два касания — пауза / продолжить, три — предыдущий / следующий трек на ПК. Одно касание меняет громкость телефона, то есть громкость звука ПК в наушниках. + Один наушник + Одинаковые действия на левом и правом + 1 касание + 2 касания + 3 касания + Громкость, как задано в наушнике + Наушник сам превращает касания в команды и сообщает телефону только «играть / пауза» и «следующий / предыдущий», без числа касаний и стороны. Одно касание он отправляет как громкость: Android меняет громкость телефона, не сообщая приложениям, поэтому его переназначить нельзя. Если ваша модель раскладывает касания иначе, смотрите строку «Последняя команда». + Последняя команда: %1$s + Последняя команда: %1$s → %2$s + Последняя команда: ещё не было + Android отдаёт кнопки приложению, которое последним играло звук. Если после музыки на телефоне кнопки снова управляют им, дождитесь звука с ПК в AudioBridge. + Пауза / продолжить + Следующий трек + Предыдущий трек + Громкость + + Громкость − + Ничего + Играть + Пауза + Чтобы работало в фоне Уведомления diff --git a/android/app/src/test/java/app/audiobridge/BridgeStatusTest.kt b/android/app/src/test/java/app/audiobridge/BridgeStatusTest.kt index ab535bb..eaf6bd4 100644 --- a/android/app/src/test/java/app/audiobridge/BridgeStatusTest.kt +++ b/android/app/src/test/java/app/audiobridge/BridgeStatusTest.kt @@ -21,11 +21,12 @@ class BridgeStatusTest { micDefault: Boolean = false, pcVolume: String = "64", pcMuted: Boolean = false, + media: String = """{"playback":"playing","app":"Яндекс Музыка","title":"Танцуй!","artist":"SATS"}""", error: String = "null", ) = """ {"id":"$id","name":"$name","state":"$state","path":$path,"rttMs":$rtt,"pcAudioEnabled":true, "micEnabled":$micEnabled,"micDemanded":$micDemanded,"pcMic":$pcMic,"micDefault":$micDefault, - "pcVolume":$pcVolume,"pcMuted":$pcMuted, + "pcVolume":$pcVolume,"pcMuted":$pcMuted,"media":$media, "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} @@ -79,6 +80,21 @@ class BridgeStatusTest { assertFalse(unknown.pcMuted) } + @Test + fun parsesPcMedia() { + val p = BridgeStatus.parse(hub()).peers[0] + assertEquals(PcMedia(PcPlayback.Playing, "Яндекс Музыка", "Танцуй!", "SATS"), p.media) + for (playback in PcPlayback.entries) { + val media = """{"playback":"${playback.wire}","app":"","title":"","artist":""}""" + assertEquals(PcMedia(playback), BridgeStatus.parse(hub(peers = listOf(peer(media = media)))).peers[0].media) + } + } + + @Test(expected = IllegalArgumentException::class) + fun unknownPlaybackIsRejected() { + BridgeStatus.parse(hub(peers = listOf(peer(media = """{"playback":"buffering","app":"","title":"","artist":""}""")))) + } + @Test fun idleHubWithNoPeers() { val s = BridgeStatus.parse(hub(state = "idle", pcAudioActive = false, peers = emptyList())) diff --git a/android/app/src/test/java/app/audiobridge/MediaKeysTest.kt b/android/app/src/test/java/app/audiobridge/MediaKeysTest.kt new file mode 100644 index 0000000..731c876 --- /dev/null +++ b/android/app/src/test/java/app/audiobridge/MediaKeysTest.kt @@ -0,0 +1,195 @@ +package app.audiobridge + +import android.content.SharedPreferences +import android.view.KeyEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class MediaKeysTest { + private val normal = HeadsetSettings() + private val single = HeadsetSettings(singleEarbud = true, doubleTap = TapAction.PlayPause, tripleTap = TapAction.Next) + + private fun pc(cmd: Int) = KeyAction.Pc(cmd) + + @Test + fun normalModeKeepsTheMeaningOfEachCommand() { + val cases = mapOf( + KeyEvent.KEYCODE_MEDIA_PLAY to pc(NativeBridge.MEDIA_PLAY), + KeyEvent.KEYCODE_MEDIA_PAUSE to pc(NativeBridge.MEDIA_PAUSE), + KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE to pc(NativeBridge.MEDIA_PLAY_PAUSE), + KeyEvent.KEYCODE_HEADSETHOOK to pc(NativeBridge.MEDIA_PLAY_PAUSE), + KeyEvent.KEYCODE_MEDIA_NEXT to pc(NativeBridge.MEDIA_NEXT), + KeyEvent.KEYCODE_MEDIA_PREVIOUS to pc(NativeBridge.MEDIA_PREVIOUS), + ) + cases.forEach { (key, action) -> assertEquals("key $key", action, MediaKeys.actionFor(key, normal)) } + } + + @Test + fun volumeAndOtherKeysAreNotHandled() { + // Volume never reaches a media session as a key; if it did, it must stay with the system. + for (key in listOf( + KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_MEDIA_STOP, + KeyEvent.KEYCODE_MEDIA_FAST_FORWARD, KeyEvent.KEYCODE_ENTER, + )) { + assertNull("key $key", MediaKeys.actionFor(key, normal)) + assertNull("key $key", MediaKeys.actionFor(key, single)) + } + } + + @Test + fun singleEarbudGivesBothSidesTheSameActions() { + // Left and right triple taps (previous / next) both run the 3-tap action. + assertEquals(pc(NativeBridge.MEDIA_NEXT), MediaKeys.actionFor(KeyEvent.KEYCODE_MEDIA_PREVIOUS, single)) + assertEquals(pc(NativeBridge.MEDIA_NEXT), MediaKeys.actionFor(KeyEvent.KEYCODE_MEDIA_NEXT, single)) + // A double tap arrives as play or pause depending on what the earbud thinks: both toggle on the PC, + // which decides from its real state. + for (key in listOf(KeyEvent.KEYCODE_MEDIA_PLAY, KeyEvent.KEYCODE_MEDIA_PAUSE, KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE)) { + assertEquals(pc(NativeBridge.MEDIA_PLAY_PAUSE), MediaKeys.actionFor(key, single)) + } + } + + @Test + fun singleEarbudRunsTheChosenActions() { + val s = single.copy(doubleTap = TapAction.VolumeUp, tripleTap = TapAction.VolumeDown) + assertEquals(KeyAction.PhoneVolume(up = true), MediaKeys.actionFor(KeyEvent.KEYCODE_MEDIA_PAUSE, s)) + assertEquals(KeyAction.PhoneVolume(up = false), MediaKeys.actionFor(KeyEvent.KEYCODE_MEDIA_NEXT, s)) + val none = single.copy(doubleTap = TapAction.Nothing, tripleTap = TapAction.Previous) + assertEquals(KeyAction.Ignore, MediaKeys.actionFor(KeyEvent.KEYCODE_MEDIA_PLAY, none)) + assertEquals(pc(NativeBridge.MEDIA_PREVIOUS), MediaKeys.actionFor(KeyEvent.KEYCODE_MEDIA_NEXT, none)) + } + + @Test + fun onePressIsExactlyOneCommand() { + // DOWN, repeated DOWNs of a held key, UP: only the first DOWN acts. + val events = listOf(KeyEvent.ACTION_DOWN to 0, KeyEvent.ACTION_DOWN to 1, KeyEvent.ACTION_DOWN to 2, KeyEvent.ACTION_UP to 0) + assertEquals(1, events.count { (action, repeat) -> MediaKeys.isPress(action, repeat) }) + } + + @Test + fun mediaCommandCodesMatchTheNativeContract() { + // crates/core/src/proto.rs MediaCommand::code + assertEquals(listOf(0, 1, 2, 3, 4), listOf( + NativeBridge.MEDIA_PLAY, NativeBridge.MEDIA_PAUSE, NativeBridge.MEDIA_PLAY_PAUSE, + NativeBridge.MEDIA_NEXT, NativeBridge.MEDIA_PREVIOUS, + )) + assertEquals(5, NativeBridge.PC_MEDIA) + } + + // region target + + private fun peer(id: String, state: PeerState = PeerState.Connected, playback: PcPlayback = PcPlayback.Paused, audio: Boolean = false) = + PeerStatus(id = id, name = id.uppercase(), state = state, media = PcMedia(playback), pcAudio = StreamStats(active = audio)) + + private fun status(vararg peers: PeerStatus) = BridgeStatus(running = true, peers = peers.toList()) + + private fun paired(vararg ids: String, muted: Set = emptySet()) = + ids.map { PairedPc(it, it.uppercase(), "audiobridge://pair?d=$it", muted = it in muted) } + + private fun MediaTarget.at(s: BridgeStatus, pcs: List, choice: String? = null): String? { + observe(s) + return resolve(s, pcs, choice)?.id + } + + @Test + fun autoPicksThePlayingPc() { + val t = MediaTarget() + assertEquals("b", t.at(status(peer("a"), peer("b", playback = PcPlayback.Playing)), paired("a", "b"))) + } + + @Test + fun severalPlayingPicksTheOneThatStartedLast() { + val t = MediaTarget() + val pcs = paired("a", "b") + assertEquals("a", t.at(status(peer("a", playback = PcPlayback.Playing), peer("b")), pcs)) + assertEquals( + "b", + t.at(status(peer("a", playback = PcPlayback.Playing), peer("b", playback = PcPlayback.Playing)), pcs), + ) + } + + @Test + fun whenNothingPlaysTheLastPlayingOrControlledPcStays() { + val t = MediaTarget() + val pcs = paired("a", "b") + t.at(status(peer("a"), peer("b", playback = PcPlayback.Playing)), pcs) + // paused with a double tap: the next double tap must resume the same PC + assertEquals("b", t.at(status(peer("a"), peer("b")), pcs)) + t.touch("a") + assertEquals("a", t.at(status(peer("a"), peer("b")), pcs)) + } + + @Test + fun ambiguityMeansNoTargetInsteadOfFanOut() { + assertNull(MediaTarget().at(status(peer("a"), peer("b")), paired("a", "b"))) + // a single connected PC needs no history + assertEquals("a", MediaTarget().at(status(peer("a"), peer("b", state = PeerState.Reconnecting)), paired("a", "b"))) + } + + @Test + fun mutedAndDisconnectedPcsAreNotAutoTargets() { + val t = MediaTarget() + val s = status(peer("a", playback = PcPlayback.Playing), peer("b")) + assertEquals("b", t.at(s, paired("a", "b", muted = setOf("a")))) + assertNull(MediaTarget().at(status(peer("a", state = PeerState.Reconnecting, playback = PcPlayback.Playing)), paired("a"))) + } + + @Test + fun chosenPcIsUsedOnlyWhileConnected() { + val pcs = paired("a", "b") + val playingA = status(peer("a", playback = PcPlayback.Playing), peer("b")) + assertEquals("b", MediaTarget().at(playingA, pcs, choice = "b")) + // the chosen PC is offline: no silent fallback to another PC + assertNull(MediaTarget().at(status(peer("a", playback = PcPlayback.Playing), peer("b", state = PeerState.Connecting)), pcs, choice = "b")) + // a choice that is no longer paired means automatic + assertEquals("a", MediaTarget().at(playingA, pcs, choice = "gone")) + } + + @Test + fun publishedStateMirrorsThePc() { + assertTrue(sessionPlaying(peer("a", playback = PcPlayback.Playing))) + assertFalse(sessionPlaying(peer("a", playback = PcPlayback.Paused, audio = true))) + assertFalse(sessionPlaying(peer("a", playback = PcPlayback.Stopped))) + // no media state from the PC: judge by the arriving audio + assertTrue(sessionPlaying(peer("a", playback = PcPlayback.None, audio = true))) + assertFalse(sessionPlaying(peer("a", playback = PcPlayback.None, audio = false))) + } + + // endregion + + // region persistence + + @Test + fun settingsSurviveARoundTrip() { + val sp = MemoryPrefs() + assertEquals(HeadsetSettings(), Prefs.readHeadset(sp)) + val custom = HeadsetSettings( + enabled = false, + singleEarbud = true, + doubleTap = TapAction.VolumeDown, + tripleTap = TapAction.Previous, + targetId = "pc-1", + ) + sp.edit().also { Prefs.writeHeadset(it, custom) }.apply() + assertEquals(custom, Prefs.readHeadset(sp)) + sp.edit().also { Prefs.writeHeadset(it, custom.copy(targetId = null)) }.apply() + assertNull(Prefs.readHeadset(sp).targetId) + } + + @Test + fun persistedValuesAreStableAndUnknownOnesFallBack() { + assertEquals( + listOf("play_pause", "next", "previous", "volume_up", "volume_down", "none"), + TapAction.entries.map { it.pref }, + ) + val sp = MemoryPrefs() + sp.edit().putString("headset_double_tap", "rewind").putString("headset_triple_tap", "volume_up").apply() + val s = Prefs.readHeadset(sp) + assertEquals(TapAction.PlayPause, s.doubleTap) + assertEquals(TapAction.VolumeUp, s.tripleTap) + } + + // endregion +} diff --git a/android/app/src/test/java/app/audiobridge/MemoryPrefs.kt b/android/app/src/test/java/app/audiobridge/MemoryPrefs.kt new file mode 100644 index 0000000..5cb7225 --- /dev/null +++ b/android/app/src/test/java/app/audiobridge/MemoryPrefs.kt @@ -0,0 +1,40 @@ +package app.audiobridge + +import android.content.SharedPreferences + +/** In-memory [SharedPreferences] (the JVM tests have no Android runtime). */ +class MemoryPrefs : SharedPreferences { + val map = HashMap() + + override fun getAll(): Map = map + override fun getString(key: String, defValue: String?) = map[key] as String? ?: defValue + override fun getStringSet(key: String, defValues: Set?) = defValues + override fun getInt(key: String, defValue: Int) = map[key] as Int? ?: defValue + override fun getLong(key: String, defValue: Long) = map[key] as Long? ?: defValue + override fun getFloat(key: String, defValue: Float) = map[key] as Float? ?: defValue + override fun getBoolean(key: String, defValue: Boolean) = map[key] as Boolean? ?: defValue + override fun contains(key: String) = key in map + override fun registerOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener) = Unit + override fun unregisterOnSharedPreferenceChangeListener(l: SharedPreferences.OnSharedPreferenceChangeListener) = Unit + + override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor { + val pending = HashMap() + val removed = HashSet() + override fun putString(key: String, value: String?) = apply { pending[key] = value } + override fun putStringSet(key: String, values: Set?) = apply { pending[key] = values } + override fun putInt(key: String, value: Int) = apply { pending[key] = value } + override fun putLong(key: String, value: Long) = apply { pending[key] = value } + override fun putFloat(key: String, value: Float) = apply { pending[key] = value } + override fun putBoolean(key: String, value: Boolean) = apply { pending[key] = value } + override fun remove(key: String) = apply { removed.add(key) } + override fun clear() = apply { removed.addAll(map.keys) } + override fun commit(): Boolean { + apply() + return true + } + override fun apply() { + removed.forEach { map.remove(it) } + map.putAll(pending) + } + } +} diff --git a/android/app/src/test/java/app/audiobridge/ServiceSwitchTest.kt b/android/app/src/test/java/app/audiobridge/ServiceSwitchTest.kt new file mode 100644 index 0000000..9cabc67 --- /dev/null +++ b/android/app/src/test/java/app/audiobridge/ServiceSwitchTest.kt @@ -0,0 +1,42 @@ +package app.audiobridge + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ServiceSwitchTest { + @Test + fun installsFromBeforeTheSwitchStayOn() { + assertTrue(Prefs.readServiceEnabled(MemoryPrefs())) + } + + @Test + fun offSurvivesARestartAndOnComesBack() { + val sp = MemoryPrefs() + sp.edit().also { Prefs.writeServiceEnabled(it, false) }.apply() + // what BootReceiver / a sticky restart reads after a reboot + assertFalse(Prefs.readServiceEnabled(sp)) + sp.edit().also { Prefs.writeServiceEnabled(it, true) }.apply() + assertTrue(Prefs.readServiceEnabled(sp)) + } + + @Test + fun switchingOffKeepsPairingsAndSettings() { + val sp = MemoryPrefs() + sp.edit().putString("paired_pcs", "[...]").putBoolean("mic_enabled", false).apply() + val headset = HeadsetSettings(singleEarbud = true, tripleTap = TapAction.Previous) + sp.edit().also { Prefs.writeHeadset(it, headset) }.apply() + sp.edit().also { Prefs.writeServiceEnabled(it, false) }.apply() + assertEquals("[...]", sp.getString("paired_pcs", null)) + assertFalse(sp.getBoolean("mic_enabled", true)) + assertEquals(headset, Prefs.readHeadset(sp)) + } + + @Test + fun runsOnlyWhenSwitchedOnWithAPairedPc() { + assertTrue(Prefs.shouldRun(serviceEnabled = true, pairedPcs = 1)) + assertFalse(Prefs.shouldRun(serviceEnabled = false, pairedPcs = 3)) + assertFalse(Prefs.shouldRun(serviceEnabled = true, pairedPcs = 0)) + } +} diff --git a/crates/android-native/src/android/jni_api.rs b/crates/android-native/src/android/jni_api.rs index 56397db..8249b97 100644 --- a/crates/android-native/src/android/jni_api.rs +++ b/crates/android-native/src/android/jni_api.rs @@ -212,7 +212,8 @@ pub extern "system" fn Java_app_audiobridge_NativeBridge_setVolume<'l>( } /// 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. +/// `0..=100` for the volume, a media command code for `PC_MEDIA` (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>, @@ -226,7 +227,7 @@ pub extern "system" fn Java_app_audiobridge_NativeBridge_controlPc<'l>( 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}"), + None => log::warn!("controlPc: unknown action {action} / value {value}"), } }) } diff --git a/crates/android-native/src/controls.rs b/crates/android-native/src/controls.rs index fbc9996..411fb6e 100644 --- a/crates/android-native/src/controls.rs +++ b/crates/android-native/src/controls.rs @@ -2,7 +2,7 @@ //! to core types. use audiobridge_core::proto::MAX_LEVEL; -use audiobridge_core::session::PcRequest; +use audiobridge_core::session::{MediaCommand, PcRequest}; // `controlPc` actions; keep in sync with the `NativeBridge.PC_*` constants. pub const PC_AUDIO: i32 = 0; @@ -10,9 +10,11 @@ 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; +/// `value` is a media command code (`NativeBridge.MEDIA_*` = [`MediaCommand::code`]). +pub const PC_MEDIA: i32 = 5; /// `controlPc(peerId, action, value)`: switches are on for any non-zero `value`, the volume is clamped to -/// `0..=100`. `None` for an unknown action. +/// `0..=100`. `None` for an unknown action or media command. pub fn pc_request(action: i32, value: i32) -> Option { let on = value != 0; Some(match action { @@ -21,6 +23,7 @@ pub fn pc_request(action: i32, value: i32) -> Option { PC_MIC_DEFAULT => PcRequest::MicDefault(on), PC_VOLUME => PcRequest::Volume(clamp_level(value)), PC_MUTE => PcRequest::Mute(on), + PC_MEDIA => PcRequest::Media(MediaCommand::from_code(u8::try_from(value).ok()?)?), _ => return None, }) } @@ -68,11 +71,29 @@ mod tests { #[test] fn unknown_action_is_none() { - for action in [-1, 5, i32::MIN, i32::MAX] { + for action in [-1, 6, i32::MIN, i32::MAX] { assert_eq!(pc_request(action, 1), None, "action {action}"); } } + #[test] + fn media_actions_map_to_commands() { + // the Kotlin side sends NativeBridge.MEDIA_PLAY..MEDIA_PREVIOUS = 0..4 + let cases = [ + (0, MediaCommand::Play), + (1, MediaCommand::Pause), + (2, MediaCommand::PlayPause), + (3, MediaCommand::Next), + (4, MediaCommand::Previous), + ]; + for (value, cmd) in cases { + assert_eq!(pc_request(PC_MEDIA, value), Some(PcRequest::Media(cmd)), "value {value}"); + } + for value in [-1, 5, 256, i32::MAX] { + assert_eq!(pc_request(PC_MEDIA, value), None, "value {value}"); + } + } + #[test] fn phone_volume_percent_maps_unknown_and_clamps() { let cases = [ diff --git a/crates/android-native/src/status.rs b/crates/android-native/src/status.rs index 3b3724f..b60d817 100644 --- a/crates/android-native/src/status.rs +++ b/crates/android-native/src/status.rs @@ -2,16 +2,18 @@ //! //! Schema (all keys always present): //! `{"state","micEnabled","micCapturing","micWanted","pcAudioActive","peers":[{"id","name","state","path","rttMs", -//! "pcAudioEnabled","micEnabled","micDemanded","pcMic","micDefault","pcVolume","pcMuted","pcAudio":{...},"mic":{...}, -//! "error"}]}` +//! "pcAudioEnabled","micEnabled","micDemanded","pcMic","micDefault","pcVolume","pcMuted","media":{...},"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. +//! `media` = `{"playback","app","title","artist"}`: the PC's current media session; `playback` is one of +//! `none|stopped|paused|playing` (`none` also while disconnected), strings are empty while unknown. use std::time::{Duration, Instant}; -use audiobridge_core::session::{ConnState, HubStatus, PathKind, Status, StreamStats}; +use audiobridge_core::session::{ConnState, HubStatus, PathKind, PcMedia, Playback, Status, StreamStats}; use serde::Serialize; /// Minimum spacing between two listener calls. @@ -43,6 +45,35 @@ impl StreamView { } } +#[derive(Clone, Debug, Default, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MediaView { + pub playback: &'static str, + pub app: String, + pub title: String, + pub artist: String, +} + +impl MediaView { + fn none() -> Self { + MediaView::from_media(&PcMedia::default()) + } + + fn from_media(m: &PcMedia) -> Self { + MediaView { + playback: match m.playback { + Playback::None => "none", + Playback::Stopped => "stopped", + Playback::Paused => "paused", + Playback::Playing => "playing", + }, + app: m.app.clone(), + title: m.title.clone(), + artist: m.artist.clone(), + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct PeerView { @@ -61,6 +92,8 @@ pub struct PeerView { /// The PC's default playback device volume in percent; `None` while unknown. pub pc_volume: Option, pub pc_muted: bool, + /// The PC's current media session (headphone buttons control it). + pub media: MediaView, pub pc_audio: StreamView, pub mic: StreamView, pub error: Option, @@ -82,6 +115,7 @@ impl PeerView { 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), + media: MediaView::from_media(&s.media), pc_audio: StreamView::from_stats(&s.pc_audio), mic: StreamView::from_stats(&s.mic), error: s.last_error.clone(), @@ -103,6 +137,7 @@ impl PeerView { mic_default: false, pc_volume: None, pc_muted: false, + media: MediaView::none(), pc_audio: StreamView::IDLE, mic: StreamView::IDLE, error, @@ -122,6 +157,7 @@ impl PeerView { && self.mic_default == o.mic_default && self.pc_volume == o.pc_volume && self.pc_muted == o.pc_muted + && self.media == o.media && self.pc_audio.active == o.pc_audio.active && self.mic.active == o.mic.active && self.error == o.error @@ -314,7 +350,7 @@ impl NotifyGate { #[cfg(test)] mod tests { use super::*; - use audiobridge_core::session::{PcControls, PeerStatus, Volume}; + use audiobridge_core::session::{PcControls, PcMedia, PeerStatus, Playback, Volume}; fn stats(active: bool, buffer_ms: f32, kbps: f32) -> StreamStats { StreamStats { active, buffer_ms, underruns: 3, lost_packets: 7, kbps } @@ -333,6 +369,12 @@ mod tests { 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, + media: PcMedia { + playback: Playback::Playing, + app: "Яндекс Музыка".into(), + title: "Танцуй!".into(), + artist: "SATS".into(), + }, last_error: None, } } @@ -376,6 +418,7 @@ mod tests { "micDefault": false, "pcVolume": 64, "pcMuted": false, + "media": {"playback": "playing", "app": "Яндекс Музыка", "title": "Танцуй!", "artist": "SATS"}, "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 @@ -435,6 +478,7 @@ mod tests { assert_eq!(peers[1]["error"], "bind failed"); assert!(peers[0]["path"].is_null() && peers[0]["rttMs"].is_null()); assert_eq!(peers[0]["pcAudio"]["active"], false); + assert_eq!(peers[0]["media"], serde_json::json!({"playback": "none", "app": "", "title": "", "artist": ""})); } #[test] @@ -465,6 +509,20 @@ mod tests { assert_eq!(PeerView::from_status("a", "", &s).path, None); } + #[test] + fn playback_names() { + let mut s = connected(None); + for (playback, name) in [ + (Playback::None, "none"), + (Playback::Stopped, "stopped"), + (Playback::Paused, "paused"), + (Playback::Playing, "playing"), + ] { + s.media.playback = playback; + assert_eq!(PeerView::from_status("a", "", &s).media.playback, name); + } + } + #[test] fn non_finite_numbers_never_reach_json() { let mut s = connected(None); @@ -545,6 +603,8 @@ mod tests { |v| v.peers[1].pc_audio.active = false, |v| v.peers[1].mic.active = true, |v| v.peers[1].error = Some("x".into()), + |v| v.peers[1].media.playback = "paused", + |v| v.peers[1].media.title = "Другой трек".into(), ]; for (i, edit) in edits.into_iter().enumerate() { let mut v = base.clone(); diff --git a/crates/core/examples/local_server.rs b/crates/core/examples/local_server.rs index 9bbf2a1..3782906 100644 --- a/crates/core/examples/local_server.rs +++ b/crates/core/examples/local_server.rs @@ -1,17 +1,19 @@ //! Minimal PC-side server for testing phones/probes without the desktop app. //! //! ```text -//! cargo run -p audiobridge-core --example local_server -- [--seconds N] [--sine HZ] [--local] [--mic-demand] +//! cargo run -p audiobridge-core --example local_server -- [--seconds N] [--sine HZ] [--local] [--mic-demand] [--media] //! ``` //! Prints the pairing URI, streams a sine as "PC audio" while a phone is connected and reports -//! the RMS of the received microphone every second. +//! the RMS of the received microphone every second. Every request from the phone is printed +//! (`REQUEST …`). `--media` also reports a fake media player that follows the phone's media +//! commands (headphone buttons), so the phone side can be tested without a real PC player. use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::{Duration, Instant}; use anyhow::{bail, Context, Result}; -use audiobridge_core::session::{NetOptions, Server, ServerConfig}; +use audiobridge_core::session::{MediaCommand, NetOptions, PcMedia, PcRequest, Playback, Server, ServerConfig}; use parking_lot::Mutex; const BLOCK: usize = 480; @@ -28,6 +30,7 @@ async fn main() -> Result<()> { let mut sine = 1000.0f64; let mut opts = NetOptions::default(); let mut demand = false; + let mut media = false; let mut it = std::env::args().skip(1); while let Some(a) = it.next() { match a.as_str() { @@ -35,6 +38,7 @@ async fn main() -> Result<()> { "--sine" => sine = it.next().context("--sine HZ")?.parse()?, "--local" => opts = NetOptions::local_only(), "--mic-demand" => demand = true, + "--media" => media = true, s => bail!("unknown argument {s}"), } } @@ -48,6 +52,17 @@ async fn main() -> Result<()> { ) .await?; server.set_mic_demand(demand); + let mut requests = server.take_requests().context("requests")?; + let fake_media = |playing: bool, track: u32| PcMedia { + playback: if playing { Playback::Playing } else { Playback::Paused }, + app: "Тестовый плеер".into(), + title: format!("Трек {track}"), + artist: "local_server".into(), + }; + let (mut playing, mut track) = (true, 1u32); + if media { + server.set_media(fake_media(playing, track)); + } let mut pairing = server.pairing(); tokio::spawn(async move { loop { @@ -104,15 +119,34 @@ async fn main() -> Result<()> { let status = server.status(); let mut tick = tokio::time::interval(Duration::from_secs(1)); tick.tick().await; - for sec in 1..=seconds { - tick.tick().await; - let s = status.borrow().clone(); - let (sum, n) = std::mem::take(&mut *mic_level.lock()); - let rms = if n == 0 { 0.0 } else { (sum / n as f64).sqrt() }; - println!( - "[{sec:>3}s] {:?} peer={:?} path={:?} rtt={:?} | tx {:.0} kbps active={} | mic rms={rms:.3} active={} buf={:.0}ms", - s.state, s.peer_name, s.path, s.rtt_ms, s.pc_audio.kbps, s.pc_audio.active, s.mic.active, s.mic.buffer_ms - ); + let mut sec = 0u64; + while sec < seconds { + tokio::select! { + _ = tick.tick() => { + sec += 1; + let s = status.borrow().clone(); + let (sum, n) = std::mem::take(&mut *mic_level.lock()); + let rms = if n == 0 { 0.0 } else { (sum / n as f64).sqrt() }; + println!( + "[{sec:>3}s] {:?} peer={:?} path={:?} rtt={:?} | tx {:.0} kbps active={} | mic rms={rms:.3} active={} buf={:.0}ms", + s.state, s.peer_name, s.path, s.rtt_ms, s.pc_audio.kbps, s.pc_audio.active, s.mic.active, s.mic.buffer_ms + ); + } + Some(req) = requests.recv() => { + println!("REQUEST {req:?}"); + if let (true, PcRequest::Media(cmd)) = (media, req) { + match cmd { + MediaCommand::Play => playing = true, + MediaCommand::Pause => playing = false, + MediaCommand::PlayPause => playing = !playing, + MediaCommand::Next => track += 1, + MediaCommand::Previous => track = track.saturating_sub(1).max(1), + } + println!("MEDIA {} track {track}", if playing { "playing" } else { "paused" }); + server.set_media(fake_media(playing, track)); + } + } + } } stop.store(true, Ordering::Relaxed); let _ = cap_thread.join(); diff --git a/crates/core/src/proto.rs b/crates/core/src/proto.rs index bb11b95..045d734 100644 --- a/crates/core/src/proto.rs +++ b/crates/core/src/proto.rs @@ -11,7 +11,8 @@ 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`. v2: remote control (state reports + requests). -pub const PROTOCOL_VERSION: u16 = 2; +/// v3: media transport (phone → PC media commands, PC → phone now-playing state). +pub const PROTOCOL_VERSION: u16 = 3; /// 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. @@ -175,6 +176,76 @@ pub enum PcRequest { MicDefault(bool), Volume(u8), Mute(bool), + /// Media transport command for the PC's current media session (headphone buttons). + Media(MediaCommand), +} + +/// Media transport command, applied by the PC to its current media session. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MediaCommand { + Play, + Pause, + /// Pause if playing, otherwise play (decided by the PC from the real state). + PlayPause, + Next, + Previous, +} + +impl MediaCommand { + pub const ALL: [MediaCommand; 5] = [ + MediaCommand::Play, + MediaCommand::Pause, + MediaCommand::PlayPause, + MediaCommand::Next, + MediaCommand::Previous, + ]; + + /// Stable wire / JNI code. + pub fn code(self) -> u8 { + match self { + MediaCommand::Play => 0, + MediaCommand::Pause => 1, + MediaCommand::PlayPause => 2, + MediaCommand::Next => 3, + MediaCommand::Previous => 4, + } + } + + pub fn from_code(code: u8) -> Option { + Self::ALL.into_iter().find(|c| c.code() == code) + } +} + +/// Playback state of the PC's current media session. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Playback { + /// No media session on the PC (or not reported yet). + #[default] + None, + Stopped, + Paused, + Playing, +} + +impl Playback { + fn code(self) -> u8 { + match self { + Playback::None => 0, + Playback::Stopped => 1, + Playback::Paused => 2, + Playback::Playing => 3, + } + } +} + +/// What the PC's current media session plays (PC → phone). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PcMedia { + pub playback: Playback, + /// Player name (e.g. "Яндекс Музыка"); empty while unknown. + pub app: String, + pub title: String, + pub artist: String, } /// PC → phone: change a phone-side control. @@ -213,6 +284,8 @@ pub enum ControlMsg { SetPc(PcRequest), /// PC → phone: remote-control request. SetPhone(PhoneRequest), + /// PC → phone: the PC's media session changed (v3). + PcMedia(PcMedia), } const MAX_CONTROL_LEN: usize = 1024; @@ -271,6 +344,7 @@ impl ControlMsg { PcRequest::MicDefault(on) => (2, on as u8), PcRequest::Volume(level) => (3, level.min(MAX_LEVEL)), PcRequest::Mute(on) => (4, on as u8), + PcRequest::Media(cmd) => (5, cmd.code()), }; body.extend_from_slice(&[kind, value]); } @@ -282,6 +356,13 @@ impl ControlMsg { }; body.extend_from_slice(&[kind, value]); } + ControlMsg::PcMedia(media) => { + body.push(9); + body.push(media.playback.code()); + put_str(&mut body, &media.app); + put_str(&mut body, &media.title); + put_str(&mut body, &media.artist); + } } let mut out = Vec::with_capacity(body.len() + 2); out.extend_from_slice(&(body.len() as u16).to_le_bytes()); @@ -322,6 +403,9 @@ impl ControlMsg { 2 => PcRequest::MicDefault(r.bool()?), 3 => PcRequest::Volume(r.level()?), 4 => PcRequest::Mute(r.bool()?), + 5 => PcRequest::Media( + MediaCommand::from_code(r.u8()?).context("unknown media command")?, + ), k => bail!("unknown PC request {k}"), }), 8 => ControlMsg::SetPhone(match r.u8()? { @@ -329,6 +413,18 @@ impl ControlMsg { 1 => PhoneRequest::Volume(r.level()?), k => bail!("unknown phone request {k}"), }), + 9 => ControlMsg::PcMedia(PcMedia { + playback: match r.u8()? { + 0 => Playback::None, + 1 => Playback::Stopped, + 2 => Playback::Paused, + 3 => Playback::Playing, + v => bail!("bad playback state {v}"), + }, + app: r.string()?, + title: r.string()?, + artist: r.string()?, + }), t => bail!("unknown control message tag {t}"), }; ensure!(r.0.is_empty(), "trailing bytes in control message"); @@ -527,8 +623,20 @@ mod tests { ControlMsg::SetPc(PcRequest::MicDefault(false)), ControlMsg::SetPc(PcRequest::Volume(0)), ControlMsg::SetPc(PcRequest::Mute(true)), + ControlMsg::SetPc(PcRequest::Media(MediaCommand::Play)), + ControlMsg::SetPc(PcRequest::Media(MediaCommand::Pause)), + ControlMsg::SetPc(PcRequest::Media(MediaCommand::PlayPause)), + ControlMsg::SetPc(PcRequest::Media(MediaCommand::Next)), + ControlMsg::SetPc(PcRequest::Media(MediaCommand::Previous)), ControlMsg::SetPhone(PhoneRequest::Mic(false)), ControlMsg::SetPhone(PhoneRequest::Volume(100)), + ControlMsg::PcMedia(PcMedia::default()), + ControlMsg::PcMedia(PcMedia { + playback: Playback::Playing, + app: "Яндекс Музыка".into(), + title: "Танцуй!".into(), + artist: "SATS".into(), + }), ]; for m in msgs { let enc = m.encode(); @@ -551,6 +659,35 @@ mod tests { // 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())); + // unknown media commands and playback states are rejected + assert!(ControlMsg::decode(&[7, 5, 5]).is_err()); + assert!(ControlMsg::decode(&[9, 4, 0, 0, 0]).is_err()); + } + + #[test] + fn media_codes_are_stable() { + // the Android app passes these codes over JNI; they must never be renumbered + let codes: Vec = MediaCommand::ALL.iter().map(|c| c.code()).collect(); + assert_eq!(codes, [0, 1, 2, 3, 4]); + for c in MediaCommand::ALL { + assert_eq!(MediaCommand::from_code(c.code()), Some(c)); + } + assert_eq!(MediaCommand::from_code(5), None); + } + + #[test] + fn long_media_strings_are_truncated_on_a_char_boundary() { + let long = "я".repeat(200); // 400 bytes + let enc = ControlMsg::PcMedia(PcMedia { + playback: Playback::Paused, + app: long.clone(), + title: long.clone(), + artist: long, + }) + .encode(); + assert!(enc.len() - 2 <= MAX_CONTROL_LEN); + let ControlMsg::PcMedia(m) = ControlMsg::decode(&enc[2..]).unwrap() else { panic!() }; + assert_eq!(m.title, "я".repeat(MAX_NAME_LEN / 2)); } #[test] @@ -564,4 +701,20 @@ mod tests { }; assert_eq!((version, device_name.as_str(), phone), (1, "P5", PhoneControls::default())); } + + #[test] + fn v2_hello_is_still_readable_for_a_reject() { + // v2 phones send the same Hello layout; the PC must see version 2 to reject it + let mut v2 = ControlMsg::Hello { + version: 2, + secret: [1; 16], + device_name: "Old".into(), + phone: PhoneControls { mic: true, mic_ready: true, volume: Some(5) }, + } + .encode(); + let ControlMsg::Hello { version, device_name, .. } = ControlMsg::decode(&v2.split_off(2)).unwrap() else { + panic!("not a Hello"); + }; + assert_eq!((version, device_name.as_str()), (2, "Old")); + } } diff --git a/crates/core/src/session/hub.rs b/crates/core/src/session/hub.rs index e2010ba..448e79b 100644 --- a/crates/core/src/session/hub.rs +++ b/crates/core/src/session/hub.rs @@ -555,7 +555,10 @@ impl Peer { 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"); - self.status.update(clear_connection_status); + self.status.update(|s| { + clear_connection_status(s); + s.media = Default::default(); + }); self.refresh_toggles(); reason } @@ -619,6 +622,11 @@ impl Peer { let _ = self.hub.requests.send(req); continue; } + ControlMsg::PcMedia(media) => { + tracing::debug!("PC media: {:?} '{}'", media.playback, media.app); + self.status.update(|s| s.media = media); + continue; + } msg => { let mut remote = self.remote.lock(); let r = remote.as_mut().context("not connected")?; diff --git a/crates/core/src/session/mod.rs b/crates/core/src/session/mod.rs index 475e06c..86ff683 100644 --- a/crates/core/src/session/mod.rs +++ b/crates/core/src/session/mod.rs @@ -17,7 +17,9 @@ use crate::audio::tx::TxStats; use crate::audio::RxSlot; use crate::proto::{ControlMsg, DatagramHeader, PacketKind, StreamId}; -pub use crate::proto::{PcControls, PcRequest, PhoneControls, PhoneRequest, Volume}; +pub use crate::proto::{ + MediaCommand, PcControls, PcMedia, PcRequest, PhoneControls, PhoneRequest, Playback, Volume, +}; pub use hub::{Hub, MAX_PEERS}; pub use server::Server; @@ -71,6 +73,9 @@ pub struct Status { /// PC side: the connected phone's last reported controls (`None` without a phone). /// Always `None` on the phone side. pub phone: Option, + /// PC side: its own media session. Phone side: what this PC last reported (cleared on + /// disconnect). + pub media: PcMedia, pub last_error: Option, } @@ -88,6 +93,7 @@ impl Status { mic: StreamStats::default(), pc: PcControls::default(), phone: None, + media: PcMedia::default(), last_error: None, } } diff --git a/crates/core/src/session/server.rs b/crates/core/src/session/server.rs index 5b78122..fd1a333 100644 --- a/crates/core/src/session/server.rs +++ b/crates/core/src/session/server.rs @@ -20,7 +20,8 @@ use crate::audio::tx::TxStats; use crate::audio::{CaptureHandle, IncomingStream, OutgoingStream, PlayoutHandle}; use crate::pairing::PairingInfo; use crate::proto::{ - ControlMsg, PcControls, PcRequest, PhoneControls, PhoneRequest, StreamId, Volume, PROTOCOL_VERSION, + ControlMsg, PcControls, PcMedia, PcRequest, PhoneControls, PhoneRequest, StreamId, Volume, + PROTOCOL_VERSION, }; const PC_AUDIO_BITRATE: i32 = 192_000; @@ -54,6 +55,8 @@ pub(super) struct Inner { status_tx: Arc>, status: StatusCell, toggles: watch::Sender, + /// The PC's current media session (reported to the phone). + media: watch::Sender, /// Controls of the connected phone (`None` without a phone). phone: Mutex>, /// Requests from the phone, for the app to apply. @@ -96,6 +99,7 @@ impl Server { status_tx, status, toggles: watch::channel(toggles).0, + media: watch::channel(PcMedia::default()).0, phone: Mutex::new(None), requests: requests_tx, pc_audio: OutgoingStream::new(StreamId::PcAudio, PC_AUDIO_BITRATE, PC_AUDIO_COMPLEXITY)?, @@ -158,6 +162,20 @@ impl Server { self.inner.set_toggles(|t| t.pc.volume = volume); } + /// The PC's current media session (reported to the phone; only changes are sent). + pub fn set_media(&self, media: PcMedia) { + let changed = self.inner.media.send_if_modified(|m| { + if *m == media { + return false; + } + *m = media.clone(); + true + }); + if changed { + self.inner.status.update(|s| s.media = media); + } + } + /// 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> { @@ -444,6 +462,10 @@ impl Inner { let mut toggles_rx = self.toggles.subscribe(); // the toggles may have changed between Welcome and now toggles_rx.mark_changed(); + // the Welcome carries no media state: send it right away unless there is none + let mut media_rx = self.media.subscribe(); + media_rx.mark_changed(); + let mut sent_media = PcMedia::default(); loop { tokio::select! { msg = incoming.recv() => match msg { @@ -478,6 +500,14 @@ impl Inner { } sent = t; } + r = media_rx.changed() => { + r?; + let now = media_rx.borrow_and_update().clone(); + if now != sent_media { + ControlMsg::PcMedia(now.clone()).write_to(&mut send).await?; + sent_media = now; + } + } } } } diff --git a/crates/core/tests/integration.rs b/crates/core/tests/integration.rs index c36190c..2915f65 100644 --- a/crates/core/tests/integration.rs +++ b/crates/core/tests/integration.rs @@ -5,8 +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, PcRequest, PhoneControls, PhoneRequest, Server, - ServerConfig, Status, Volume, + ConnState, Hub, HubConfig, HubStatus, MediaCommand, NetOptions, PathKind, PcMedia, PcRequest, PhoneControls, + PhoneRequest, Playback, Server, ServerConfig, Status, Volume, }; use tokio::sync::watch; @@ -688,6 +688,83 @@ async fn remote_control_in_both_directions() { server.shutdown().await; } +/// Headphone buttons: a media command reaches only the PC it is addressed to, exactly once and +/// in order; the PC's now-playing state flows to the phone and is cleared when the PC goes away. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn media_commands_reach_only_the_target_pc_exactly_once() { + init_logs(); + let (da, db, dh) = ( + tempfile::tempdir().unwrap(), + tempfile::tempdir().unwrap(), + tempfile::tempdir().unwrap(), + ); + let a = start_server(da.path(), "PC-A", NetOptions::local_only()).await; + let b = start_server(db.path(), "PC-B", NetOptions::local_only()).await; + let mut ra = a.take_requests().unwrap(); + let mut rb = b.take_requests().unwrap(); + let (pa, pb) = (pairing_of(&a), pairing_of(&b)); + let (ida, idb) = (pa.peer_id(), pb.peer_id()); + // reported before the phone connects: arrives right after the handshake + let playing = PcMedia { + playback: Playback::Playing, + app: "Яндекс Музыка".into(), + title: "Танцуй!".into(), + artist: "SATS".into(), + }; + a.set_media(playing.clone()); + assert_eq!(a.status().borrow().media, playing); + let hub = start_hub(dh.path(), vec![pa, pb]).await; + let mut hs = hub.status(); + let h = wait_for(&mut hs, Duration::from_secs(10), "both connected, A playing", |h| { + connected(h, &ida) && connected(h, &idb) && peer(h, &ida).is_some_and(|s| s.media == playing) + }) + .await; + assert_eq!(peer(&h, &idb).unwrap().media, PcMedia::default(), "PC-B reported nothing"); + + let cmds = [ + MediaCommand::Pause, + MediaCommand::Play, + MediaCommand::PlayPause, + MediaCommand::Next, + MediaCommand::Previous, + ]; + for c in cmds { + assert!(hub.request_pc(&ida, PcRequest::Media(c))); + } + // a sentinel right behind them proves there were no duplicates in between + assert!(hub.request_pc(&ida, PcRequest::Audio(true))); + for c in cmds { + assert_eq!(next(&mut ra).await, PcRequest::Media(c)); + } + assert_eq!(next(&mut ra).await, PcRequest::Audio(true)); + // no fan-out: PC-B's first request is its own sentinel + assert!(hub.request_pc(&idb, PcRequest::Audio(true))); + assert_eq!(next(&mut rb).await, PcRequest::Audio(true)); + assert!(ra.try_recv().is_err() && rb.try_recv().is_err()); + + // state changes reach the phone; an unchanged report sends nothing + let paused = PcMedia { playback: Playback::Paused, ..playing.clone() }; + a.set_media(paused.clone()); + a.set_media(paused.clone()); + wait_for(&mut hs, Duration::from_secs(5), "A paused", |h| { + peer(h, &ida).is_some_and(|s| s.media == paused) + }) + .await; + + // PC-A goes away: its media state is cleared, commands for it are refused, PC-B still works + a.shutdown().await; + wait_for(&mut hs, Duration::from_secs(10), "A gone", |h| { + peer(h, &ida).is_some_and(|s| s.state != ConnState::Connected && s.media == PcMedia::default()) + }) + .await; + assert!(!hub.request_pc(&ida, PcRequest::Media(MediaCommand::Next))); + assert!(hub.request_pc(&idb, PcRequest::Media(MediaCommand::Next))); + assert_eq!(next(&mut rb).await, PcRequest::Media(MediaCommand::Next)); + + hub.shutdown().await; + b.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 922e35b..c246bad 100644 --- a/crates/desktop/Cargo.toml +++ b/crates/desktop/Cargo.toml @@ -25,6 +25,10 @@ windows-core = "0.62" [dependencies.windows] version = "0.62" features = [ + "ApplicationModel", + "Foundation", + "Foundation_Collections", + "Media_Control", "Win32_Devices_FunctionDiscovery", "Win32_Foundation", "Win32_Graphics_Gdi", @@ -44,7 +48,9 @@ features = [ "Win32_System_SystemInformation", "Win32_System_Threading", "Win32_System_Variant", + "Win32_System_WinRT", "Win32_UI_HiDpi", + "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging", diff --git a/crates/desktop/src/instance.rs b/crates/desktop/src/instance.rs index 277e0df..cc32877 100644 --- a/crates/desktop/src/instance.rs +++ b/crates/desktop/src/instance.rs @@ -1,16 +1,24 @@ //! Single instance: a named mutex plus a named auto-reset event the primary waits on. //! A second launch signals the event (after granting foreground rights) and exits. -use windows::core::{w, PCWSTR}; +use windows::core::{HSTRING, PCWSTR}; use windows::Win32::Foundation::{CloseHandle, GetLastError, ERROR_ALREADY_EXISTS, HANDLE}; use windows::Win32::System::Threading::{ CreateEventW, CreateMutexW, OpenEventW, SetEvent, WaitForSingleObject, EVENT_MODIFY_STATE, INFINITE, }; use windows::Win32::UI::WindowsAndMessaging::{AllowSetForegroundWindow, ASFW_ANY}; -const MUTEX_NAME: PCWSTR = w!("Local\\AudioBridge.Instance"); -const SHOW_EVENT_NAME: PCWSTR = w!("Local\\AudioBridge.Show"); -const QUIT_EVENT_NAME: PCWSTR = w!("Local\\AudioBridge.Quit"); +fn object_name(kind: &str) -> HSTRING { + #[cfg(debug_assertions)] + if let Some(dir) = crate::paths::dev_dir() { + use std::hash::{Hash, Hasher}; + let canonical = std::fs::canonicalize(&dir).unwrap_or(dir); + let mut hash = std::collections::hash_map::DefaultHasher::new(); + canonical.to_string_lossy().to_lowercase().hash(&mut hash); + return HSTRING::from(format!("Local\\AudioBridge.Dev.{:016x}.{kind}", hash.finish())); + } + HSTRING::from(format!("Local\\AudioBridge.{kind}")) +} /// An owned kernel handle that may be moved across threads. pub struct OwnedHandle(pub HANDLE); @@ -36,14 +44,14 @@ pub struct Primary { /// Returns `Some(Primary)` if this is the only instance, `None` if another one runs. pub fn acquire() -> anyhow::Result> { - // SAFETY: plain kernel object creation with static names. + // SAFETY: plain kernel object creation with names valid for each call. unsafe { - let mutex = OwnedHandle(CreateMutexW(None, false, MUTEX_NAME)?); + let mutex = OwnedHandle(CreateMutexW(None, false, &object_name("Instance"))?); if GetLastError() == ERROR_ALREADY_EXISTS { return Ok(None); } - let show_event = OwnedHandle(CreateEventW(None, false, false, SHOW_EVENT_NAME)?); - let quit_event = OwnedHandle(CreateEventW(None, false, false, QUIT_EVENT_NAME)?); + let show_event = OwnedHandle(CreateEventW(None, false, false, &object_name("Show"))?); + let quit_event = OwnedHandle(CreateEventW(None, false, false, &object_name("Quit"))?); Ok(Some(Primary { _mutex: mutex, show_event, quit_event })) } } @@ -52,7 +60,7 @@ pub fn acquire() -> anyhow::Result> { pub fn another_running() -> bool { // SAFETY: as in `acquire`; the handle is closed right away. unsafe { - match CreateMutexW(None, false, MUTEX_NAME) { + match CreateMutexW(None, false, &object_name("Instance")) { Ok(h) => { let exists = GetLastError() == ERROR_ALREADY_EXISTS; let _ = CloseHandle(h); @@ -79,12 +87,12 @@ pub fn signal_existing() { unsafe { let _ = AllowSetForegroundWindow(ASFW_ANY); } - signal(SHOW_EVENT_NAME); + signal(PCWSTR(object_name("Show").as_ptr())); } /// Asks the running instance to exit (used before replacing its exe). pub fn signal_quit() { - signal(QUIT_EVENT_NAME); + signal(PCWSTR(object_name("Quit").as_ptr())); } fn listen(name: &str, event: &OwnedHandle, on_fire: impl Fn() + Send + 'static) { diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs index b9828c0..72e9601 100644 --- a/crates/desktop/src/main.rs +++ b/crates/desktop/src/main.rs @@ -16,7 +16,9 @@ mod cable_install; mod autostart; mod icon; mod instance; +mod media; mod paths; +mod service; mod settings; mod shared; mod selfinstall; @@ -38,9 +40,8 @@ use windows::Win32::System::Threading::GetCurrentProcess; use windows::Win32::UI::HiDpi::{SetProcessDpiAwarenessContext, DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2}; use windows::Win32::UI::WindowsAndMessaging::{MessageBoxW, MB_ICONERROR, MB_OK}; -use crate::audio::{AudioEngine, Hooks}; use crate::settings::Settings; -use crate::shared::{shared, MainCmd}; +use crate::shared::shared; fn main() -> ExitCode { let args: Vec = std::env::args().skip(1).collect(); @@ -114,39 +115,13 @@ fn run(primary: instance::Primary, background: bool, data_dir: &Path) -> Result< .enable_all() .build() .context("tokio runtime")?; - let pc_name = paths::computer_name(); - let server = rt - .block_on(Server::start(ServerConfig { data_dir: data_dir.to_path_buf(), pc_name: pc_name.clone() })) - .context("сетевой сервер")?; - server.set_pc_audio_enabled(settings.pc_audio_enabled); - 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); - - let audio = AudioEngine::spawn( - capture, - playout, - 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 sh = shared::init(data_dir.to_path_buf(), paths::computer_name(), background, settings, main_tx); + let (window_tx, window_rx) = mpsc::channel(); + let supervisor = std::thread::Builder::new() + .name("service-supervisor".into()) + .spawn(move || service::supervise(rt, main_rx, window_tx)) + .context("service supervisor thread")?; let tray = tray::spawn().context("tray icon")?; primary.listen_for_show(|| shared().show_window()); primary.listen_for_quit(|| shared().request_exit()); @@ -156,26 +131,19 @@ fn run(primary: instance::Primary, background: bool, data_dir: &Path) -> Result< trim_working_set(); } while !sh.is_exiting() { - match main_rx.recv() { - Ok(MainCmd::Show) => { + match window_rx.recv() { + Ok(()) => { ui::run_window(); trim_working_set(); } - Ok(MainCmd::Exit) | Err(_) => break, + Err(_) => break, } } tracing::info!("shutting down"); + sh.request_exit(); + let _ = supervisor.join(); tray.quit(); - audio.shutdown(); - if let Some(server) = sh.take_server() { - rt.block_on(async { - if tokio::time::timeout(Duration::from_secs(3), server.shutdown()).await.is_err() { - tracing::warn!("server shutdown timed out"); - } - }); - } - rt.shutdown_timeout(Duration::from_secs(1)); Ok(()) } diff --git a/crates/desktop/src/media.rs b/crates/desktop/src/media.rs new file mode 100644 index 0000000..d340ca9 --- /dev/null +++ b/crates/desktop/src/media.rs @@ -0,0 +1,681 @@ +//! Event-driven Windows media transport. WinRT and blocking async results stay off the UI/runtime. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{mpsc, Arc}; +use std::thread::{self, JoinHandle}; + +use anyhow::{bail, Context, Result}; +use audiobridge_core::session::{MediaCommand, PcMedia, Playback}; +use windows::ApplicationModel::AppInfo; +use windows::Foundation::TypedEventHandler; +use windows::Media::Control::{ + GlobalSystemMediaTransportControlsSession as Session, + GlobalSystemMediaTransportControlsSessionManager as Manager, + GlobalSystemMediaTransportControlsSessionPlaybackStatus as PlaybackStatus, +}; +use windows::Win32::System::WinRT::{RoInitialize, RoUninitialize, RO_INIT_MULTITHREADED}; +use windows::Win32::UI::Input::KeyboardAndMouse::{ + SendInput, INPUT, INPUT_0, INPUT_KEYBOARD, KEYBDINPUT, KEYEVENTF_KEYUP, VK_MEDIA_NEXT_TRACK, + VK_MEDIA_PLAY_PAUSE, VK_MEDIA_PREV_TRACK, +}; + +enum Message { + Command(MediaCommand), + Changed, + Shutdown, +} + +#[derive(Clone)] +pub struct Sender(mpsc::Sender); + +impl Sender { + pub fn send(&self, command: MediaCommand) { + if self.0.send(Message::Command(command)).is_err() { + tracing::warn!(?command, "media transport thread unavailable"); + } + } +} + +pub struct MediaTransport { + sender: Sender, + thread: JoinHandle<()>, +} + +impl MediaTransport { + pub fn spawn(report: impl Fn(PcMedia) + Send + 'static) -> Result { + let (tx, rx) = mpsc::channel(); + let changed = Changes { + tx: tx.clone(), + pending: Arc::new(AtomicBool::new(false)), + }; + let thread = thread::Builder::new() + .name("media-transport".into()) + .spawn(move || run(rx, changed, report)) + .context("spawn media transport thread")?; + Ok(Self { + sender: Sender(tx), + thread, + }) + } + + pub fn sender(&self) -> Sender { + self.sender.clone() + } + + pub fn shutdown(self) { + let _ = self.sender.0.send(Message::Shutdown); + if self.thread.join().is_err() { + tracing::warn!("media transport thread failed during shutdown"); + } + } +} + +struct Apartment; + +impl Apartment { + fn new() -> Result { + // SAFETY: this dedicated thread has no prior apartment; Drop balances successful initialization. + unsafe { RoInitialize(RO_INIT_MULTITHREADED) }.context("initialize media WinRT MTA")?; + Ok(Self) + } +} + +impl Drop for Apartment { + fn drop(&mut self) { + // SAFETY: runs on the same thread as the successful RoInitialize, after all WinRT objects drop. + unsafe { RoUninitialize() }; + } +} + +#[derive(Clone)] +struct Changes { + tx: mpsc::Sender, + pending: Arc, +} + +impl Changes { + fn notify(&self) { + // At most one outstanding event wakeup, without dropping or coalescing commands. + if !self.pending.swap(true, Ordering::AcqRel) { + let _ = self.tx.send(Message::Changed); + } + } +} + +struct ManagerWatch { + manager: Manager, + current: Option, + sessions: Option, +} + +impl ManagerWatch { + fn new(changes: &Changes) -> Result { + let manager = Manager::RequestAsync() + .context("request GSMTC manager")? + .join() + .context("open GSMTC manager")?; + let mut watch = Self { + manager, + current: None, + sessions: None, + }; + let changed = changes.clone(); + watch.current = Some( + watch + .manager + .CurrentSessionChanged(&TypedEventHandler::new(move |_, _| { + changed.notify(); + Ok(()) + })) + .context("subscribe to current media session")?, + ); + let changed = changes.clone(); + watch.sessions = Some( + watch + .manager + .SessionsChanged(&TypedEventHandler::new(move |_, _| { + changed.notify(); + Ok(()) + })) + .context("subscribe to media sessions")?, + ); + Ok(watch) + } +} + +impl Drop for ManagerWatch { + fn drop(&mut self) { + if let Some(token) = self.current { + let _ = self.manager.RemoveCurrentSessionChanged(token); + } + if let Some(token) = self.sessions { + let _ = self.manager.RemoveSessionsChanged(token); + } + } +} + +struct SessionWatch { + session: Session, + playback: Option, + properties: Option, +} + +impl SessionWatch { + fn update(&mut self, reported: bool, changes: &Changes) -> Result<()> { + // Watch playback for every session: a background player becoming Playing can change selection. + if self.playback.is_none() { + let changed = changes.clone(); + self.playback = Some( + self.session + .PlaybackInfoChanged(&TypedEventHandler::new(move |_, _| { + changed.notify(); + Ok(()) + })) + .context("subscribe to media playback")?, + ); + } + if reported && self.properties.is_none() { + let changed = changes.clone(); + self.properties = Some( + self.session + .MediaPropertiesChanged(&TypedEventHandler::new(move |_, _| { + changed.notify(); + Ok(()) + })) + .context("subscribe to media properties")?, + ); + } else if !reported { + if let Some(token) = self.properties { + self.session + .RemoveMediaPropertiesChanged(token) + .context("unsubscribe media properties")?; + self.properties = None; + } + } + Ok(()) + } +} + +impl Drop for SessionWatch { + fn drop(&mut self) { + if let Some(token) = self.playback { + let _ = self.session.RemovePlaybackInfoChanged(token); + } + if let Some(token) = self.properties { + let _ = self.session.RemoveMediaPropertiesChanged(token); + } + } +} + +fn run(rx: mpsc::Receiver, changes: Changes, report: impl Fn(PcMedia)) { + let apartment = Apartment::new(); + let manager = match apartment.as_ref() { + Ok(_) => ManagerWatch::new(&changes), + Err(error) => Err(anyhow::anyhow!("{error:#}")), + }; + if let Err(error) = &manager { + tracing::warn!("GSMTC unavailable; hardware media keys remain available: {error:#}"); + } + let mut watches = Vec::new(); + refresh(manager.as_ref().ok(), &mut watches, &changes, &report); + while let Ok(message) = rx.recv() { + match message { + Message::Shutdown => break, + Message::Changed => { + changes.pending.store(false, Ordering::Release); + refresh(manager.as_ref().ok(), &mut watches, &changes, &report); + } + Message::Command(command) => { + let selected = manager + .as_ref() + .ok() + .map(|m| selected_session(&m.manager)) + .transpose(); + let mut aumid = String::new(); + let attempt = match selected { + Ok(Some(Some(session))) => { + aumid = session + .SourceAppUserModelId() + .map(|id| id.to_string()) + .unwrap_or_default(); + apply(&session, command).map(Some) + } + Ok(_) => Ok(None), + Err(error) => Err(error), + }; + let gsmtc_error = attempt.as_ref().err().map(|e| format!("{e:#}")); + match finish_command(attempt, || send_key(command)) { + Ok(route) => { + let fallback_toggle = route == Route::HardwareKeyFallback + && matches!( + command, + MediaCommand::Play | MediaCommand::Pause | MediaCommand::PlayPause + ); + tracing::info!(?command, %aumid, ?route, ?gsmtc_error, fallback_toggle, "media command"); + } + Err(error) => { + tracing::info!(?command, %aumid, ?gsmtc_error, "media command hardware-key fallback failed: {error:#}") + } + } + refresh(manager.as_ref().ok(), &mut watches, &changes, &report); + } + } + } + // Subscriptions and their callbacks must be released before the apartment. + drop(watches); + drop(manager); + drop(apartment); +} + +#[derive(Debug, PartialEq, Eq)] +enum Selection { + Current, + Listed(usize), +} + +fn choose_session( + current: Option, + mut sessions: impl Iterator, +) -> Option { + if current == Some(Playback::Playing) { + Some(Selection::Current) + } else if let Some(index) = sessions.position(|status| status == Playback::Playing) { + Some(Selection::Listed(index)) + } else { + current.map(|_| Selection::Current) + } +} + +fn playback(session: &Session) -> Result { + Ok(map_status( + session + .GetPlaybackInfo() + .context("get playback info")? + .PlaybackStatus() + .context("get playback status")?, + )) +} + +fn sessions(manager: &Manager) -> Result<(Option, Vec)> { + // GetCurrentSession returns E_POINTER for a null session when no app is current. + let current = manager.GetCurrentSession().ok(); + let sessions = manager.GetSessions().context("enumerate media sessions")?; + let mut list = Vec::with_capacity(sessions.Size().context("count media sessions")? as usize); + for session in sessions { + list.push(session); + } + Ok((current, list)) +} + +fn select<'a>(current: Option<&'a Session>, list: &'a [Session]) -> Option<&'a Session> { + match choose_session( + current.map(|s| playback(s).unwrap_or(Playback::Stopped)), + list.iter() + .map(|s| playback(s).unwrap_or(Playback::Stopped)), + ) { + Some(Selection::Current) => current, + Some(Selection::Listed(index)) => list.get(index), + None => None, + } +} + +fn selected_session(manager: &Manager) -> Result> { + let (current, list) = sessions(manager)?; + Ok(select(current.as_ref(), &list).cloned()) +} + +fn refresh( + manager: Option<&ManagerWatch>, + watches: &mut Vec, + changes: &Changes, + report: &impl Fn(PcMedia), +) { + let Some(manager) = manager else { + report(PcMedia::default()); + return; + }; + let result = (|| -> Result { + let (current, mut list) = sessions(&manager.manager)?; + if let Some(current) = ¤t { + if !list.contains(current) { + list.push(current.clone()); + } + } + watches.retain(|watch| list.contains(&watch.session)); + for session in &list { + if !watches.iter().any(|watch| watch.session == *session) { + let mut watch = SessionWatch { + session: session.clone(), + playback: None, + properties: None, + }; + if let Err(error) = watch.update(false, changes) { + tracing::warn!("media playback subscription: {error:#}"); + } + watches.push(watch); + } + } + // Read selection after subscribing, so a newly discovered player cannot change unnoticed. + let selected = select(current.as_ref(), &list); + for watch in watches { + if let Err(error) = watch.update(selected == Some(&watch.session), changes) { + tracing::warn!("media event subscription: {error:#}"); + } + } + selected + .map(read_media) + .transpose() + .map(Option::unwrap_or_default) + })(); + match result { + Ok(media) => report(media), + Err(error) => { + tracing::warn!("read media state: {error:#}"); + report(PcMedia::default()); + } + } +} + +fn read_media(session: &Session) -> Result { + let aumid = session.SourceAppUserModelId().context("get player AUMID")?; + let app = AppInfo::GetFromAppUserModelId(&aumid) + .and_then(|info| info.DisplayInfo()) + .and_then(|info| info.DisplayName()) + .map(|name| name.to_string()) + .ok() + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| app_name(&aumid.to_string())); + let mut media = PcMedia { + playback: playback(session)?, + app, + ..PcMedia::default() + }; + match session + .TryGetMediaPropertiesAsync() + .and_then(|op| op.join()) + { + Ok(properties) => { + media.title = properties + .Title() + .map(|s| s.to_string()) + .unwrap_or_default(); + media.artist = properties + .Artist() + .map(|s| s.to_string()) + .unwrap_or_default(); + } + Err(error) => tracing::warn!("read media properties: {error}"), + } + Ok(media) +} + +fn map_status(status: PlaybackStatus) -> Playback { + match status { + PlaybackStatus::Playing => Playback::Playing, + PlaybackStatus::Paused => Playback::Paused, + _ => Playback::Stopped, + } +} + +fn resolve_command(command: MediaCommand, status: Playback) -> MediaCommand { + match command { + MediaCommand::PlayPause if status == Playback::Playing => MediaCommand::Pause, + MediaCommand::PlayPause => MediaCommand::Play, + command => command, + } +} + +/// Play while playing / pause while not playing (after [`resolve_command`]). +fn is_redundant(command: MediaCommand, status: Playback) -> bool { + match command { + MediaCommand::Play => status == Playback::Playing, + MediaCommand::Pause => status != Playback::Playing, + _ => false, + } +} + +/// What the player did with a command. +#[derive(Debug, PartialEq, Eq)] +enum Applied { + Accepted, + /// The control is disabled (e.g. no next track) or the player refused it. + Declined, + /// Play while already playing, pause while not playing: nothing to do. Earbuds send "play" when they connect, + /// and a toggle key here would pause the music instead. + Redundant, +} + +/// Uses explicit Play/Pause, not toggle: Chromium often advertises only the explicit controls. +fn apply(session: &Session, command: MediaCommand) -> Result { + let info = session + .GetPlaybackInfo() + .context("get command playback info")?; + let status = map_status(info.PlaybackStatus().context("get command status")?); + let command = resolve_command(command, status); + if is_redundant(command, status) { + return Ok(Applied::Redundant); + } + let controls = info.Controls().context("get supported media controls")?; + let operation = match command { + MediaCommand::Play if controls.IsPlayEnabled()? => session.TryPlayAsync(), + MediaCommand::Pause if controls.IsPauseEnabled()? => session.TryPauseAsync(), + MediaCommand::Next if controls.IsNextEnabled()? => session.TrySkipNextAsync(), + MediaCommand::Previous if controls.IsPreviousEnabled()? => session.TrySkipPreviousAsync(), + _ => return Ok(Applied::Declined), + }; + let accepted = operation + .context("send GSMTC command")? + .join() + .context("complete GSMTC command")?; + Ok(if accepted { Applied::Accepted } else { Applied::Declined }) +} + +#[derive(Debug, PartialEq, Eq)] +enum Route { + Gsmtc(Applied), + HardwareKeyFallback, +} + +/// Exactly one action per command: the media session's answer is final; the hardware media key is used only when +/// there is no media session (`Ok(None)`) or GSMTC itself failed. A declined command never falls back: the key +/// would reach the same player or, worse, toggle another one. +fn finish_command(attempt: Result>, fallback: impl FnOnce() -> Result<()>) -> Result { + match attempt { + Ok(Some(applied)) => Ok(Route::Gsmtc(applied)), + Ok(None) | Err(_) => { + fallback()?; + Ok(Route::HardwareKeyFallback) + } + } +} + +fn send_key(command: MediaCommand) -> Result<()> { + let key = match command { + MediaCommand::Next => VK_MEDIA_NEXT_TRACK, + MediaCommand::Previous => VK_MEDIA_PREV_TRACK, + MediaCommand::Play | MediaCommand::Pause | MediaCommand::PlayPause => VK_MEDIA_PLAY_PAUSE, + }; + let down = KEYBDINPUT { + wVk: key, + ..Default::default() + }; + let up = KEYBDINPUT { + dwFlags: KEYEVENTF_KEYUP, + ..down + }; + let inputs = [ + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { ki: down }, + }, + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { ki: up }, + }, + ]; + // SAFETY: two initialized keyboard INPUTs with the correct ABI size, alive for the call. + let sent = unsafe { SendInput(&inputs, size_of::() as i32) }; + if sent != inputs.len() as u32 { + bail!("SendInput inserted {sent}/2 media key events (desktop may be locked or UIPI may block input)"); + } + Ok(()) +} + +fn app_name(aumid: &str) -> String { + let name = aumid.rsplit(['\\', '/']).next().unwrap_or_default(); + if name + .get(name.len().saturating_sub(4)..) + .is_some_and(|suffix| suffix.eq_ignore_ascii_case(".exe")) + { + return name[..name.len() - 4].to_owned(); + } + // Packaged apps: `Family_hash!AppId` → the last part of the app id. + if let Some((_, app)) = name.rsplit_once('!') { + return app.rsplit('.').next().unwrap_or(app).to_owned(); + } + // A plain id such as `ru.yandex.desktop.music`: shown as is (its last segment alone means nothing). + name.to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + + #[test] + fn playing_current_wins_over_other_players() { + assert_eq!( + choose_session(Some(Playback::Playing), [Playback::Playing].into_iter()), + Some(Selection::Current) + ); + } + + #[test] + fn first_playing_session_wins_over_paused_current() { + assert_eq!( + choose_session( + Some(Playback::Paused), + [Playback::Stopped, Playback::Playing, Playback::Playing].into_iter() + ), + Some(Selection::Listed(1)) + ); + } + + #[test] + fn current_is_used_only_when_nothing_is_playing() { + assert_eq!( + choose_session( + Some(Playback::Paused), + [Playback::Stopped, Playback::Paused].into_iter() + ), + Some(Selection::Current) + ); + assert_eq!(choose_session(None, [Playback::Paused].into_iter()), None); + assert_eq!( + choose_session(None, [Playback::Playing].into_iter()), + Some(Selection::Listed(0)) + ); + assert_eq!(choose_session(None, [].into_iter()), None); + } + + #[test] + fn toggle_uses_real_status_and_explicit_commands_stay_explicit() { + for status in [ + Playback::None, + Playback::Stopped, + Playback::Paused, + Playback::Playing, + ] { + let expected = if status == Playback::Playing { + MediaCommand::Pause + } else { + MediaCommand::Play + }; + assert_eq!(resolve_command(MediaCommand::PlayPause, status), expected); + for command in [ + MediaCommand::Play, + MediaCommand::Pause, + MediaCommand::Next, + MediaCommand::Previous, + ] { + assert_eq!(resolve_command(command, status), command); + } + } + } + + #[test] + fn maps_windows_playback_states() { + assert_eq!(map_status(PlaybackStatus::Playing), Playback::Playing); + assert_eq!(map_status(PlaybackStatus::Paused), Playback::Paused); + for status in [ + PlaybackStatus::Stopped, + PlaybackStatus::Closed, + PlaybackStatus::Opened, + PlaybackStatus::Changing, + ] { + assert_eq!(map_status(status), Playback::Stopped); + } + } + + #[test] + fn player_names_never_expose_paths() { + assert_eq!( + app_name(r"C:\Users\someone\Яндекс Музыка.exe"), + "Яндекс Музыка" + ); + assert_eq!(app_name("ru.yandex.desktop.music"), "ru.yandex.desktop.music"); + assert_eq!( + app_name("Microsoft.ZuneMusic_8wekyb3d8bbwe!Microsoft.ZuneMusic"), + "ZuneMusic" + ); + assert_eq!(app_name("C:/players/Player.EXE"), "Player"); + assert_eq!(app_name(""), ""); + } + + #[test] + fn a_media_session_answer_never_sends_a_hardware_key() { + // accepted, declined (disabled control / player said no) and redundant (play while playing) are all final + for applied in [Applied::Accepted, Applied::Declined, Applied::Redundant] { + let keys = Cell::new(0); + let route = finish_command(Ok(Some(applied)), || { + keys.set(keys.get() + 1); + Ok(()) + }) + .unwrap(); + assert!(matches!(route, Route::Gsmtc(_))); + assert_eq!(keys.get(), 0); + } + } + + #[test] + fn no_session_or_failed_gsmtc_sends_exactly_one_hardware_key() { + for attempt in [Ok(None), Err(anyhow::anyhow!("session disappeared"))] { + let keys = Cell::new(0); + assert_eq!( + finish_command(attempt, || { + keys.set(keys.get() + 1); + Ok(()) + }) + .unwrap(), + Route::HardwareKeyFallback + ); + assert_eq!(keys.get(), 1); + } + assert!(finish_command(Ok(None), || bail!("input blocked")).is_err()); + } + + #[test] + fn play_while_playing_and_pause_while_paused_do_nothing() { + assert!(is_redundant(MediaCommand::Play, Playback::Playing)); + assert!(!is_redundant(MediaCommand::Play, Playback::Paused)); + assert!(is_redundant(MediaCommand::Pause, Playback::Paused)); + assert!(is_redundant(MediaCommand::Pause, Playback::Stopped)); + assert!(!is_redundant(MediaCommand::Pause, Playback::Playing)); + for status in [Playback::Playing, Playback::Paused] { + // a toggle is never redundant once resolved against the real state + assert!(!is_redundant(resolve_command(MediaCommand::PlayPause, status), status)); + assert!(!is_redundant(MediaCommand::Next, status)); + assert!(!is_redundant(MediaCommand::Previous, status)); + } + } +} diff --git a/crates/desktop/src/paths.rs b/crates/desktop/src/paths.rs index 3d046e9..564129d 100644 --- a/crates/desktop/src/paths.rs +++ b/crates/desktop/src/paths.rs @@ -16,14 +16,28 @@ fn known_dir(var: &str) -> PathBuf { /// `%APPDATA%\AudioBridge`: keys, pairing secret, `settings.json`, `pairing.txt`. pub fn data_dir() -> PathBuf { + #[cfg(debug_assertions)] + if let Some(dir) = dev_dir() { + return dir; + } known_dir("APPDATA").join(APP_DIR) } /// `%LOCALAPPDATA%\AudioBridge\logs`. pub fn log_dir() -> PathBuf { + #[cfg(debug_assertions)] + if let Some(dir) = dev_dir() { + return dir.join("logs"); + } known_dir("LOCALAPPDATA").join(APP_DIR).join("logs") } +/// Isolated debug identity and logs; release builds never inspect this override. +#[cfg(debug_assertions)] +pub fn dev_dir() -> Option { + std::env::var_os("AUDIOBRIDGE_DEV_DIR").filter(|v| !v.is_empty()).map(PathBuf::from) +} + /// The computer's host name as the user set it (e.g. "LYTEN"). pub fn computer_name() -> String { let mut buf = [0u16; 256]; diff --git a/crates/desktop/src/service.rs b/crates/desktop/src/service.rs new file mode 100644 index 0000000..ea54730 --- /dev/null +++ b/crates/desktop/src/service.rs @@ -0,0 +1,191 @@ +//! Serializes service lifetime and remote requests independently of the blocking window loop. + +use std::sync::mpsc::{Receiver, Sender}; + +use anyhow::{Context, Result}; +use audiobridge_core::session::{Server, ServerConfig}; +use tokio::runtime::Runtime; +use tokio::task::JoinHandle; + +use crate::audio::{self, AudioEngine, Hooks}; +use crate::media::MediaTransport; +use crate::shared::{shared, MainCmd}; + +struct Running { + audio: AudioEngine, + media: MediaTransport, + watchers: Vec>, +} + +impl Running { + fn start(rt: &Runtime, generation: u64) -> Result { + let sh = shared(); + sh.set_generation(generation); + let server = rt.block_on(Server::start(ServerConfig { + data_dir: sh.data_dir.clone(), + pc_name: sh.pc_name.clone(), + })).context("starting network server")?; + let settings = sh.settings(); + server.set_pc_audio_enabled(settings.pc_audio_enabled); + server.set_mic_enabled(settings.mic_enabled); + let capture = server.pc_audio_capture(); + let playout = server.mic_playout(); + sh.install_server(server); + let media = match MediaTransport::spawn(|state| shared().set_media_state(state)) { + Ok(media) => media, + Err(error) => { + shutdown_server(rt); + return Err(error).context("starting media transport"); + } + }; + sh.set_media_sender(media.sender()); + let audio = match AudioEngine::spawn(capture, playout, 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)), + }) { + Ok(audio) => audio, + Err(error) => { + media.shutdown(); + shutdown_server(rt); + return Err(error).context("starting audio engine"); + } + }; + sh.set_audio_sender(audio.sender()); + let watchers = sh.spawn_watchers(rt, audio.sender(), generation); + tracing::info!("service started (generation={generation})"); + Ok(Self { audio, media, watchers }) + } + + fn stop(self, rt: &Runtime) { + // Join cancelled relays before clearing state or starting the next generation. + for watcher in &self.watchers { + watcher.abort(); + } + rt.block_on(async { + for watcher in self.watchers { + let _ = watcher.await; + } + }); + self.audio.shutdown(); + self.media.shutdown(); + shutdown_server(rt); + restore_devices(false); + shared().service_stopped(None); + tracing::info!("service stopped"); + } +} + +fn shutdown_server(rt: &Runtime) { + if let Some(server) = shared().take_server() { + // Complete endpoint closure before binding the same persisted port again. + rt.block_on(server.shutdown()); + } +} + +fn set_default(id: &str, what: &str) { + match audio::set_default_endpoint(id) { + Ok(()) => tracing::info!("default {what} device set to {id}"), + Err(error) => tracing::warn!("setting default {what} device failed: {error:#}"), + } + shared().rescan_devices(); +} + +fn restore_devices(enabled: bool) { + let _com = audio::com::Com::init(); + let Ok(enumerator) = audio::com::enumerator() else { return }; + let devices = audio::devices::summarize(&enumerator); + let settings = shared().settings(); + // Inspect current devices, not a stale asynchronous notification snapshot. + shared().set_devices(&devices); + if enabled { + if settings.mic_enabled && settings.resume_mic_default { + if let Some(id) = &devices.cable_capture_id { + set_default(id, "recording"); + } + } + } else { + shared().remember_mic_default(devices.default_capture_is_cable); + } + if !enabled && devices.default_capture_is_cable { + if let Some(target) = devices.capture_restore_candidate(settings.last_default_capture.as_deref()) { + set_default(&target.id, "recording"); + } else { + tracing::warn!("cannot restore recording device: no non-CABLE endpoint"); + } + } + if devices.default_is_cable && (!enabled || settings.pc_audio_enabled) { + if let Some(target) = devices.render_restore_candidate(settings.last_default_render.as_deref()) { + set_default(&target.id, "playback"); + } else { + tracing::warn!("cannot restore playback device: no non-CABLE endpoint"); + } + } +} + +fn accepts_remote(running: bool, current: u64, incoming: u64) -> bool { + running && current == incoming +} + +pub fn supervise(rt: Runtime, commands: Receiver, windows: Sender<()>) { + let sh = shared(); + let mut running = None; + let mut generation = 0; + let start = |generation| match Running::start(&rt, generation) { + Ok(service) => { + restore_devices(true); + Some(service) + } + Err(error) => { + tracing::error!("service start failed: {error:#}"); + sh.persist_enabled(false); + sh.service_stopped(Some(format!("Не удалось включить AudioBridge: {error:#}"))); + None + } + }; + if sh.settings().service_enabled { + running = start(generation); + } + while let Ok(command) = commands.recv() { + match command { + MainCmd::Show => { let _ = windows.send(()); } + MainCmd::Exit => break, + MainCmd::SetEnabled(enabled) => { + if enabled == running.is_some() { continue; } + sh.persist_enabled(enabled); + if enabled { + generation += 1; + running = start(generation); + } else if let Some(service) = running.take() { + service.stop(&rt); + } + } + MainCmd::Remote { generation: incoming, request } => { + if accepts_remote(running.is_some(), generation, incoming) { + sh.apply_request(request); + } + } + MainCmd::DefaultDevice { generation: incoming, id, what } => { + if accepts_remote(running.is_some(), generation, incoming) { + set_default(&id, what); + } + } + } + } + if let Some(service) = running { + service.stop(&rt); + } + rt.shutdown_timeout(std::time::Duration::from_secs(1)); +} + +#[cfg(test)] +mod tests { + use super::accepts_remote; + + #[test] + fn stopped_and_previous_generation_requests_are_ignored() { + assert!(accepts_remote(true, 2, 2)); + assert!(!accepts_remote(false, 2, 2)); + assert!(!accepts_remote(true, 3, 2)); + } +} diff --git a/crates/desktop/src/settings.rs b/crates/desktop/src/settings.rs index 354cc6e..d5add92 100644 --- a/crates/desktop/src/settings.rs +++ b/crates/desktop/src/settings.rs @@ -8,8 +8,12 @@ use serde::{Deserialize, Serialize}; #[serde(default)] pub struct Settings { pub autostart: bool, + /// Global service switch. Missing in older files means enabled. + pub service_enabled: bool, pub pc_audio_enabled: bool, pub mic_enabled: bool, + /// Restore the pre-off recording route, independently of accepting the phone microphone. + pub resume_mic_default: bool, /// 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, @@ -22,8 +26,10 @@ impl Default for Settings { fn default() -> Self { Self { autostart: true, + service_enabled: true, pc_audio_enabled: true, mic_enabled: true, + resume_mic_default: false, last_default_render: None, last_default_capture: None, } @@ -61,3 +67,22 @@ impl Settings { } } } + +#[cfg(test)] +mod tests { + use super::Settings; + + #[test] + fn old_settings_keep_service_enabled() { + let settings: Settings = serde_json::from_str(r#"{"mic_enabled":false}"#).unwrap(); + assert!(settings.service_enabled); + assert!(!settings.mic_enabled); + } + + #[test] + fn disabled_service_roundtrips_without_changing_features() { + let settings = Settings { service_enabled: false, mic_enabled: false, ..Settings::default() }; + let json = serde_json::to_vec(&settings).unwrap(); + assert_eq!(serde_json::from_slice::(&json).unwrap(), settings); + } +} diff --git a/crates/desktop/src/shared.rs b/crates/desktop/src/shared.rs index efe87d2..6072912 100644 --- a/crates/desktop/src/shared.rs +++ b/crates/desktop/src/shared.rs @@ -1,20 +1,20 @@ //! Process-wide state shared by the UI, tray, audio hooks and status watchers. use std::path::PathBuf; -use std::sync::atomic::{AtomicBool, AtomicIsize, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicIsize, AtomicU64, Ordering}; use std::sync::mpsc::Sender; use std::sync::{Mutex, OnceLock}; use audiobridge_core::pairing::PairingInfo; use audiobridge_core::proto::MAX_LEVEL; -use audiobridge_core::session::{ConnState, PcRequest, PhoneRequest, Server, Status, Volume}; -use tokio::sync::watch; +use audiobridge_core::session::{ConnState, PcMedia, PcRequest, PhoneRequest, Server, Status, Volume}; +use tokio::task::JoinHandle; 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_endpoint, AudioMsg, Desired, DeviceSummary}; +use crate::audio::{AudioMsg, Desired, DeviceSummary}; use crate::cable_install::InstallState; use crate::settings::Settings; use crate::{autostart, tray}; @@ -22,24 +22,30 @@ use crate::{autostart, tray}; pub enum MainCmd { Show, Exit, + SetEnabled(bool), + DefaultDevice { generation: u64, id: String, what: &'static str }, + Remote { generation: u64, request: PcRequest }, } pub struct Shared { pub data_dir: PathBuf, pub pc_name: String, server: Mutex>, - pub status: watch::Receiver, - pub pairing: watch::Receiver>, + status: Mutex, + pairing: Mutex>, settings: Mutex, devices: Mutex, ui_ctx: Mutex>, window_hwnd: AtomicIsize, + window_state: Mutex, tray_hwnd: AtomicIsize, tray_connected: AtomicBool, tray_tip: Mutex, main_tx: Sender, exiting: AtomicBool, + service_generation: AtomicU64, audio_tx: Mutex>>, + media_tx: Mutex>, cable_install: Mutex, } @@ -53,28 +59,34 @@ pub fn shared() -> &'static Shared { pub fn init( data_dir: PathBuf, pc_name: String, - server: Server, + background: bool, settings: Settings, main_tx: Sender, ) -> &'static Shared { - let status = server.status(); - let pairing = server.pairing(); + let status = stopped_status(&settings); + let pairing = std::fs::read_to_string(data_dir.join("pairing.txt")) + .ok() + .and_then(|uri| PairingInfo::from_uri(&uri).ok()); + let tip = if settings.service_enabled { "AudioBridge — Запуск…" } else { "AudioBridge — Выключено" }; let state = Shared { data_dir, pc_name, - server: Mutex::new(Some(server)), - status, - pairing, + server: Mutex::new(None), + status: Mutex::new(status), + pairing: Mutex::new(pairing), settings: Mutex::new(settings), devices: Mutex::new(DeviceSummary::default()), ui_ctx: Mutex::new(None), window_hwnd: AtomicIsize::new(0), + window_state: Mutex::new(if background { WindowState::Closed } else { WindowState::Opening }), tray_hwnd: AtomicIsize::new(0), tray_connected: AtomicBool::new(false), - tray_tip: Mutex::new("AudioBridge — Ожидание телефона".to_owned()), + tray_tip: Mutex::new(tip.to_owned()), main_tx, exiting: AtomicBool::new(false), + service_generation: AtomicU64::new(0), audio_tx: Mutex::new(None), + media_tx: Mutex::new(None), cable_install: Mutex::new(InstallState::Idle), }; if SHARED.set(state).is_err() { @@ -87,6 +99,42 @@ fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { m.lock().unwrap_or_else(|e| e.into_inner()) } + +/// Coalesce requests before native window creation; never queue another window behind an open one. +#[derive(Debug, PartialEq, Eq)] +enum WindowState { + Closed, + Opening, + Open, +} + +impl WindowState { + fn request_open(&mut self) -> bool { + if *self != Self::Closed { + return false; + } + *self = Self::Opening; + true + } +} + +fn stopped_status(settings: &Settings) -> Status { + Status { + state: ConnState::Stopped, + peer_name: None, + path: None, + rtt_ms: None, + pc_audio_enabled: settings.pc_audio_enabled, + mic_enabled: settings.mic_enabled, + mic_demanded: false, + pc_audio: Default::default(), + mic: Default::default(), + pc: Default::default(), + phone: None, + media: Default::default(), + last_error: None, + } +} impl Shared { fn with_server(&self, f: impl FnOnce(&Server)) { if let Some(s) = lock(&self.server).as_ref() { @@ -98,17 +146,67 @@ impl Shared { lock(&self.server).take() } + pub fn install_server(&self, server: Server) { + *lock(&self.status) = server.status().borrow().clone(); + *lock(&self.server) = Some(server); + } + + pub fn status(&self) -> Status { + lock(&self.status).clone() + } + + pub fn pairing_uri(&self) -> Option { + lock(&self.pairing).as_ref().map(PairingInfo::to_uri) + } + + pub fn set_enabled(&self, enabled: bool) { + let _ = self.main_tx.send(MainCmd::SetEnabled(enabled)); + } + + pub fn persist_enabled(&self, enabled: bool) { + self.update_settings(|s| s.service_enabled = enabled); + self.refresh_tray(); + } + + pub fn remember_mic_default(&self, enabled: bool) { + self.update_settings(|s| s.resume_mic_default = enabled); + } + + pub fn service_stopped(&self, error: Option) { + let mut status = stopped_status(&self.settings()); + status.last_error = error; + *lock(&self.status) = status; + *lock(&self.audio_tx) = None; + *lock(&self.media_tx) = None; + self.update_tray(false, "AudioBridge — Выключено".into()); + self.repaint(); + } + + pub fn set_generation(&self, generation: u64) { + self.service_generation.store(generation, Ordering::SeqCst); + } + + fn refresh_tray(&self) { + let hwnd = self.tray_hwnd.load(Ordering::SeqCst); + if hwnd != 0 { + // SAFETY: posting a refresh to our own tray window. + unsafe { + let _ = PostMessageW(Some(HWND(hwnd as *mut _)), tray::WM_TRAY_REFRESH, WPARAM(0), LPARAM(0)); + } + } + } + pub fn settings(&self) -> Settings { lock(&self.settings).clone() } fn update_settings(&self, f: impl FnOnce(&mut Settings)) { - let snapshot = { + { let mut s = lock(&self.settings); f(&mut s); - s.clone() - }; - snapshot.save(&self.data_dir); + // Serialize mutation and tmp+rename with every other settings writer. + s.save(&self.data_dir); + } self.repaint(); } @@ -118,7 +216,7 @@ impl Shared { 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 { + if on && self.settings().service_enabled && 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()), @@ -135,12 +233,14 @@ impl Shared { 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; + let phone = self.status().phone; if phone.is_some_and(|p| !p.mic) { self.set_phone_mic(true); } } - self.set_mic_default(on); + if self.settings().service_enabled { + self.set_mic_default(on); + } } /// Makes the virtual mic ("CABLE Output") the Windows default recording device, or restores @@ -177,6 +277,14 @@ impl Shared { self.with_server(|s| s.set_volume(v)); } + pub fn set_media_state(&self, media: PcMedia) { + self.with_server(|s| s.set_media(media)); + } + + pub fn set_media_sender(&self, sender: crate::media::Sender) { + *lock(&self.media_tx) = Some(sender); + } + /// Switches the connected phone's mic. pub fn set_phone_mic(&self, on: bool) { self.request_phone(PhoneRequest::Mic(on)); @@ -198,6 +306,7 @@ impl Shared { /// Applies a remote-control request from the phone; results flow back through `Status`. pub fn apply_request(&'static self, req: PcRequest) { match req { + PcRequest::Media(_) => {} PcRequest::Volume(_) => tracing::debug!("phone request: {req:?}"), _ => tracing::info!("phone request: {req:?}"), } @@ -207,6 +316,11 @@ impl Shared { PcRequest::MicDefault(on) => self.set_mic_default(on), PcRequest::Volume(level) => self.set_volume(level), PcRequest::Mute(muted) => self.set_mute(muted), + PcRequest::Media(command) => { + if let Some(sender) = lock(&self.media_tx).as_ref() { + sender.send(command); + } + } } } @@ -275,19 +389,10 @@ impl Shared { 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. + /// Makes `id` the default device on the service supervisor, serialized with service shutdown. 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_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(); - }); - if let Err(e) = spawned { - tracing::warn!("set-default-device thread: {e}"); - } + let generation = self.service_generation.load(Ordering::SeqCst); + let _ = self.main_tx.send(MainCmd::DefaultDevice { generation, id, what }); } pub fn cable_install_state(&self) -> InstallState { @@ -304,11 +409,13 @@ impl Shared { pub fn register_window(&self, hwnd: isize, ctx: eframe::egui::Context) { *lock(&self.ui_ctx) = Some(ctx); self.window_hwnd.store(hwnd, Ordering::SeqCst); + *lock(&self.window_state) = WindowState::Open; } pub fn unregister_window(&self) { self.window_hwnd.store(0, Ordering::SeqCst); *lock(&self.ui_ctx) = None; + *lock(&self.window_state) = WindowState::Closed; } /// Requests a repaint only while the window exists; costs nothing otherwise. @@ -329,7 +436,7 @@ impl Shared { let _ = SetForegroundWindow(hwnd); } self.repaint(); - } else if !self.is_exiting() { + } else if !self.is_exiting() && lock(&self.window_state).request_open() { let _ = self.main_tx.send(MainCmd::Show); } } @@ -379,14 +486,24 @@ impl Shared { // ---- watchers --------------------------------------------------------------------------- /// Mirrors status changes into the audio engine, tray and UI. Runs on the tokio runtime. - pub fn spawn_watchers(&'static self, rt: &tokio::runtime::Runtime, audio: Sender) { - let mut status = self.status.clone(); - rt.spawn(async move { + pub fn spawn_watchers( + &'static self, + rt: &tokio::runtime::Runtime, + audio: Sender, + generation: u64, + ) -> Vec> { + let server = lock(&self.server); + let server = server.as_ref().expect("service installed before watchers"); + let mut status = server.status(); + let mut pairing = server.pairing(); + let mut requests = server.take_requests().expect("one request reader per service"); + let status_task = rt.spawn(async move { let mut last = None; let mut mic_glitches = (0u64, 0u64); loop { let (desired, connected, tip) = { let s = status.borrow_and_update(); + *lock(&self.status) = s.clone(); let connected = s.state == ConnState::Connected; let desired = Desired { connected, @@ -425,14 +542,20 @@ impl Shared { } } }); - let mut pairing = self.pairing.clone(); let path = self.data_dir.join("pairing.txt"); - rt.spawn(async move { + let pairing_task = rt.spawn(async move { loop { - let uri = pairing.borrow_and_update().as_ref().map(PairingInfo::to_uri); - if let Some(uri) = uri { - if let Err(e) = std::fs::write(&path, &uri) { - tracing::warn!("cannot write {}: {e}", path.display()); + // Keep the published QR stable across off/on and application restarts. + let info = pairing.borrow_and_update().clone(); + { + let mut cached = lock(&self.pairing); + if let (None, Some(info)) = (&*cached, info) { + let uri = info.to_uri(); + let tmp = path.with_extension("txt.tmp"); + if let Err(e) = std::fs::write(&tmp, &uri).and_then(|()| std::fs::rename(&tmp, &path)) { + tracing::warn!("cannot write {}: {e}", path.display()); + } + *cached = Some(info); } } self.repaint(); @@ -441,5 +564,38 @@ impl Shared { } } }); + let requests_task = rt.spawn(async move { + while let Some(request) = requests.recv().await { + if self.main_tx.send(MainCmd::Remote { generation, request }).is_err() { + break; + } + } + }); + vec![status_task, pairing_task, requests_task] + } +} + +#[cfg(test)] +mod tests { + use super::WindowState; + + #[test] + fn repeated_tray_events_open_only_one_window() { + let mut state = WindowState::Closed; + assert!(state.request_open()); + // WM_LBUTTONUP, WM_LBUTTONDBLCLK and the second UP may precede registration. + assert!(!state.request_open()); + assert!(!state.request_open()); + state = WindowState::Open; + assert!(!state.request_open()); + state = WindowState::Closed; + // Only a new explicit request can open it after X. + assert!(state.request_open()); + } + + #[test] + fn foreground_start_coalesces_show_during_creation() { + let mut state = WindowState::Opening; + assert!(!state.request_open()); } } diff --git a/crates/desktop/src/tray.rs b/crates/desktop/src/tray.rs index ca7052f..514fa81 100644 --- a/crates/desktop/src/tray.rs +++ b/crates/desktop/src/tray.rs @@ -18,7 +18,7 @@ use windows::Win32::UI::WindowsAndMessaging::{ AppendMenuW, CreateIconIndirect, CreatePopupMenu, CreateWindowExW, DefWindowProcW, DestroyIcon, DestroyMenu, DestroyWindow, DispatchMessageW, GetCursorPos, GetMessageW, GetSystemMetrics, PostMessageW, PostQuitMessage, RegisterClassW, RegisterWindowMessageW, SetForegroundWindow, SetMenuDefaultItem, TrackPopupMenu, - TranslateMessage, HICON, ICONINFO, MF_CHECKED, MF_SEPARATOR, MF_STRING, MF_UNCHECKED, MSG, SM_CXSMICON, + TranslateMessage, HICON, ICONINFO, MF_CHECKED, MF_GRAYED, MF_SEPARATOR, MF_STRING, MF_UNCHECKED, MSG, SM_CXSMICON, TPM_NONOTIFY, TPM_RETURNCMD, TPM_RIGHTBUTTON, WINDOW_EX_STYLE, WM_APP, WM_CONTEXTMENU, WM_DESTROY, WM_LBUTTONDBLCLK, WM_LBUTTONUP, WM_NULL, WM_RBUTTONUP, WNDCLASSW, WS_OVERLAPPED, }; @@ -35,10 +35,12 @@ const ID_OPEN: usize = 1; const ID_PC: usize = 2; const ID_MIC: usize = 3; const ID_EXIT: usize = 4; +const ID_ENABLED: usize = 5; static TASKBAR_CREATED: AtomicU32 = AtomicU32::new(0); static ICON_ON: AtomicIsize = AtomicIsize::new(0); static ICON_OFF: AtomicIsize = AtomicIsize::new(0); +static ICON_DISABLED: AtomicIsize = AtomicIsize::new(0); pub struct Tray { hwnd: isize, @@ -72,8 +74,9 @@ pub fn spawn() -> Result { // SAFETY: creating icons and adding the notification icon for our own window. unsafe { let size = GetSystemMetrics(SM_CXSMICON).max(16) as u32; - ICON_ON.store(make_icon(size, true).map_or(0, |h| h.0 as isize), Ordering::SeqCst); - ICON_OFF.store(make_icon(size, false).map_or(0, |h| h.0 as isize), Ordering::SeqCst); + ICON_ON.store(make_icon(size, true, false).map_or(0, |h| h.0 as isize), Ordering::SeqCst); + ICON_OFF.store(make_icon(size, false, false).map_or(0, |h| h.0 as isize), Ordering::SeqCst); + ICON_DISABLED.store(make_icon(size, false, true).map_or(0, |h| h.0 as isize), Ordering::SeqCst); notify(hwnd, NIM_ADD); } let _ = tx.send(Ok(hwnd.0 as isize)); @@ -84,7 +87,7 @@ pub fn spawn() -> Result { let _ = TranslateMessage(&msg); DispatchMessageW(&msg); } - for icon in [&ICON_ON, &ICON_OFF] { + for icon in [&ICON_ON, &ICON_OFF, &ICON_DISABLED] { let h = icon.swap(0, Ordering::SeqCst); if h != 0 { let _ = DestroyIcon(HICON(h as *mut _)); @@ -130,8 +133,14 @@ unsafe fn create_window() -> Result { } /// Builds an HICON from the code-drawn RGBA image. -unsafe fn make_icon(size: u32, connected: bool) -> Result { - let rgba = icon::render(size, connected); +unsafe fn make_icon(size: u32, connected: bool, disabled: bool) -> Result { + let mut rgba = icon::render(size, connected); + if disabled { + for pixel in rgba.as_chunks_mut::<4>().0 { + let grey = ((u16::from(pixel[0]) + u16::from(pixel[1]) + u16::from(pixel[2])) / 3) as u8; + pixel[..3].fill(grey); + } + } unsafe { let bmi = BITMAPINFO { bmiHeader: BITMAPINFOHEADER { @@ -174,7 +183,13 @@ fn copy_wide(dst: &mut [u16], s: &str) { unsafe fn notify(hwnd: HWND, op: windows::Win32::UI::Shell::NOTIFY_ICON_MESSAGE) { let (connected, tip) = shared().tray_state(); - let icon = if connected { &ICON_ON } else { &ICON_OFF }; + let icon = if !shared().settings().service_enabled { + &ICON_DISABLED + } else if connected { + &ICON_ON + } else { + &ICON_OFF + }; let mut data = NOTIFYICONDATAW { cbSize: size_of::() as u32, hWnd: hwnd, @@ -197,9 +212,12 @@ unsafe fn show_menu(hwnd: HWND) { unsafe { let Ok(menu) = CreatePopupMenu() else { return }; let _ = AppendMenuW(menu, MF_STRING, ID_OPEN, w!("Открыть")); + let label = if s.service_enabled { w!("Выключить AudioBridge") } else { w!("Включить AudioBridge") }; + let _ = AppendMenuW(menu, MF_STRING, ID_ENABLED, label); let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); - let _ = AppendMenuW(menu, MF_STRING | check(s.pc_audio_enabled), ID_PC, w!("Звук компьютера")); - let _ = AppendMenuW(menu, MF_STRING | check(s.mic_enabled), ID_MIC, w!("Микрофон")); + let disabled = if s.service_enabled { MF_STRING } else { MF_STRING | MF_GRAYED }; + let _ = AppendMenuW(menu, disabled | check(s.pc_audio_enabled), ID_PC, w!("Звук компьютера")); + let _ = AppendMenuW(menu, disabled | check(s.mic_enabled), ID_MIC, w!("Микрофон")); let _ = AppendMenuW(menu, MF_SEPARATOR, 0, PCWSTR::null()); let _ = AppendMenuW(menu, MF_STRING, ID_EXIT, w!("Выход")); let _ = SetMenuDefaultItem(menu, ID_OPEN as u32, 0); @@ -212,6 +230,7 @@ unsafe fn show_menu(hwnd: HWND) { let _ = DestroyMenu(menu); match cmd.0 as usize { ID_OPEN => shared().show_window(), + ID_ENABLED => shared().set_enabled(!s.service_enabled), ID_PC => shared().set_pc_audio(!s.pc_audio_enabled), ID_MIC => shared().set_mic(!s.mic_enabled), ID_EXIT => shared().request_exit(), diff --git a/crates/desktop/src/ui.rs b/crates/desktop/src/ui.rs index 9719dc4..63eb775 100644 --- a/crates/desktop/src/ui.rs +++ b/crates/desktop/src/ui.rs @@ -6,7 +6,6 @@ use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use audiobridge_core::pairing::PairingInfo; use audiobridge_core::session::{ConnState, PathKind, Status}; use eframe::egui::{ self, pos2, vec2, Align, Align2, Color32, CornerRadius, FontData, FontDefinitions, FontFamily, FontId, Layout, @@ -395,8 +394,8 @@ impl eframe::App for UiApp { fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { let sh = shared(); - let status = sh.status.borrow().clone(); - let pairing = sh.pairing.borrow().as_ref().map(PairingInfo::to_uri); + let status = sh.status(); + let pairing = sh.pairing_uri(); let settings = sh.settings(); let devices = sh.devices(); let connected = status.state == ConnState::Connected; @@ -412,11 +411,23 @@ impl eframe::App for UiApp { egui::Frame::new().inner_margin(Margin::same(PAD as i8)).show(ui, |ui| { egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = GAP; - hero(ui, &status, live); - warnings(ui, &devices, &settings); - if !connected || self.show_qr { + hero(ui, &status, live, settings.service_enabled); + let label = if settings.service_enabled { "Выключить AudioBridge" } else { "Включить AudioBridge" }; + if wide_button(ui, label, None) { + sh.set_enabled(!settings.service_enabled); + } + if !settings.service_enabled { + card(ui, CARD, CARD_STROKE, |ui| { + ui.label("Выключено. Телефон отключён; настройки сохранены."); + if let Some(error) = &status.last_error { + ui.colored_label(RED, error); + } + }); + } else if !connected || self.show_qr { + warnings(ui, &devices, &settings); self.qr_card(ui, pairing.as_deref(), connected, &status); } else { + warnings(ui, &devices, &settings); connected_cards(ui, &status, &settings, &devices, &mut self.volume_edit); if wide_button(ui, "Показать QR-код", Some(icon_qr)) { self.show_qr = true; @@ -434,7 +445,7 @@ impl eframe::App for UiApp { } /// Gradient header: logo, name, connection state and live level bars. -fn hero(ui: &mut Ui, status: &Status, live: bool) { +fn hero(ui: &mut Ui, status: &Status, live: bool, enabled: bool) { let (rect, _) = ui.allocate_exact_size(vec2(ui.available_width(), 104.0), Sense::hover()); let p = ui.painter(); gradient_rect(p, rect, 20.0, INDIGO, TEAL); @@ -445,7 +456,7 @@ fn hero(ui: &mut Ui, status: &Status, live: bool) { let x = logo.right() + 14.0; p.text(pos2(x, rect.center().y - 12.0), Align2::LEFT_CENTER, "AudioBridge", semibold(22.0), Color32::WHITE); - let (text, color) = status_line(status); + let (text, color) = if enabled { status_line(status) } else { ("Выключено".into(), MUTED) }; let dot = pos2(x + 5.0, rect.center().y + 15.0); p.circle_filled(dot, 7.0, Color32::from_black_alpha(60)); p.circle_filled(dot, 4.0, color); diff --git a/docs/screenshots/desktop-off.png b/docs/screenshots/desktop-off.png new file mode 100644 index 0000000..28527a1 Binary files /dev/null and b/docs/screenshots/desktop-off.png differ diff --git a/docs/screenshots/headset-single.png b/docs/screenshots/headset-single.png new file mode 100644 index 0000000..eaf4a9d Binary files /dev/null and b/docs/screenshots/headset-single.png differ diff --git a/docs/screenshots/headset.png b/docs/screenshots/headset.png new file mode 100644 index 0000000..de69627 Binary files /dev/null and b/docs/screenshots/headset.png differ diff --git a/docs/screenshots/service-off.png b/docs/screenshots/service-off.png new file mode 100644 index 0000000..316f245 Binary files /dev/null and b/docs/screenshots/service-off.png differ