Files
amethyst/commons
Claude 46f693800d feat(audio-rooms): M2 multi-speaker subscribe + per-speaker speaking indicator
Listener side now subscribes to every host AND speaker on the stage (was
just hosts) and exposes a `speakingNow: ImmutableSet<String>` derived
from MoQ object arrival. Each on-stage avatar gets a primary-color ring
while its track is delivering audio (debounced 250 ms — ~12 Opus
frames — so packet jitter doesn't make the ring flicker).

ViewModel:
- `AudioRoomViewModel.uiState` gains `speakingNow`.
- New `onSpeakerActivity(pubkey)` is invoked once per received MoQ object
  via a `Flow.onEach` tap on `SubscribeHandle.objects` (before the
  AudioRoomPlayer consumes it). Each invocation (re)arms a per-speaker
  expiry coroutine that clears the entry after `SPEAKING_TIMEOUT_MS`.
- speakingNow is cleared whenever the underlying subscription closes
  (speaker removed from room) or the listener tears down (disconnect /
  onCleared).

Screen:
- AudioRoomViewModel is now hoisted up to AudioRoomStageContent so the
  speaker rows can read speakingNow alongside the connection UI.
- StagePeopleRow wraps each ClickableUserPicture in a 2 dp circular
  border when the participant is in `speakingNow`.
- updateSpeakers now receives `(hosts + speakers).pubKeys` so dynamic
  speaker promotion via NIP-53 event updates flows through to the
  reconcile loop already in M1.

Test:
- Adds `speakingNowClearsOnTeardown` to AudioRoomViewModelTest. The
  per-frame activity path is exercised end-to-end by the existing
  AudioRoomPlayerTest in :nestsClient (which uses the in-memory pipe).

Verified: `./gradlew spotlessApply :commons:jvmTest :nestsClient:jvmTest
:amethyst:compilePlayDebugKotlin` green.
2026-04-26 03:02:34 +00:00
..