feat: animate local speaker ring from MoQ frame transmission

Wire the local user's avatar into the existing speaking-ring animation so
the host/speaker sees the same green ring + amplitude glow on their own
cell that remote speakers already get.

Ground truth is the broadcaster's `publisher.send(opus)` success: the new
`onLevel` callback fires only after a frame actually leaves on the wire,
computed from the raw PCM peak (shared `peakAmplitude` util used by both
the player decode loop and the broadcaster capture loop). This means the
animation reflects what the relay sees, not any UI-side mute / role
state that could be stale or buggy — if frames are flowing, the ring
plays.

Plumbing: `NestsSpeaker.startBroadcasting` gains an optional `onLevel`
that the moq-lite + IETF broadcasters both honour, the reconnecting
wrapper replays it on every session reissue (alongside the existing
mute-intent replay), and `NestViewModel.startBroadcast` hands it a
lambda that calls the existing `onSpeakerActivity` / `onAudioLevel`
plumbing keyed on the local pubkey. The 250 ms speaking-timeout fades
the ring naturally when the user mutes or stops broadcasting.
This commit is contained in:
Claude
2026-05-02 01:17:40 +00:00
parent 0c4def6491
commit 5d955eff99
11 changed files with 108 additions and 26 deletions
@@ -479,7 +479,21 @@ class NestViewModel(
}
speaker = s
observeSpeakerState(s)
val handle = s.startBroadcasting()
// The speaker-side `onLevel` callback is the ground
// truth for the local-speaking ring: it fires only
// after a frame actually reaches the wire (see
// NestMoqLiteBroadcaster.start). Reusing the same
// `onSpeakerActivity` / `onAudioLevel` plumbing the
// remote speakers use means the local avatar gets
// the same ring + glow + 250 ms timeout fade as
// any other speaker — no separate UI branch needed.
val handle =
s.startBroadcasting(
onLevel = { level ->
onSpeakerActivity(speakerPubkeyHex)
onAudioLevel(speakerPubkeyHex, level)
},
)
broadcastHandle = handle
_uiState.update { it.copy(broadcast = BroadcastUiState.Broadcasting(isMuted = false)) }
} catch (ce: CancellationException) {