Skip to content

Instantly share code, notes, and snippets.

@kybik44
Last active April 24, 2026 13:17
Show Gist options
  • Select an option

  • Save kybik44/ef0918693a0d8bf43dcbf4f02ba518c4 to your computer and use it in GitHub Desktop.

Select an option

Save kybik44/ef0918693a0d8bf43dcbf4f02ba518c4 to your computer and use it in GitHub Desktop.
NF Call Refactoring — Types, Service, Hook

Call Code Refactoring Plan

Problem

WebRTCPipeline.ts — 1,582 lines. Call lifecycle, media, audio routing, stats, participants — all in one class. useSessionConnection.ts — 1,384 lines. Orchestration hook mixed with backend-specific logic. No central call state. 10 transport states, set from 13+ places, no validation.

Part 1: Architecture

2 independent layers, each with its own state machine.

RTC Layer — RTCService (rtc.service.ts) Low-level WebRTC. RTCPeerConnection, SDP, ICE, SFU signaling, negotiation queue. Owns RTCState. Call layer reads it but does not write it. Currently: WebRTCSession.ts (995 lines) + PeerConnectionManager.ts (398 lines). Important: the negotiation queue lives here. All SDP operations (publish, subscribe, renegotiate) are serialized through this queue. Call layer never touches SDP directly.

Call Layer — CallService (call.service.ts) Business logic. CallState, participants, media control. Sits on top of RTC layer. Currently: WebRTCPipeline.ts (1,582 lines) — target ~650-700 lines.

Media Manager (media-manager.ts) getUserMedia, camera/mic presets, quality, stop tracks, silent placeholder. Currently: MediaCaptureService.ts (277 lines) — already clean, keep as is.

Types (types.ts) All types in one top-level file: src/callapp/types.ts

Key SFU difference from the P2P examples: The RTC-FRONTEND examples are peer-to-peer (direct offer/answer between users). Our system uses an SFU (Cloudflare Calls) — publish and subscribe are separate SDP negotiations on the same PeerConnection. The SFU requires SDP surgery (strip inactive m-sections, patch extmap IDs, strip orphaned RTX). All of this stays in the RTC layer. Call layer never sees SDP.

Part 2: Types

Two independent state machines:

RTC layer state — WebRTC connection:

type RTCState =
  | { status: 'new' }
  | { status: 'connecting' }
  | { status: 'connected' }
  | { status: 'disconnected' }
  | { status: 'failed'; reason: string }
  | { status: 'closed' }

Call layer state — call lifecycle:

type CallState =
  | { status: 'idle' }
  | { status: 'connecting'; sessionID: string }
  | { status: 'joining'; sessionID: string }
  | { status: 'active'; sessionID: string }
  | { status: 'reconnecting'; sessionID: string }
  | { status: 'error'; message: string; previousStatus?: CallStatus }

Errors can happen at both layers independently:

  • RTC layer: 'failed' with reason (ICE failure, DTLS error)

  • Call layer: 'error' with message (publish failed, subscribe failed, timeout)

    type MediaState = { isCameraEnabled: boolean isMicEnabled: boolean isGuestMuted: boolean localPreviewStream: MediaStream | null }

    type CallParticipant = { id: string name: string isLocal: boolean hasVideo: boolean hasAudio: boolean isEmpty: boolean }

Transitions (Call layer):

idle --> connecting --> joining --> active
 ^          |             |          |
 |     disconnect    fail/cancel   disconnect
 |          |             |          |
 +----------+-------------+----------+

ERROR can happen from ANY state (connecting, joining, active, reconnecting).
Any state --> error.

error --> reconnecting --> connecting  (recovery success)
error --> idle                         (user gives up / disconnect)
reconnecting --> error                (recovery failed)

IMPORTANT: disconnect and error must not race.
If the system enters 'error' state, disconnect() must be safe to call
without corrupting an in-progress reconnect. The CallStateMachine must
reject disconnect() if status is 'reconnecting' — reconnect owns the
teardown/rebuild cycle. Only after reconnect completes (success or fail)
can disconnect() run.

Current bug in WebRTCPipeline: disconnect() nulls session/peer/callsClient
unconditionally. If ICE failure fires onIceRestartExhausted while reconnect()
is in progress, the reconnect sees null references and crashes silently.
The CallStateMachine fixes this by gating transitions.

Participants are separate from CallState:

active + just me    = waiting for other side
active + 2 people   = active call
active + 1 person   = other side dropped
error + any         = connection lost

Part 3: Service APIs

RTCService (RTC layer):

class RTCService {
  readonly rtcState: Accessor<RTCState>

  // Connection
  createSession(params: { sessionId: string; guestId: string }): Promise<void>
  closeSession(): Promise<void>
  restartIce(): Promise<void>

  // Publish / Subscribe (SDP negotiation queue)
  publishTracks(tracks: MediaStreamTrack[]): Promise<void>
  subscribeTracks(params?: SubscribeParams): Promise<void>
  clearPublisherSubscription(publisherGuestId: string): void

  // Track management
  addTrack(track: MediaStreamTrack): Promise<RTCRtpSender>
  replaceTrack(sender: RTCRtpSender, track: MediaStreamTrack | null): Promise<void>
  removeTrack(sender: RTCRtpSender): Promise<void>
  applySendingParams(sender: RTCRtpSender, params: RTCRtpSendParameters): void

  // Callbacks (set by CallService)
  onRemoteStream: ((stream: MediaStream | null) => void) | null
  onRemoteVideoActive: (() => void) | null
  onRemoteVideoStale: (() => void) | null
  onNetworkStats: ((stats: NetworkStats | null) => void) | null
  onIceRestartExhausted: (() => void) | null
}

CallService (Call layer, sits on top of RTCService):

class CallService {
  readonly callState: Accessor<CallState>
  readonly participants: Accessor<CallParticipant[]>
  readonly stats: Accessor<CallStats | null>
  readonly isCameraEnabled: Accessor<boolean>
  readonly isMicEnabled: Accessor<boolean>
  readonly isGuestMuted: Accessor<boolean>
  readonly localPreviewStream: Accessor<MediaStream | null>

  constructor(rtc: RTCService)

  // Connection (used by the orchestration hook)
  connect(params: ConnectParams): Promise<void>
  disconnect(): Promise<void>
  reconnect(): Promise<void>

  // Publish / Subscribe (called by the hook after connect)
  publish(): Promise<void>
  subscribe(params?: SubscribeParams): Promise<void>
  clearPublisherSubscription(publisherGuestId: string): void

  // Media Control
  enableCamera(stream?: MediaStream): Promise<void>
  disableCamera(): Promise<void>
  enableMic(stream?: MediaStream): Promise<void>
  disableMic(): Promise<void>
  setCameraQuality(quality: string, fps?: number): Promise<void>

  // Audio Output
  setSpeakerEnabled(enabled: boolean): Promise<void>
  setSpeakerDevice(deviceId: string | null): Promise<void>
  setCombinedAudioPlayback(enabled: boolean): Promise<void>
  setCombinedAudioLocalGain(gain: number): Promise<void>
  setCombinedAudioRemoteGain(gain: number): Promise<void>

  // Guest Control
  setGuestMuted(muted: boolean): Promise<void>
}

Why connect/publish/subscribe stay separate (not collapsed into joinCall): The hook (useCall) sequences these with app-level logic between each step: backend API calls, stale-flow detection, conditional camera/mic, publish-failure recovery with SFU re-join. This orchestration depends on sessionStore, localMedia, and backend API — concerns that don't belong in CallService.

Part 4: Server Methods

JoinCfSfuSession — on join — register guest with Cloudflare SFU PublishSessionTracks — on publish — send local audio/video SDP + track list to SFU SubscribeSessionTracks — on subscribe — receive remote tracks from publisher RenegotiateSessionAnswer — mid-call — SDP renegotiation (camera toggle, track add/remove) CloseSessionTracks — on leave — close tracks server-side ListSessionMembers — on subscribe — discover who else is in the session Heartbeat — every 10s — keep session alive on backend LeaveSession — on disconnect — notify backend to clean up participant Ping — every 50s — pre-call health check GetParticipantPaths — on join — discover participant routes

Part 5: File Map — What Goes Where

Current files:

src/realtime/pipelines/webrtc/
  WebRTCPipeline.ts              1,582 lines — SPLITS
  pipelineHelpers.ts               101 lines — keep
  core/
    WebRTCSession.ts               995 lines — becomes RTCService
    PeerConnectionManager.ts       398 lines — merges into RTCService
    MediaCaptureService.ts         277 lines — keep (media manager)
    networkStatsBuilder.ts         238 lines — keep
    staleVideoDetector.ts          109 lines — keep
    sdpUtils.ts                          — keep
    silentPlaceholder.ts                 — keep
  signaling/
    CloudflareCallsClient.ts             — keep
    SFUAdapter.ts                        — keep

src/features/session/hooks/
  useSessionConnection.ts        1,384 lines — simplify

After refactoring — flat structure under src/callapp/:

src/callapp/
  types.ts                       ~80 lines   ALL types, top level
  CallService.ts                 ~650-700 lines
  RTCService.ts                  ~800 lines   (WebRTCSession + PeerConnectionManager)
  CallStateMachine.ts            ~100 lines
  CombinedAudioPlayback.ts       ~290 lines   extracted from WebRTCPipeline
  ParticipantManager.ts          ~120 lines   extracted from WebRTCPipeline
  MediaManager.ts                ~277 lines   renamed from MediaCaptureService
  networkStatsBuilder.ts         ~238 lines   moved as-is
  staleVideoDetector.ts          ~109 lines   moved as-is
  sdpUtils.ts                                 moved as-is
  silentPlaceholder.ts                        moved as-is
  signaling/
    CloudflareCallsClient.ts                  moved as-is
    SFUAdapter.ts                             moved as-is

src/features/session/
  hooks/
    useSessionConnection.ts      ~800-900 lines  SIMPLIFIED
  utils/
    callRouting.ts               ~100 lines  extracted from useSessionConnection

What moves out of WebRTCPipeline.ts into CombinedAudioPlayback.ts (~290 lines):

Fields that move: playbackAudioContext, playbackDestinationNode, playbackRemoteSourceNode, playbackLocalSourceNode, playbackRemoteGainNode, playbackLocalGainNode, playbackRemoteAnalyserNode, playbackLocalAnalyserNode, playbackRemoteAnalyserData, playbackLocalAnalyserData, playbackStream, combinedPlaybackDebugTimer, combinedPlaybackSetupRetryTimer, combinedPlaybackSetupRetryCount, combinedAudioDebugEnabled, combinedMicOnlyDebugEnabled, observedCombinedMicTrackId, detachCombinedMicTrackListeners

Methods that move: buildCombinedPlaybackStream, teardownCombinedPlayback, refreshPlaybackOutput, resumePlaybackContext, scheduleCombinedPlaybackSetupRetry, clearCombinedPlaybackSetupRetry, handleMicTrackAvailability, clearCombinedMicTrackListeners, enforceMicFeedbackSafeguards, startCombinedPlaybackDebugMonitor, stopCombinedPlaybackDebugMonitor, logCombinedPlaybackState, applySpeakerSinkId

Interface — receives current state as arguments (streams change over time):

class CombinedAudioPlayback {
  // Called whenever streams or speaker state change.
  // CallService passes CURRENT values each time.
  refresh(context: {
    remoteStream: MediaStream | null
    micStream: MediaStream | undefined
    speakerEnabled: boolean
  }): Promise<void>

  setCombinedEnabled(enabled: boolean): void
  setLocalGain(gain: number): void
  setRemoteGain(gain: number): void
  setSpeakerDevice(deviceId: string | null): void
  teardown(): void
}

This class owns the remoteVideoElement and the WebAudio graph. CallService calls refresh() wherever it currently calls refreshPlaybackOutput().

What moves out into ParticipantManager.ts (~120 lines):

Fields that move: remoteVideoReceiving, activePublisherSessionId, prevRemoteVideoElementCurrentTime, prevRemoteRenderedFrames

Methods that move: emitParticipants, sampleRemoteRenderDiagnostics, applyGuestMuteState

Interface — receives current state as arguments:

class ParticipantManager {
  readonly participants: Accessor<CallParticipant[]>

  update(context: {
    remoteStream: MediaStream | null
    remoteVideoElement: HTMLVideoElement | null
    isGuestMuted: boolean
  }): void

  setVideoReceiving(active: boolean): void
  setActivePublisherId(id: string): void
  sampleRenderDiagnostics(): { ... }
  clear(): void
}

What STAYS in CallService (not extracted): onRemoteStreamChanged — coordinates both CombinedAudioPlayback and ParticipantManager clearRemoteParticipant — calls audioPlayback.teardown() + participants.clear() These stay because they coordinate across the two extracted classes.

What moves out into CallStateMachine.ts (~100 lines):

Replaces 13 scattered setState() calls with validated transitions. Invalid transitions are rejected and logged — prevents impossible states.

class CallStateMachine {
  readonly state: Accessor<CallState>

  transition(to: CallState, reason: string): boolean   // validates, rejects invalid
  reset(): void                                        // force to idle (full teardown only)
}

Key rules:

  • Error can happen from ANY state — always allowed.
  • 'reconnecting' blocks 'idle' transition — reconnect owns the teardown/rebuild cycle. disconnect() must wait for reconnect to finish.
  • Error during reconnect → status goes to 'error', then caller decides: retry (→ reconnecting) or give up (→ idle).

This fixes a current bug: disconnect() nulls session/peer unconditionally. If ICE failure fires while reconnect() is in progress, reconnect sees null references. The state machine prevents this by rejecting the disconnect transition while reconnecting.

The existing 10 transport-level RealtimeState values are replaced. Two new state machines handle both layers independently:

RTC layer mapping (RTCState ← ICE/DTLS events):

iceConnectionState "checking"      --> RTCState { status: 'connecting' }
iceConnectionState "connected"     --> RTCState { status: 'connected' }
iceConnectionState "completed"     --> RTCState { status: 'connected' }
iceConnectionState "disconnected"  --> RTCState { status: 'disconnected' }
iceConnectionState "failed"        --> RTCState { status: 'failed' }
iceConnectionState "closed"        --> RTCState { status: 'closed' }

Call layer mapping (CallState ← CallService methods):

connect() called                   --> CallState { status: 'connecting' }
publish() called                   --> CallState { status: 'joining' }
subscribe() success                --> CallState { status: 'active' }
reconnect() called                 --> CallState { status: 'reconnecting' }
disconnect() called                --> CallState { status: 'idle' }
any failure                        --> CallState { status: 'error' }

What stays in CallService.ts (~650-700 lines):

  • CallState machine (delegates to CallStateMachine)
  • connect / disconnect / reconnect lifecycle
  • publish / subscribe coordination
  • enableCamera / disableCamera / enableMic / disableMic
  • onRemoteStreamChanged (coordinates audio + participants)
  • clearRemoteParticipant (coordinates audio + participants)
  • publishIfNeeded (re-publish after mid-call media changes)
  • Stats callback wiring (receives from RTCService, enriches, emits)
  • Local preview stream management
  • Stale video re-subscribe handler
  • Camera quality and sending params

Note on stale video: When StaleVideoDetector fires onRemoteVideoStale, CallService clears the subscription cache and re-subscribes. This is a production-critical self-healing path that stays in CallService.

What moves out of useSessionConnection.ts into callRouting.ts (~100 lines):

Routing utilities extracted from useSessionConnection. The join flow stays in the hook — it depends on sessionStore, API calls, and stale-flow detection that belong at the app layer.

SolidJS note: Extracted classes create signals in their constructor (createSignal). This is safe — the codebase already does this in sessionStore.ts. Rule: extracted classes MUST NOT create createEffect or createMemo internally. They are "passive" — they expose signals but don't subscribe to them. All reactive subscriptions stay in hooks or components.

Part 6: Incremental Test Plan

All features below already work today. These are regression tests to run after each extraction, not implementation steps.

After Extraction 1 (CombinedAudioPlayback):

  • Combined audio playback toggle on/off
  • Local gain and remote gain adjustment
  • Speaker device switching
  • Mic monitoring (hear yourself through speaker)

After Extraction 2 (ParticipantManager):

  • Guest mute/unmute
  • Participant list updates when remote joins/leaves
  • Video element creation for remote stream

After Extraction 3 (RTCService + CallStateMachine):

  • RTCState tracks ICE events correctly
  • CallState transitions: idle -> connecting -> joining -> active
  • Disconnect: any -> idle
  • Error: ICE failure -> error -> reconnecting -> connecting
  • Invalid transitions are logged and rejected
  • RTCState and CallState update independently

After Extraction 4 (Restructure to src/callapp/): Steps 1-10 full regression:

  1. Pre-call connection (host + guest both browsers + mobile)
  2. Video-only call (low quality)
  3. Audio-only call
  4. Audio + Video call
  5. Empty join (no media, placeholder only)
  6. Empty join + upgrade (add camera, then mic mid-call)
  7. Start with media + downgrade (remove camera, then mic)
  8. Connect/disconnect stress (rapid join/leave, no leaked state)
  9. Quality presets, stats polling, combined audio
  10. Bug fixes from above

Summary

2 independent layers, each with its own state: RTCService — RTCState (new/connecting/connected/disconnected/failed/closed) CallService — CallState (idle/connecting/joining/active/reconnecting/error)

WebRTCPipeline.ts (1,582 lines) splits into: CallService.ts (~650-700) + CombinedAudioPlayback.ts (~290)

  • ParticipantManager.ts (~120) + CallStateMachine.ts (~100)

WebRTCSession.ts + PeerConnectionManager.ts merge into RTCService.ts (~800 lines).

useSessionConnection.ts (1,384 lines) simplifies to ~800-900 lines.

All files move to src/callapp/. Types in src/callapp/types.ts.

Every extraction ships independently. Regression tests after each step.

import type { CallType } from './call-state';
export type CallMediaOptions = {
type: CallType;
audioEnabled?: boolean;
videoEnabled?: boolean;
};
export async function getCallMediaStream(options: CallMediaOptions) {
if (typeof navigator === 'undefined' || !navigator.mediaDevices?.getUserMedia) {
throw new Error('WebRTC media capture is unavailable in this environment.');
}
const constraints: MediaStreamConstraints = {
audio: options.audioEnabled ?? true,
video: options.type === 'video' ? (options.videoEnabled ?? true) : false,
};
return navigator.mediaDevices.getUserMedia(constraints);
}
export function stopMediaStream(stream: MediaStream | null | undefined) {
if (!stream) return;
for (const track of stream.getTracks()) track.stop();
}
// Call Service — upper layer
// Business logic. Owns CallState, media control, participants, audio routing.
// Uses RTCService for all WebRTC operations. Never touches SDP or ICE directly.
// This is what WebRTCPipeline.ts becomes after refactoring.
import { createSignal, type Accessor } from 'solid-js';
import type {
CallState,
CallParticipant,
CallStats,
ConnectParams,
SubscribeParams,
} from './nf-call.types';
import type { RTCService } from './nf-rtc.service';
// Extracted classes (new):
// import { CombinedAudioPlayback } from './CombinedAudioPlayback';
// import { ParticipantManager } from './ParticipantManager';
export class CallService {
// ── State (read-only signals) ──────────────────────────────
readonly callState: Accessor<CallState>;
readonly participants: Accessor<CallParticipant[]>;
readonly stats: Accessor<CallStats | null>;
readonly isCameraEnabled: Accessor<boolean>;
readonly isMicEnabled: Accessor<boolean>;
readonly isGuestMuted: Accessor<boolean>;
readonly localPreviewStream: Accessor<MediaStream | null>;
// ── RTC layer (injected) ───────────────────────────────────
private rtc: RTCService;
// ── Private state ──────────────────────────────────────────
// private audioPlayback: CombinedAudioPlayback;
// private participantManager: ParticipantManager;
private cameraStream: MediaStream | undefined;
private micStream: MediaStream | undefined;
private hasPublished = false;
constructor(rtc: RTCService) {
this.rtc = rtc;
// Signals
const [callState, setCallState] = createSignal<CallState>({ status: 'idle' });
const [stats, setStats] = createSignal<CallStats | null>(null);
const [isCameraEnabled, setIsCameraEnabled] = createSignal(false);
const [isMicEnabled, setIsMicEnabled] = createSignal(false);
const [isGuestMuted, setIsGuestMuted] = createSignal(false);
const [localPreviewStream, setLocalPreviewStream] = createSignal<MediaStream | null>(null);
this.callState = callState;
this.stats = stats;
this.isCameraEnabled = isCameraEnabled;
this.isMicEnabled = isMicEnabled;
this.isGuestMuted = isGuestMuted;
this.localPreviewStream = localPreviewStream;
this.participants = createSignal<CallParticipant[]>([])[0]; // placeholder
// Wire RTC callbacks → Call layer handlers
// rtc.onRemoteStream = (stream) => this.onRemoteStreamChanged(stream);
// rtc.onRemoteVideoActive = () => this.participantManager.setVideoReceiving(true);
// rtc.onRemoteVideoStale = () => this.handleStaleVideo();
// rtc.onNetworkStats = (raw) => this.handleStats(raw);
// rtc.onIceRestartExhausted = () => this.handleIceFailure();
}
// ── Call Lifecycle ─────────────────────────────────────────
// The hook (useCall) calls these in sequence.
// Connect/publish/subscribe stay separate because the hook
// does app-level work between each step (API calls, stale-flow
// detection, conditional camera/mic).
async connect(params: ConnectParams): Promise<void> {
// setCallState({ status: 'connecting', sessionID: params.sessionId })
// Delegate to RTC layer:
// await this.rtc.createSession({ sessionId, guestId })
// Now RTCState is 'connected', CallState is 'connecting'
}
async disconnect(): Promise<void> {
// SAFETY: if CallState is 'reconnecting', block.
// Reconnect owns the teardown/rebuild cycle — disconnect must not race it.
//
// 1. this.participantManager.clear()
// 2. this.audioPlayback.teardown()
// 3. Stop camera/mic streams
// 4. await this.rtc.closeSession()
// 5. Reset all signals
// setCallState({ status: 'idle' })
}
async reconnect(): Promise<void> {
// setCallState({ status: 'reconnecting', sessionID })
// SAFETY: reconnect() does its own teardown before rebuilding.
// disconnect() is blocked while status is 'reconnecting'.
// If reconnect fails → error, then caller decides: retry or give up.
//
// 1. await this.rtc.closeSession()
// 2. await this.rtc.createSession(lastParams)
// 3. Re-enable camera/mic if they were on
// 4. Re-publish, re-subscribe
}
// ── Publish / Subscribe ────────────────────────────────────
async publish(): Promise<void> {
// setCallState({ status: 'joining', sessionID })
// Collect current tracks (camera, mic, silent placeholder)
// await this.rtc.publishTracks(tracks)
// hasPublished = true
}
async subscribe(params?: SubscribeParams): Promise<void> {
// await this.rtc.subscribeTracks(params)
// setCallState({ status: 'active', sessionID })
}
clearPublisherSubscription(publisherGuestId: string): void {
// this.rtc.clearPublisherSubscription(publisherGuestId)
}
// ── Media Control ──────────────────────────────────────────
async enableCamera(existingStream?: MediaStream): Promise<void> {
// 1. Capture camera (or use existingStream)
// 2. await this.rtc.addTrack(videoTrack)
// 3. this.rtc.applySendingParams(sender, cameraParams)
// 4. Set isCameraEnabled(true)
// 5. Refresh local preview
// 6. Re-publish if already in a call
}
async disableCamera(): Promise<void> {
// 1. this.rtc.removeTrack(cameraSender)
// 2. Stop camera stream
// 3. Set isCameraEnabled(false)
// 4. Refresh local preview
// 5. Re-publish to tell SFU to stop forwarding
}
async enableMic(existingStream?: MediaStream): Promise<void> {
// 1. Capture mic (or use existingStream)
// 2. Enforce echo cancellation / noise suppression
// 3. await this.rtc.addTrack(audioTrack)
// 4. Set isMicEnabled(true)
// 5. Refresh local preview + audio playback
// 6. Re-publish if needed
}
async disableMic(): Promise<void> {
// 1. Replace mic with silent placeholder (maintain SFU presence)
// this.rtc.replaceTrack(micSender, silentTrack)
// 2. Stop mic stream
// 3. Set isMicEnabled(false)
// 4. Refresh local preview + audio playback
}
async setCameraQuality(quality: string, fps?: number): Promise<void> {
// this.rtc.applySendingParams(cameraSender, qualityParams)
}
// ── Audio Output ───────────────────────────────────────────
async setSpeakerEnabled(enabled: boolean): Promise<void> {
// Mute/unmute the remote audio element
// this.audioPlayback.refresh(...)
}
async setSpeakerDevice(deviceId: string | null): Promise<void> {
// this.audioPlayback.setSpeakerDevice(deviceId)
}
async setCombinedAudioPlayback(enabled: boolean): Promise<void> {
// this.audioPlayback.setCombinedEnabled(enabled)
// this.audioPlayback.refresh(...)
}
async setCombinedAudioLocalGain(gain: number): Promise<void> {
// this.audioPlayback.setLocalGain(gain)
}
async setCombinedAudioRemoteGain(gain: number): Promise<void> {
// this.audioPlayback.setRemoteGain(gain)
}
// ── Guest Control ──────────────────────────────────────────
async setGuestMuted(muted: boolean): Promise<void> {
// Set isGuestMuted signal
// this.participantManager.applyGuestMuteState(muted)
}
// ── Internal: Coordination ─────────────────────────────────
// These stay here because they coordinate across extracted classes.
private onRemoteStreamChanged(stream: MediaStream | null): void {
// Coordinates BOTH extracted classes:
// 1. this.audioPlayback.refresh({ remoteStream: stream, ... })
// 2. this.participantManager.update({ remoteStream: stream, ... })
}
private handleStaleVideo(): void {
// Production-critical self-healing:
// When StaleVideoDetector fires (no decoded frames for several polls),
// clear subscription cache and re-subscribe to force SFU to resume.
// this.subscribe(this.lastSubscribeParams)
}
private handleIceFailure(): void {
// RTCState went to 'failed' → CallState goes to 'error'
// setCallState({ status: 'error', message: 'Connection lost', previousStatus })
// Caller (hook) decides: reconnect or disconnect
}
private handleStats(raw: unknown): void {
// Receive raw stats from RTCService
// Enrich with render diagnostics from participantManager
// Emit via setStats()
}
private refreshLocalPreview(): void {
// Combine live camera + mic tracks into a preview stream
// Emit via setLocalPreviewStream()
}
private async publishIfNeeded(force: boolean): Promise<void> {
// Re-publish after mid-call media changes (camera enable/disable).
// Only runs if already in 'active' or 'joining' state and force=true.
}
}
// ═══════════════════════════════════════════════════════════
// Call Types — single source of truth for the call system
// Two independent layers, each with its own state.
// ═══════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════
// RTC Layer — WebRTC connection state
// Managed by RTCService. Call layer reads but does not write.
// ═══════════════════════════════════════════════════════════
export type RTCState =
| { status: 'new' } // no connection yet
| { status: 'connecting' } // ICE/DTLS in progress
| { status: 'connected' } // ICE connected, media can flow
| { status: 'disconnected' } // ICE disconnected, may recover
| { status: 'failed'; reason: string } // ICE failed, needs restart
| { status: 'closed' }; // peer connection closed
export type RTCStatus = RTCState['status'];
// ═══════════════════════════════════════════════════════════
// Call Layer — call lifecycle state
// Managed by CallService. Sits on top of RTC layer.
// ═══════════════════════════════════════════════════════════
export type CallState =
| { status: 'idle' } // no call
| { status: 'connecting'; sessionID: string } // SFU session created, setting up
| { status: 'joining'; sessionID: string } // publishing/subscribing tracks
| { status: 'active'; sessionID: string } // in call, media flowing
| { status: 'reconnecting'; sessionID: string } // recovering from failure
| { status: 'error'; message: string; previousStatus?: CallStatus };
export type CallStatus = CallState['status'];
// ── Media State ──────────────────────────────────────────
// What devices are active right now. Owned by CallService.
export type MediaState = {
isCameraEnabled: boolean;
isMicEnabled: boolean;
isGuestMuted: boolean;
localPreviewStream: MediaStream | null;
};
// ── Participants ─────────────────────────────────────────
// Who is in the call. Separate from CallState.
//
// active + 0 participants = host waiting for guest
// active + 1 participant = active call
// active + 0 after having 1 = other side dropped
export type CallParticipant = {
id: string;
name: string;
isLocal: boolean;
hasVideo: boolean;
hasAudio: boolean;
isEmpty: boolean; // joined with no media (silent placeholder only)
video?: VideoSource;
};
export type VideoSource =
| { kind: 'video-frame'; getFrame: () => VideoFrame | undefined }
| { kind: 'media-element'; getElement: () => HTMLVideoElement | undefined };
// ── Network Stats ────────────────────────────────────────
export type CallStats = {
rttMs?: number;
packetLoss?: number;
bitrateKbps?: number;
status: 'live' | 'unavailable';
};
// ── Connect / Subscribe ─────────────────────────────────
export type ConnectParams = {
sessionId: string;
visibleName: string;
guestId: string;
cameraQuality?: CameraQuality;
cameraFrameRate?: number;
};
export type SubscribeParams = {
publisherSessionId?: string;
trackNames?: string[];
targets?: SubscribeTarget[];
};
export type SubscribeTarget = {
publisherGuestId: string;
trackNames: string[];
};
// ── Quality ──────────────────────────────────────────────
export type CameraQuality = 'low' | 'medium' | 'high' | 'hd';
export type AudioQuality = 'low' | 'standard' | 'high';
// ═══════════════════════════════════════════════════════════
// Layer naming (current → refactored)
//
// WebRTCPipeline → CallService (Call layer — business logic)
// WebRTCSession → RTCService (RTC layer — SDP, signaling, negotiation queue)
// PeerConnectionManager → (merged into RTCService)
// MediaCaptureService → MediaManager (Media — getUserMedia)
// RemoteParticipantManager → ParticipantManager
//
// All files under src/callapp/
// types.ts, CallService.ts, RTCService.ts, CallStateMachine.ts,
// CombinedAudioPlayback.ts, ParticipantManager.ts, MediaManager.ts, ...
// ═══════════════════════════════════════════════════════════
// RTC Service — lower layer
// Owns RTCPeerConnection, SDP negotiation, ICE, signaling.
// Has its own state (RTCState). CallService reads it but doesn't write it.
// This is what WebRTCSession.ts + PeerConnectionManager.ts become.
import { createSignal, type Accessor } from 'solid-js';
import type { RTCState, SubscribeParams } from './nf-call.types';
export class RTCService {
// ── State (read-only) ─────────────────────────────────────
readonly rtcState: Accessor<RTCState>;
// ── Callbacks (set by CallService) ────────────────────────
onRemoteStream: ((stream: MediaStream | null) => void) | null = null;
onRemoteVideoActive: (() => void) | null = null;
onRemoteVideoStale: (() => void) | null = null;
onNetworkStats: ((stats: NetworkStats | null) => void) | null = null;
onIceRestartExhausted: (() => void) | null = null;
// ── Private ───────────────────────────────────────────────
// private peer: RTCPeerConnection | null = null;
// private callsClient: SFUAdapter | null = null;
// private negotiationQueue: NegotiationQueue;
// private staleVideoDetector: StaleVideoDetector;
constructor() {
const [rtcState, setRtcState] = createSignal<RTCState>({ status: 'new' });
this.rtcState = rtcState;
}
// ── Connection ────────────────────────────────────────────
async createSession(params: { sessionId: string; guestId: string }): Promise<void> {
// setRtcState({ status: 'connecting' })
// 1. Create SFU adapter (CloudflareCallsClient)
// 2. Create RTCPeerConnection with ICE callbacks:
// - oniceconnectionstatechange → update RTCState
// - ontrack → fire onRemoteStream
// 3. Join SFU session
// 4. Create silent placeholder track
// 5. Ensure peer session (initial SDP exchange)
// setRtcState({ status: 'connected' })
}
async closeSession(): Promise<void> {
// 1. Close peer connection
// 2. Stop stale video detector
// setRtcState({ status: 'closed' })
}
// ── Publish / Subscribe ───────────────────────────────────
// All SDP operations go through the negotiation queue.
// CallService never touches SDP directly.
async publishTracks(tracks: MediaStreamTrack[]): Promise<void> {
// Enqueue SDP negotiation:
// 1. Create offer with tracks
// 2. SDP surgery (strip inactive m-sections, patch extmap, strip orphaned RTX)
// 3. Send to SFU
// 4. Apply answer
}
async subscribeTracks(params?: SubscribeParams): Promise<void> {
// Enqueue SDP negotiation:
// 1. Request tracks from SFU
// 2. Apply remote SDP
// 3. Fire onRemoteStream when tracks arrive
}
clearPublisherSubscription(publisherGuestId: string): void {
// Remove subscription for specific publisher
}
// ── Track Management ──────────────────────────────────────
async addTrack(track: MediaStreamTrack): Promise<RTCRtpSender> {
// Add track to peer connection via negotiation queue
return {} as RTCRtpSender; // placeholder
}
async replaceTrack(sender: RTCRtpSender, track: MediaStreamTrack | null): Promise<void> {
// Replace track on existing sender (no renegotiation needed)
}
async removeTrack(sender: RTCRtpSender): Promise<void> {
// Remove track from peer connection
}
applySendingParams(sender: RTCRtpSender, params: RTCRtpSendParameters): void {
// Apply encoding parameters (resolution, framerate, bitrate)
}
// ── ICE ───────────────────────────────────────────────────
async restartIce(): Promise<void> {
// Trigger ICE restart
// If exhausted → fire onIceRestartExhausted
}
// ── Stats ─────────────────────────────────────────────────
async getStats(): Promise<RTCStatsReport | null> {
// Return raw stats from peer connection
return null;
}
}
// Placeholder — actual type lives in networkStatsBuilder.ts
type NetworkStats = {
rttMs?: number;
packetLoss?: number;
bitrateKbps?: number;
};
// useCall hook — SolidJS
// Orchestrates CallService + RTCService for the UI.
// Reads both layers' state independently.
import { createEffect, onCleanup } from 'solid-js';
import type { RTCService } from './nf-rtc.service';
import type { CallService } from './nf-call.service';
import type { CallState, RTCState } from './nf-call.types';
export function useCall(callService: CallService, rtcService: RTCService) {
// ── React to Call layer state ─────────────────────────────
createEffect(() => {
const state: CallState = callService.callState();
switch (state.status) {
case 'idle':
// Clear errors, reset UI
break;
case 'connecting':
// Show "connecting to session..."
break;
case 'joining':
// Show progress indicator (publishing/subscribing)
break;
case 'active':
// Active call — start heartbeat, show call UI
break;
case 'reconnecting':
// Show "reconnecting..." notification
break;
case 'error':
// Show error: state.message
// Offer reconnect button
break;
}
});
// ── React to RTC layer state (independently) ──────────────
createEffect(() => {
const rtc: RTCState = rtcService.rtcState();
switch (rtc.status) {
case 'new':
break;
case 'connecting':
// Optional: show ICE progress indicator
break;
case 'connected':
// ICE connected — media can flow
break;
case 'disconnected':
// ICE temporarily lost — may auto-recover
// Show subtle "connection unstable" indicator
break;
case 'failed':
// ICE failed — rtc.reason has details
// CallService handles this via onIceRestartExhausted callback
break;
case 'closed':
break;
}
});
// ── Join flow (called by UI on "Join" button) ──────────────
// The hook sequences these calls with app-level logic between
// each step. This orchestration stays here, not in CallService,
// because it depends on sessionStore, backend API, and
// stale-flow detection.
async function joinCall(params: {
sessionId: string;
guestId: string;
visibleName: string;
videoStream?: MediaStream;
audioStream?: MediaStream;
}) {
// 1. Backend API: register with SFU
// await api.app.JoinCfSfuSession({ sessionId, guestId })
// 2. Connect (creates RTC session under the hood)
await callService.connect({
sessionId: params.sessionId,
visibleName: params.visibleName,
guestId: params.guestId,
});
// 3. Enable camera (if user wants video)
if (params.videoStream) {
await callService.enableCamera(params.videoStream);
}
// 4. Enable mic (if user wants audio)
if (params.audioStream) {
await callService.enableMic(params.audioStream);
}
// 5. Publish local tracks to SFU
await callService.publish();
// 6. Start heartbeat
// startSessionHeartbeat(sessionId, guestId)
}
// ── Leave ──────────────────────────────────────────────────
async function leaveCall() {
// stopSessionHeartbeat()
await callService.disconnect();
// api.app.LeaveSession({ sessionId, guestId })
}
// ── Subscribe to remote participants ───────────────────────
async function subscribeToParticipants(targets: Array<{
publisherGuestId: string;
trackNames: string[];
}>) {
await callService.subscribe({ targets });
}
// ── Reconnect ──────────────────────────────────────────────
async function reconnect() {
// Re-join SFU
// await api.app.JoinCfSfuSession({ sessionId, guestId })
await callService.reconnect();
}
// ── Cleanup on unmount ─────────────────────────────────────
onCleanup(() => {
const state = callService.callState();
if (state.status !== 'idle') {
callService.disconnect();
}
});
// ── Return ─────────────────────────────────────────────────
return {
// State — two independent layers
callState: callService.callState,
rtcState: rtcService.rtcState,
participants: callService.participants,
stats: callService.stats,
isCameraEnabled: callService.isCameraEnabled,
isMicEnabled: callService.isMicEnabled,
isGuestMuted: callService.isGuestMuted,
localPreviewStream: callService.localPreviewStream,
// Actions
joinCall,
leaveCall,
reconnect,
subscribeToParticipants,
// Media controls (pass-through)
enableCamera: (s?: MediaStream) => callService.enableCamera(s),
disableCamera: () => callService.disableCamera(),
enableMic: (s?: MediaStream) => callService.enableMic(s),
disableMic: () => callService.disableMic(),
setCameraQuality: (q: string, fps?: number) => callService.setCameraQuality(q, fps),
// Audio output (pass-through)
setSpeakerEnabled: (e: boolean) => callService.setSpeakerEnabled(e),
setSpeakerDevice: (d: string | null) => callService.setSpeakerDevice(d),
setCombinedAudioPlayback: (e: boolean) => callService.setCombinedAudioPlayback(e),
// Guest control
setGuestMuted: (m: boolean) => callService.setGuestMuted(m),
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment