Skip to content

Instantly share code, notes, and snippets.

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

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

Select an option

Save kybik44/02f405bb90189b4bb4b27a17db388f46 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

3 layers, inspired by RTC-FRONTEND pattern, adapted for our SFU (not P2P).

Layer 1 — Call Service (call.service.ts) Business logic. CallState, participants, media control, coordinates layers below. Currently: WebRTCPipeline.ts (1,582 lines) — target ~650-700 lines.

Layer 2 — WebRTC Service (webrtc.service.ts) Low-level WebRTC. RTCPeerConnection, SDP, ICE, SFU signaling, negotiation queue. Currently: WebRTCSession.ts + PeerConnectionManager.ts — already separate, keep. Important: the negotiation queue lives here. All SDP operations (publish, subscribe, renegotiate) are serialized through this queue. Call Service never touches SDP directly.

Layer 3 — 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 (call.types.ts) All call types in one file.

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 Layer 2 (WebRTC Service). Call Service never sees SDP.

Part 2: Types

type CallState =
  | { status: 'idle' }
  | { status: 'connected'; hostID: string }
  | { status: 'joining'; sessionID: string }
  | { status: 'call'; sessionID: string }
  | { status: 'reconnecting'; sessionID: string }
  | { status: 'error'; message: string }

6 states. The joining state covers the publish/subscribe phase where the user sees a progress indicator. The reconnecting state is needed because recovery takes time and the UI must show it.

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

type StreamState = {
  remoteStream: MediaStream | null
  localCameraStream: MediaStream | null
  localMicStream: MediaStream | null
}

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

Transitions:

idle --> connected --> joining --> call
 ^          |             |          |
 |     disconnect    fail/cancel   disconnect
 |          |             |          |
 +----------+-------------+----------+

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

error --> reconnecting --> connected  (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:

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

Part 3: Call Service Public API

class CallService {

  // State (read-only signals)
  readonly callState: Accessor<CallState>
  readonly participants: Accessor<CallParticipant[]>
  readonly mediaState: Accessor<MediaState>
  readonly stats: Accessor<CallStats | null>

  // 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 (useSessionConnection) 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 the Call Service. The Call Service is a cleaner version of the current WebRTCPipeline — same methods, but organized.

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 INTO 4 FILES
  pipelineHelpers.ts               101 lines — keep
  core/
    WebRTCSession.ts               995 lines — keep (Layer 2, owns negotiation queue)
    PeerConnectionManager.ts       398 lines — keep (Layer 2)
    MediaCaptureService.ts         277 lines — keep (Layer 3)
    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 — new files:

src/realtime/pipelines/webrtc/
  CallService.ts                 ~650-700 lines  RENAMED from WebRTCPipeline.ts
  core/
    CallStateMachine.ts          ~100 lines  NEW
    CombinedAudioPlayback.ts     ~290 lines  NEW — extracted from WebRTCPipeline
    RemoteParticipantManager.ts  ~120 lines  NEW — extracted from WebRTCPipeline
  types/
    call.types.ts                ~50 lines   NEW

src/features/session/
  hooks/
    useSessionConnection.ts      ~800-900 lines  SIMPLIFIED
  utils/
    moqRouting.ts                ~100 lines  NEW — 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 RemoteParticipantManager.ts (~120 lines):

Fields that move: remoteVideoReceiving, activePublisherSessionId, prevRemoteVideoElementCurrentTime, prevRemoteRenderedFrames

Methods that move: emitParticipants, sampleRemoteRenderDiagnostics, applyGuestMuteState

Interface — receives current state as arguments:

class RemoteParticipantManager {
  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 RemoteParticipantManager 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 stay as INTERNAL state in CallService for backward compatibility. CallStateMachine is a new, higher-level signal derived from transport events.

Mapping:

Transport state "connecting"           --> CallState { status: 'connected' }
Transport state "connected"            --> CallState { status: 'connected' }
Transport state "publishing"           --> CallState { status: 'joining' }
Transport state "published"            --> CallState { status: 'joining' }
Transport state "subscribing"          --> CallState { status: 'joining' }
Transport state "subscribed"           --> CallState { status: 'call' }
Transport state "reconnecting"         --> CallState { status: 'reconnecting' }
Transport state "error"                --> CallState { status: 'error' }
Transport state "disconnected"         --> CallState { status: 'idle' }
Transport state "idle"                 --> CallState { status: 'idle' }

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 WebRTCSession, 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 moqRouting.ts (~100 lines):

parseMoqUrl, normalizeNamespace, derivePrefix, buildMoqRoutingConfig, buildSubscribeNamespacesFromParticipantPaths, resolveNamespaceFromPath

What simplifies in useSessionConnection.ts:

Transport-state pattern matching replaced with callState reads. Backend-specific branches in runJoinCall() extracted to focused functions. 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 (RemoteParticipantManager):

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

After Extraction 3 (CallStateMachine):

  • Full join flow: idle -> connected -> joining -> call
  • Disconnect: any -> idle
  • Error: ICE failure -> error -> reconnecting -> connected
  • Invalid transitions are logged and rejected

After Extraction 4 (Rename + Restructure): 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

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

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

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

New types file: call.types.ts (~50 lines). New utility: moqRouting.ts (~100 lines).

6 call states (idle/connected/joining/call/reconnecting/error) replace 10 unvalidated transport states for external consumers. Transport states stay internally for backward compatibility.

Every extraction ships independently. Regression tests after each step.

// Call Service — Layer 1
// Business logic layer. Owns CallState, coordinates WebRTC Service + Media Manager.
// This is what WebRTCPipeline.ts becomes after refactoring.
import { createSignal, type Accessor } from 'solid-js';
import type {
CallState,
CallParticipant,
CallStats,
MediaState,
ConnectParams,
SubscribeParams,
} from './nf-call.types';
// These already exist and stay as-is:
// import { WebRTCSession } from './core/WebRTCSession';
// import { PeerConnectionManager } from './core/PeerConnectionManager';
// import { MediaCaptureService } from './core/MediaCaptureService';
// import { CloudflareCallsClient } from './signaling/CloudflareCallsClient';
// These are new extracted classes:
// import { CallStateMachine } from './core/CallStateMachine';
// import { CombinedAudioPlayback } from './core/CombinedAudioPlayback';
// import { RemoteParticipantManager } from './core/RemoteParticipantManager';
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>;
// ── Private state ──────────────────────────────────────────
// private stateMachine: CallStateMachine;
// private audioPlayback: CombinedAudioPlayback;
// private remoteParticipants: RemoteParticipantManager;
// private session: WebRTCSession | null = null;
// private peer: PeerConnectionManager | null = null;
// private capture: MediaCaptureService | null = null;
// private callsClient: SFUAdapter | null = null;
private cameraStream: MediaStream | undefined;
private micStream: MediaStream | undefined;
private hasPublished = false;
constructor() {
// 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;
// Extracted classes own their own signals
// this.stateMachine = new CallStateMachine();
// this.callState = this.stateMachine.state;
// this.remoteParticipants = new RemoteParticipantManager();
// this.participants = this.remoteParticipants.participants;
// this.audioPlayback = new CombinedAudioPlayback();
this.participants = createSignal<CallParticipant[]>([])[0]; // placeholder
}
// ── Call Lifecycle ─────────────────────────────────────────
// The hook (useSessionConnection) 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> {
// this.stateMachine.transition({ status: 'connected', hostID: params.guestId }, 'connect');
// 1. Create callsClient (CloudflareCallsClient)
// 2. Create peer (PeerConnectionManager) with callbacks
// 3. Create session (WebRTCSession) with callbacks:
// - onNetworkStats → this.handleStats()
// - onRemoteVideoActive → this.remoteParticipants.setVideoReceiving()
// - onRemoteVideoStale → this.handleStaleVideo()
// - onBeforeNewSubscriptions → this.clearRemoteParticipant()
// 4. session.connect({ sessionId, guestId })
// 5. Create silent placeholder
// 6. Ensure peer session
}
async disconnect(): Promise<void> {
// SAFETY: if status is 'reconnecting', wait for reconnect to finish first.
// Reconnect owns the teardown/rebuild cycle — disconnect must not race it.
// if (this.stateMachine.state().status === 'reconnecting') return;
//
// this.stateMachine.transition({ status: 'idle' }, 'disconnect');
// 1. this.clearRemoteParticipant()
// 2. this.audioPlayback.teardown()
// 3. Stop camera/mic streams
// 4. session.disconnect()
// 5. peer.closeConnection()
// 6. Reset all state
}
async reconnect(): Promise<void> {
// this.stateMachine.transition({ status: 'reconnecting', sessionID }, 'reconnect');
// SAFETY: reconnect() does its own teardown before rebuilding.
// disconnect() is blocked while status is 'reconnecting'.
// If reconnect fails → transition to 'error', then caller decides:
// retry (→ reconnect again) or give up (→ disconnect).
// Re-run connect() with last params
}
// ── Publish / Subscribe ────────────────────────────────────
async publish(): Promise<void> {
// this.stateMachine.transition({ status: 'joining', sessionID }, 'publish');
// Delegates to session.publishTracks()
// On success: hasPublished = true
// On failure: retry with session recreation
}
async subscribe(params?: SubscribeParams): Promise<void> {
// Delegates to session.subscribeTracks()
// On success: stateMachine.transition({ status: 'call', sessionID }, 'subscribed')
}
clearPublisherSubscription(publisherGuestId: string): void {
// session.clearPublisherSubscription(publisherGuestId)
}
// ── Media Control ──────────────────────────────────────────
async enableCamera(existingStream?: MediaStream): Promise<void> {
// 1. Ensure peer session
// 2. Capture camera (or use existingStream)
// 3. Attach to peer via negotiation queue
// 4. Apply camera sending params
// 5. Set isCameraEnabled(true)
// 6. Refresh local preview
// 7. Re-publish if already in a call
}
async disableCamera(): Promise<void> {
// 1. Disable camera track on peer
// 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. Ensure peer session
// 2. Capture mic (or use existingStream)
// 3. Enforce echo cancellation / noise suppression
// 4. Attach to peer via negotiation queue
// 5. Set isMicEnabled(true)
// 6. Refresh local preview + audio playback
// 7. Re-publish if needed
}
async disableMic(): Promise<void> {
// 1. Replace mic with silent placeholder (maintain SFU presence)
// 2. Stop mic stream
// 3. Set isMicEnabled(false)
// 4. Refresh local preview + audio playback
}
async setCameraQuality(quality: string, fps?: number): Promise<void> {
// Apply camera sending params to peer (resolution, framerate)
}
// ── 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.remoteParticipants.update(...)
}
// ── 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.remoteParticipants.update({ remoteStream: stream, ... })
}
private clearRemoteParticipant(): void {
// Coordinates BOTH extracted classes:
// 1. this.audioPlayback.teardown()
// 2. this.remoteParticipants.clear()
}
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 handleStats(networkStats: NetworkStats | null): void {
// Receive raw stats from WebRTCSession
// Enrich with render diagnostics from remoteParticipants.sampleRenderDiagnostics()
// 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 'call' or 'joining' state and force=true.
}
}
// ═══════════════════════════════════════════════════════════
// Call Types — single source of truth for the call system
// ═══════════════════════════════════════════════════════════
// ── Call State ────────────────────────────────────────────
// Where the call is in its lifecycle. 6 states.
// Error can happen from ANY state.
export type CallState =
| { status: 'idle' }
| { status: 'connected'; hostID: string }
| { status: 'joining'; sessionID: string }
| { status: 'call'; sessionID: string }
| { status: 'reconnecting'; sessionID: string }
| { status: 'error'; message: string; previousStatus?: CallStatus };
export type CallStatus = CallState['status'];
// ── Media State ──────────────────────────────────────────
// What devices are active right now.
export type MediaState = {
isCameraEnabled: boolean;
isMicEnabled: boolean;
isGuestMuted: boolean;
localPreviewStream: MediaStream | null;
};
// ── Participants ─────────────────────────────────────────
// Who is in the call. Separate from CallState.
//
// call + 0 participants = host waiting for guest
// call + 1 participant = active call
// call + 0 after having 1 = other side dropped
export type CallParticipant = {
id: string;
name: string;
isLocal: boolean;
hasVideo: boolean;
hasAudio: boolean;
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 (Layer 1 — business logic)
// WebRTCSession → RTCSessionManager (Layer 2 — SDP, signaling)
// PeerConnectionManager → RTCConnectionManager(Layer 2 — RTCPeerConnection)
// MediaCaptureService → MediaManager (Layer 3 — getUserMedia)
// RemoteParticipantManager → ParticipantManager
// ═══════════════════════════════════════════════════════════
// useCall hook — SolidJS
// Adapted from useWebRTC.ts (RTC-FRONTEND), rewritten for SolidJS + SFU.
// This is what useSessionConnection.ts becomes — much simpler.
import { createEffect, onCleanup } from 'solid-js';
import type { CallService } from './nf-call.service';
import type { CallState } from './nf-call.types';
export function useCall(callService: CallService) {
// ── Reactive effects on callState ──────────────────────────
createEffect(() => {
const state: CallState = callService.callState();
switch (state.status) {
case 'idle':
// Clear errors, reset UI
break;
case 'connected':
// Show "ready" state, clear previous errors
break;
case 'joining':
// Show progress indicator
break;
case 'call':
// Active call — start heartbeat, show call UI
break;
case 'reconnecting':
// Show "reconnecting..." notification
break;
case 'error':
// Show error: state.message
// Offer reconnect button
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 pipeline
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 ───────────────────────
// Called reactively when participant routes change.
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();
// Re-enable camera/mic if they were on
// Re-publish
// Re-subscribe
}
// ── Cleanup on unmount ─────────────────────────────────────
onCleanup(() => {
const state = callService.callState();
if (state.status !== 'idle') {
callService.disconnect();
}
});
// ── Return ─────────────────────────────────────────────────
return {
// State (reactive signals, read in templates)
callState: callService.callState,
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