Skip to content

feat(voice): DM voice/video calls — call button, ring/accept/decline UI (#127)#134

Merged
krazyjakee merged 3 commits into
masterfrom
feat/dm-voice-video-calls
Jun 14, 2026
Merged

feat(voice): DM voice/video calls — call button, ring/accept/decline UI (#127)#134
krazyjakee merged 3 commits into
masterfrom
feat/dm-voice-video-calls

Conversation

@krazyjakee

Copy link
Copy Markdown
Contributor

Closes #127. Implements DM voice/video calling on top of the existing channel-agnostic voice stack, consuming the new server-side call signaling from accordserver #32 (DM voice/join + call.ring/call.decline/call.cancel/call.end).

accordkit (SDK)

  • AccordCallSignal model for the call.* events.
  • Gateway streams onCallRing / onCallDecline / onCallCancel / onCallEnd (dispatch + getters), forwarded on AccordClient.
  • REST: VoiceApi.ring / declineCall / cancelCall (POST /channels/{id}/call/{ring,decline,cancel}).

Client

  • CallController (Riverpod, keepAlive) owns app-wide call state:
    • outgoing call = join voice (spaceId: null) + call/ring; optional video;
    • incoming ring state from call.ring;
    • accept is an implicit voice join, declinecall/decline, cancel → leave + call/cancel;
    • gateway-driven transitions; a peer joining the ringing channel clears the "Calling…" state, call.decline ends our outgoing call, call.cancel/call.end dismiss an incoming ring.
  • call.* events wired into the per-connection event handler (they're targeted at DM participants, so they're honored on whichever connection owns the DM).
  • VoiceController.join now accepts a nullable spaceId for DM calls.
  • DM header: audio + video call buttons (1:1 and group DM). Outgoing calls open the full-screen voice view with a "Calling…" banner; the view auto-pops when the call ends.
  • IncomingCallOverlay: app-wide ring banner (caller avatar/name, accept/decline), a looping ringtone, and a "Call declined" snackbar. In-call UI reuses VoiceChannelView with no space chrome.
  • Ringtone assets (incoming + outgoing ringback), generated tones.

Tests

  • accordkit: endpoint (ring/decline/cancel paths + body) and gateway dispatch tests — dart test green.
  • CallState.copyWith unit tests (clear-flag semantics) — flutter test green.
  • flutter analyze clean across all changed files.

Notes / follow-ups

  • Mid-call mute/deafen isn't broadcast over the gateway for DM calls (the gateway voice-state op is space-scoped); the initial join state is sent and local toggles work. Group-call presence still updates via voice.state_update on join/leave.
  • The PiP overlay remains space-only for now (its reopen path is keyed on a space); a DM call stays in the full-screen view.
  • Unrelated pre-existing analyzer errors in lib/features/spaces/views/accord_home_channels.dart exist on the base branch and are untouched here.

🤖 Generated with Claude Code

Adds 1:1 and group-DM calling on top of the existing channel-agnostic
voice stack, wiring the new server-side call signaling (accordserver #32).

accordkit:
- AccordCallSignal model + call.ring/decline/cancel/end gateway streams
- VoiceApi.ring / declineCall / cancelCall REST endpoints
- forwarded streams on AccordClient

client:
- CallController: outgoing call (join voice + ring), incoming ring state,
  accept (implicit join) / decline / cancel, and gateway-driven transitions
- call.* events wired through the per-connection event handler; a peer
  joining the ringing channel clears the "Calling…" state
- VoiceController.join accepts a null spaceId for DM calls
- call/video buttons in the DM conversation header; outgoing calls open the
  full-screen voice view with a "Calling…" banner that pops when the call ends
- IncomingCallOverlay banner (accept/decline) mounted app-wide, with a
  looping ringtone and a "Call declined" snackbar
- ringtone assets (incoming + outgoing ringback)

Tests: accordkit endpoint + gateway dispatch tests; CallState.copyWith unit
tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The outgoing-call ringback was dead code: `startRingtone(outgoing: true)`
and the bundled `ring_outgoing.wav` existed but nothing called them, so the
caller saw a "Calling…" spinner in silence while the callee heard a ringtone.
Wire the ringback in `startCall` and stop it the moment the call is answered,
declined, or cancelled.

Also fix a state leak: hitting the in-call "Disconnect" button while still
ringing routes through `VoiceController.leave()`, which never cleared
`CallController.outgoingChannelId` — so the "Calling…" state (and now the
ringback) leaked after a manual hang-up. A `build()` listener on the voice
channel clears the outgoing state and silences the ringback whenever the
ringing session ends by any path the explicit transitions don't cover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- acceptIncoming: return null when voice join fails, preventing the full-screen
  view from opening for a channel we aren't actually connected to
- startCall: set outgoingChannelId synchronously before the first await so
  concurrent taps (before the join completes) are blocked by hasOutgoing;
  also clear it on join failure or missing client
- cancelOutgoing: guard at top — no-ops when there is no outgoing call, so
  it cannot disconnect an unrelated voice session
- startCall / markAnswered / handleDecline / cancelOutgoing: wire the outgoing
  ringback tone (ring_outgoing.wav was added but never played)
- handleRing / handleDecline / handleCancelOrEnd / accept/declineIncoming:
  wrap fire-and-forget sound futures in unawaited() to make intent explicit
- test: add hasOutgoing group documenting the re-entrancy guard semantics

Copy link
Copy Markdown
Contributor Author

Code Review — DM voice/video calls

Overall the PR is well-structured and follows existing conventions. Five issues were found and fixed in the follow-up commit (267909f). Ranked by severity:


🔴 HIGH — acceptIncoming opens the full-screen view even when the voice join fails

File: lib/features/voice/controllers/call.dart

acceptIncoming() always returned incoming.channelId, even when the underlying voice.join() failed. The caller (IncomingCallOverlay._accept and future callers) would then call showFullScreenVoice for a channel the user is not connected to, showing a broken in-call UI.

Fix: After join, check voiceControllerProvider.channelId == incoming.channelId and return null if the join didn't land. The overlay already guards on channelId == null.


🔴 HIGH — cancelOutgoing disconnects any active voice session when no outgoing call is pending

File: lib/features/voice/controllers/call.dart

// Before — voice.leave() ran unconditionally:
Future<void> cancelOutgoing() async {
  final channelId = state.outgoingChannelId;           // may be null
  ...
  await ref.read(voiceControllerProvider.notifier).leave(); // always ran!
  if (channelId != null) { ... }
}

If called with no outgoing call (e.g. from a defensive code path), this silently disconnected the user from whatever voice channel they were in.

Fix: Early-return guard at the top: if (channelId == null) return;. Also moved state clear and stopRingtone before the async leave for immediate UI feedback.


🟡 MEDIUM — ring_outgoing.wav asset is present but never played

File: lib/features/voice/controllers/call.dart

SoundManager.startRingtone({bool outgoing = false}) was added with an outgoing parameter pointing to sfx/ring_outgoing.wav, but startCall never called startRingtone at all. The outgoing ringback tone was therefore completely silent.

Additionally, markAnswered and handleDecline never called stopRingtone, so if the asset had been wired up, the tone would have looped indefinitely after the call connected or was declined.

Fix:

  • startCall: call startRingtone(outgoing: true) after a successful join
  • markAnswered + handleDecline + cancelOutgoing: call stopRingtone()

🟡 MEDIUM — Double-tap on the call button sends two call/ring REST calls

File: lib/features/voice/controllers/call.dart

The original hasOutgoing guard was checked at the start of startCall, but outgoingChannelId was only set after await voice.join(...). Two rapid taps would both pass the guard before either await completed, with the second ring landing after the first join finished (since _serialize queues joins but not the surrounding startCall logic).

Fix: Set outgoingChannelId synchronously before the first await, so a second tap immediately sees hasOutgoing == true. On join failure or missing client the field is cleared.


🟢 LOW — Fire-and-forget Future<void> calls in synchronous methods

File: lib/features/voice/controllers/call.dart

soundManager.startRingtone() / stopRingtone() and voiceController.leave() were called from void methods without await and without unawaited(), which silently discards the returned futures and suppresses any propagated errors.

Fix: Added import 'dart:async' show unawaited; and wrapped all fire-and-forget calls. This matches the intent (these are best-effort audio side effects) while being explicit and suppressing analyzer warnings.


No changes needed

  • AccordCallSignal model — clean; lenient parsing via json_utils helpers, toJson omits nulls, consistent with the rest of the SDK.
  • GatewaySocket dispatch — correctly routes call.* event types to typed stream controllers.
  • VoiceController.join signature change (StringString? for spaceId) — safe; all existing callers already pass a non-null value and the null path is now exercised only for DM calls.
  • IncomingCallOverlay — UI logic is correct; the two instances in accord_home.dart are in mutually exclusive layout branches so only one is mounted at a time.
  • File sizes — all changed files are well under 1000 lines.
  • No 3D model / visual rendering changes — visual review not applicable.
  • Tests — existing accordkit gateway + endpoint tests cover the new call.* event dispatch and REST paths. The call_state_test.dart suite covers CallState semantics. Added a hasOutgoing group documenting the re-entrancy guard.

Generated by Claude Code

@krazyjakee krazyjakee merged commit 21b3095 into master Jun 14, 2026
2 checks passed
@krazyjakee krazyjakee deleted the feat/dm-voice-video-calls branch June 14, 2026 00:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

DM voice/video calls — call button, ring/accept/decline UI

1 participant