diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml
index 9f41d69f2..a37046037 100644
--- a/amethyst/src/main/AndroidManifest.xml
+++ b/amethyst/src/main/AndroidManifest.xml
@@ -260,6 +260,12 @@
android:stopWithTask="false"
android:exported="false" />
+
+
{
+ startForegroundWithType(includeMic = true)
+ }
+
+ ACTION_STOP -> {
+ stopForeground(STOP_FOREGROUND_REMOVE)
+ stopSelf()
+ }
+
+ else -> {
+ startForegroundWithType(includeMic = promoted)
+ }
+ }
+ if (wakeLock?.isHeld != true) {
+ wakeLock?.acquire(WAKE_LOCK_TIMEOUT_MS)
+ }
+ return START_STICKY
+ }
+
+ private fun startForegroundWithType(includeMic: Boolean) {
+ promoted = includeMic
+ val notification = buildNotification()
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ val type =
+ if (includeMic) {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
+ } else {
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
+ }
+ startForeground(NOTIFICATION_ID, notification, type)
+ } else {
+ startForeground(NOTIFICATION_ID, notification)
+ }
+ }
+
+ private fun buildNotification(): Notification {
+ val openIntent =
+ PendingIntent.getActivity(
+ this,
+ 0,
+ Intent(this, MainActivity::class.java),
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+ val stopIntent =
+ PendingIntent.getService(
+ this,
+ 1,
+ Intent(this, AudioRoomForegroundService::class.java).apply { action = ACTION_STOP },
+ PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
+ )
+
+ val title =
+ if (promoted) {
+ getString(R.string.audio_room_notification_broadcasting)
+ } else {
+ getString(R.string.audio_room_notification_listening)
+ }
+
+ return NotificationCompat
+ .Builder(this, CHANNEL_ID)
+ .setContentTitle(title)
+ .setContentText(getString(R.string.audio_room_notification_text))
+ .setSmallIcon(R.drawable.amethyst)
+ .setOngoing(true)
+ .setContentIntent(openIntent)
+ .addAction(0, getString(R.string.audio_room_notification_stop), stopIntent)
+ .setCategory(NotificationCompat.CATEGORY_CALL)
+ .build()
+ }
+
+ private fun createNotificationChannel() {
+ val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
+ if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
+ mgr.createNotificationChannel(
+ NotificationChannel(
+ CHANNEL_ID,
+ getString(R.string.audio_room_notification_channel),
+ NotificationManager.IMPORTANCE_LOW,
+ ).apply {
+ description = getString(R.string.audio_room_notification_channel_description)
+ setShowBadge(false)
+ },
+ )
+ }
+ }
+
+ override fun onDestroy() {
+ wakeLock?.takeIf { it.isHeld }?.release()
+ wakeLock = null
+ super.onDestroy()
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ companion object {
+ private const val CHANNEL_ID = "audio_room_foreground"
+ private const val NOTIFICATION_ID = 0xA0D10
+ private const val ACTION_PROMOTE_TO_MIC = "com.vitorpamplona.amethyst.audio_room.PROMOTE_MIC"
+ private const val ACTION_STOP = "com.vitorpamplona.amethyst.audio_room.STOP"
+ private const val WAKE_LOCK_TIMEOUT_MS = 12L * 60 * 60 * 1000 // 12 hours hard cap
+
+ /** Start the service in listener-only foreground mode. Idempotent. */
+ fun startListening(context: Context) {
+ ContextCompat.startForegroundService(
+ context,
+ Intent(context, AudioRoomForegroundService::class.java),
+ )
+ }
+
+ /**
+ * Re-`startForeground` with mediaPlayback + microphone type while the
+ * user is broadcasting. Required by Android 14's split foreground-type
+ * permission model.
+ */
+ fun promoteToMicrophone(context: Context) {
+ ContextCompat.startForegroundService(
+ context,
+ Intent(context, AudioRoomForegroundService::class.java).apply {
+ action = ACTION_PROMOTE_TO_MIC
+ },
+ )
+ }
+
+ /** Stop and remove the foreground notification. Idempotent. */
+ fun stop(context: Context) {
+ context.stopService(Intent(context, AudioRoomForegroundService::class.java))
+ }
+ }
+}
diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt
index 51ead34af..68635cd9c 100644
--- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt
+++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/audiorooms/room/AudioRoomStage.kt
@@ -68,6 +68,7 @@ import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActiviti
import com.vitorpamplona.amethyst.commons.viewmodels.AudioRoomViewModel
import com.vitorpamplona.amethyst.commons.viewmodels.BroadcastUiState
import com.vitorpamplona.amethyst.commons.viewmodels.ConnectionUiState
+import com.vitorpamplona.amethyst.service.audiorooms.AudioRoomForegroundService
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -95,20 +96,19 @@ import kotlinx.coroutines.launch
* Clubhouse-style audio-room "stage" rendered in place of the video player when
* the underlying activity is a NIP-53 kind 30312 [MeetingSpaceEvent].
*
- * Responsibilities (M1 = listener-only):
+ * Responsibilities:
* - Displays host / speaker / audience avatars parsed from the 30312 `p` tags.
- * - Publishes kind 10312 presence on enter and every 30 s while composed.
- * - Hand-raise toggle flips the `["hand","1"|"0"]` tag on that presence event
- * so a host on any NIP-53 client can see the request and promote.
- * - **Connect button** opens the listener-side audio pipeline (HTTP →
- * WebTransport over QUIC → MoQ → Opus decode → AudioTrack). On Connected,
- * auto-subscribes to every host's speaker track.
- * - **Mute toggle** silences the local audio device without halting the
- * network pipeline so unmute is instant.
- *
- * Speaker-side (mic capture + publish) is M5+ in the audio-rooms completion
- * plan — `nestsClient/plans/2026-04-26-audio-rooms-completion.md`. Until then
- * the presence event omits the `muted` tag (we have no mic to mute).
+ * Avatars get a primary-color ring while their MoQ track is delivering audio.
+ * - Publishes kind 10312 presence on enter and every 30 s while composed,
+ * reflecting the hand-raise + mic-mute state.
+ * - **Listener** path: Connect button → HTTP → WebTransport over QUIC → MoQ
+ * listener session → Opus decode → AudioTrack. Auto-subscribes to every
+ * host + speaker track. Mute toggle silences the local device without
+ * halting the network so unmute is instant.
+ * - **Speaker** path (only for users in the room's `p` tags as host or
+ * speaker): Talk button → RECORD_AUDIO permission → second MoQ session
+ * in publisher mode → AudioRecord → MediaCodec Opus encoder → MoQ
+ * OBJECT_DATAGRAM emission. Live indicator + mic-mute toggle.
*/
@Composable
fun AudioRoomStage(
@@ -144,25 +144,6 @@ private fun AudioRoomStageContent(
val scope = rememberCoroutineScope()
val account = accountViewModel.account
- // Publish initial presence on enter and refresh every PRESENCE_REFRESH_MS while composed.
- LaunchedEffect(event.address().toValue(), handRaised) {
- publishPresence(account, event, handRaised)
- while (isActive) {
- delay(PRESENCE_REFRESH_MS)
- publishPresence(account, event, handRaised)
- }
- }
-
- // Best-effort "leave" — re-publish a lowered-hand presence so peers see us
- // drop sooner than the 30 s heartbeat would otherwise allow.
- DisposableEffect(event.address().toValue()) {
- onDispose {
- scope.launch(Dispatchers.IO) {
- runCatching { publishPresence(account, event, handRaised = false) }
- }
- }
- }
-
val serviceBase = event.service()
val roomId = event.address().dTag
val audioAvailable = !serviceBase.isNullOrBlank() && roomId.isNotBlank()
@@ -193,6 +174,52 @@ private fun AudioRoomStageContent(
val ui = viewModel?.uiState?.collectAsState()?.value
val speakingNow = ui?.speakingNow ?: persistentSetOf()
+ // Foreground-service lifecycle. The service is a process-anchor — it
+ // doesn't own the audio resources (those live in the VM) but its
+ // foreground notification + wake-lock keep audio playing with the
+ // screen off. Promotes to mediaPlayback+microphone type while the user
+ // broadcasts (Android 14+ split foreground-type permission model).
+ val context = LocalContext.current
+ val isConnected = ui?.connection is ConnectionUiState.Connected
+ val isBroadcasting = ui?.broadcast is BroadcastUiState.Broadcasting
+ LaunchedEffect(isConnected, isBroadcasting) {
+ when {
+ isConnected && isBroadcasting -> AudioRoomForegroundService.promoteToMicrophone(context)
+ isConnected -> AudioRoomForegroundService.startListening(context)
+ else -> AudioRoomForegroundService.stop(context)
+ }
+ }
+ DisposableEffect(Unit) {
+ onDispose { AudioRoomForegroundService.stop(context) }
+ }
+ // Mic state for the presence event: null when not broadcasting (we have
+ // no mic stream to be muted on), explicit true/false while live so other
+ // clients can render the mute indicator on our avatar.
+ val micMutedTag: Boolean? =
+ when (val b = ui?.broadcast) {
+ is BroadcastUiState.Broadcasting -> b.isMuted
+ else -> null
+ }
+
+ // Publish initial presence on enter and refresh every PRESENCE_REFRESH_MS while composed.
+ LaunchedEffect(event.address().toValue(), handRaised, micMutedTag) {
+ publishPresence(account, event, handRaised, micMutedTag)
+ while (isActive) {
+ delay(PRESENCE_REFRESH_MS)
+ publishPresence(account, event, handRaised, micMutedTag)
+ }
+ }
+
+ // Best-effort "leave" — re-publish a lowered-hand presence so peers see us
+ // drop sooner than the 30 s heartbeat would otherwise allow.
+ DisposableEffect(event.address().toValue()) {
+ onDispose {
+ scope.launch(Dispatchers.IO) {
+ runCatching { publishPresence(account, event, handRaised = false, micMuted = null) }
+ }
+ }
+ }
+
Card(
modifier = Modifier.fillMaxWidth().padding(8.dp),
shape = RoundedCornerShape(12.dp),
@@ -530,16 +557,17 @@ private suspend fun publishPresence(
account: com.vitorpamplona.amethyst.model.Account,
event: MeetingSpaceEvent,
handRaised: Boolean,
+ micMuted: Boolean?,
) {
runCatching {
account.signAndComputeBroadcast(
MeetingRoomPresenceEvent.build(
root = event,
handRaised = handRaised,
- // muted tag intentionally omitted — listener-only mute (M1)
- // silences our speakers, not a mic we don't yet broadcast.
- // Re-evaluate when M5 (publisher path) lands.
- muted = null,
+ // null = not broadcasting (no mic stream); true/false reflect
+ // the speaker's current mic state so other clients can show a
+ // mute indicator on the avatar.
+ muted = micMuted,
),
)
}
diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml
index efacb3dd5..1f54d9aea 100644
--- a/amethyst/src/main/res/values/strings.xml
+++ b/amethyst/src/main/res/values/strings.xml
@@ -512,6 +512,12 @@
Live
Broadcast failed: %1$s
Microphone access is required to talk in this room.
+ Audio rooms
+ Keeps audio playing while a room is open.
+ Audio room connected
+ Audio room — Live
+ Tap to return.
+ Stop
Videos
Articles
Private Bookmarks
diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt
index c6c816196..06f50d90e 100644
--- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt
+++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/AudioRoomViewModel.kt
@@ -109,6 +109,12 @@ class AudioRoomViewModel(
private var requestedSpeakers: Set = emptySet()
private var closed = false
+ // Auto-reconnect state — incremented every time we observe Failed and
+ // schedule a retry; reset to 0 on Connected or on a user-initiated
+ // connect()/disconnect() so manual recovery starts fresh.
+ private var autoRetryAttempts = 0
+ private var autoRetryJob: Job? = null
+
// Speaker / publisher path
private var speaker: NestsSpeaker? = null
private var broadcastHandle: BroadcastHandle? = null
@@ -133,6 +139,11 @@ class AudioRoomViewModel(
val current = _uiState.value.connection
if (current is ConnectionUiState.Connecting || current is ConnectionUiState.Connected) return
+ // User-initiated connect — clear any pending auto-retry timer + counter.
+ autoRetryJob?.cancel()
+ autoRetryJob = null
+ autoRetryAttempts = 0
+
_uiState.update { it.copy(connection = ConnectionUiState.Connecting(ConnectionUiState.Step.ResolvingRoom)) }
connectJob =
@@ -264,12 +275,17 @@ class AudioRoomViewModel(
/** Tear down without finalizing the VM (e.g. user pressed Disconnect). */
fun disconnect() {
if (closed) return
+ autoRetryJob?.cancel()
+ autoRetryJob = null
+ autoRetryAttempts = 0
teardownBroadcast(BroadcastUiState.Idle)
teardown(targetState = ConnectionUiState.Idle)
}
override fun onCleared() {
closed = true
+ autoRetryJob?.cancel()
+ autoRetryJob = null
teardownBroadcast(BroadcastUiState.Idle)
teardown(targetState = ConnectionUiState.Closed)
super.onCleared()
@@ -323,13 +339,87 @@ class AudioRoomViewModel(
_uiState.update { ui ->
ui.copy(connection = state.toUiState(ui.connection))
}
- if (state is NestsListenerState.Connected) {
- reconcileSubscriptions()
+ when (state) {
+ is NestsListenerState.Connected -> {
+ autoRetryAttempts = 0
+ reconcileSubscriptions()
+ }
+
+ is NestsListenerState.Failed -> {
+ scheduleAutoRetry()
+ }
+
+ else -> { /* no extra side effect */ }
}
}
}
}
+ /**
+ * Auto-reconnect on listener Failed with capped exponential backoff.
+ * The user can still tap Connect manually at any time; that resets the
+ * retry counter via the `connect()` path.
+ */
+ private fun scheduleAutoRetry() {
+ if (closed) return
+ if (autoRetryJob?.isActive == true) return
+ if (autoRetryAttempts >= MAX_AUTO_RETRIES) return
+
+ val attempt = autoRetryAttempts
+ autoRetryAttempts = attempt + 1
+ val backoffMs = minOf(MAX_RETRY_BACKOFF_MS, INITIAL_RETRY_BACKOFF_MS shl attempt)
+ autoRetryJob =
+ viewModelScope.launch {
+ delay(backoffMs)
+ if (closed) return@launch
+ if (_uiState.value.connection !is ConnectionUiState.Failed) return@launch
+ // Drop the previous listener cleanly before starting a new
+ // attempt. Reset state to Idle so the connect() guard passes.
+ teardown(targetState = ConnectionUiState.Idle)
+ connectInternal()
+ }
+ }
+
+ /**
+ * Internal connect that bypasses the auto-retry counter reset — used by
+ * [scheduleAutoRetry] so successive auto-retries continue to back off.
+ */
+ private fun connectInternal() {
+ if (closed) return
+ val current = _uiState.value.connection
+ if (current is ConnectionUiState.Connecting || current is ConnectionUiState.Connected) return
+
+ _uiState.update { it.copy(connection = ConnectionUiState.Connecting(ConnectionUiState.Step.ResolvingRoom)) }
+
+ connectJob =
+ viewModelScope.launch {
+ try {
+ val l =
+ connector.connect(
+ httpClient = httpClient,
+ transport = transport,
+ scope = viewModelScope,
+ serviceBase = serviceBase,
+ roomId = roomId,
+ signer = signer,
+ )
+ if (closed) {
+ runCatching { l.close() }
+ return@launch
+ }
+ listener = l
+ observeListenerState(l)
+ } catch (ce: CancellationException) {
+ throw ce
+ } catch (t: Throwable) {
+ _uiState.update {
+ it.copy(connection = ConnectionUiState.Failed(t.message ?: t::class.simpleName ?: "connect failed"))
+ }
+ scheduleAutoRetry()
+ }
+ }
+ }
+
private fun reconcileSubscriptions() {
val l = listener ?: return
if (_uiState.value.connection !is ConnectionUiState.Connected) return
@@ -588,6 +678,15 @@ sealed class BroadcastUiState {
*/
const val SPEAKING_TIMEOUT_MS: Long = 250L
+/** Max number of auto-reconnect attempts after a Failed listener state. */
+private const val MAX_AUTO_RETRIES = 3
+
+/** First auto-retry backoff in ms; doubles each subsequent attempt. */
+private const val INITIAL_RETRY_BACKOFF_MS = 1_000L
+
+/** Cap on the auto-retry backoff so the wait stays human-acceptable. */
+private const val MAX_RETRY_BACKOFF_MS = 16_000L
+
/**
* Indirection over the top-level `connectNestsListener` so tests can drive
* a fake [NestsListener] directly without standing up an HTTP fake +