Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 18 additions & 10 deletions AGENTS.md

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-кода.

## Что нужно
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
65 changes: 65 additions & 0 deletions android/app/src/main/java/app/audiobridge/AppState.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean> = _autostartDone.asStateFlow()

private val _headset = MutableStateFlow(readHeadset(sp))
val headset: StateFlow<HeadsetSettings> = _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<Boolean> = _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<PairedPc> {
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.
Expand Down Expand Up @@ -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
Expand All @@ -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<String?>(null)

/** The last headphone command the service handled, for the settings screen. */
val lastMediaKey = MutableStateFlow<MediaKeyLog?>(null)
}

object Native {
Expand Down
4 changes: 2 additions & 2 deletions android/app/src/main/java/app/audiobridge/BootReceiver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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) {
Expand Down
24 changes: 23 additions & 1 deletion android/app/src/main/java/app/audiobridge/BridgeService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
34 changes: 34 additions & 0 deletions android/app/src/main/java/app/audiobridge/BridgeStatus.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
Loading
Loading