@robono/calling 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,8 +1,24 @@
1
- # 0.1.3
1
+ # Changelog
2
2
 
3
- Authenticated event listening, no idle state polling, foreground reconciliation and silent idle-session expiry. Bounded event reconnects preserve existing call controls and optional UI.
3
+ ## 0.2.0
4
4
 
5
- # Changelog
5
+ - Add bounded, best-effort transport counters for operational relay visibility. Reports contain no media or network addresses and are not used for billing.
6
+
7
+ Voice and video calls, group calls, screen sharing, participant-based usage reporting and shared-budget call admission. Existing one-to-one voice APIs remain available.
8
+
9
+ - Keep group calls alive during successful signaling, with bounded parallel ICE
10
+ submission and retry recovery. Unrelated requests and stale responses cannot
11
+ extend an active call's authorization lease.
12
+ - Preserve native remote streams so video tracks remain attached to mobile video views.
13
+ - Reuse the original session description after a lost response; ICE gathering cannot change the retried payload.
14
+ - Negotiate group peers independently and prevent pre-start state polling from clearing a new call’s local media.
15
+ - Cap transmitted video when native cameras capture above the selected resolution; reapply the same limits when sharing a screen or restoring the camera.
16
+ - Enforce request deadlines even when native networking ignores cancellation, including stalled response bodies, so signaling can recover instead of remaining stuck.
17
+
18
+
19
+ ## Unreleased
20
+
21
+ - Optional video, group voice/video (two to eight members), separate screen-sharing permissions, and per-participant history. Group signaling preserves isolated peer legs and current relationship permissions. Screen sharing replaces video tracks without changing microphone transport; restored cameras keep their prior choice. Group invitation deadlines and expired-session cleanup stop stale UI and media.
6
22
 
7
23
  ## 0.1.2
8
24
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Robono Calling SDK
2
2
 
3
- Add one-to-one voice calls within your app or between people connected across Robono Bridge networks. The SDK manages WebRTC negotiation, direct connections, relay fallback, call state and microphone cleanup. Your app keeps its identity and platform notification integration. Use your own interface, or choose the separate optional mobile screen package. The calling engine never requires a Robono interface.
3
+ Add voice and video calls within your app or between people connected across Robono Bridge networks, with optional group calls and screen sharing. The SDK manages WebRTC negotiation, direct connections, relay fallback, call state and microphone cleanup. Your app keeps its identity and platform notification integration. Use your own interface, or choose the separate optional mobile screen package. The calling engine never requires a Robono interface.
4
4
 
5
5
  ## Install
6
6
 
@@ -128,12 +128,34 @@ For an established call, `client.hold()` silences local and remote tracks immedi
128
128
 
129
129
  Call `client.resume(prepareAudio)` only when the OS permits audio to return, or when the person explicitly requests resume. The client first refreshes authorization and call state, then awaits your `prepareAudio` callback, acknowledges resume and restores tracks. The callback must reject if another call still owns audio. A stale response, remote hangup, expired session or ended call cannot restart capture. The optional mobile package supplies this wiring and a Resume call control. No voicemail is involved.
130
130
 
131
+ ### Optional video
132
+
133
+ Check `availability(connectionId).videoAvailable` separately from `voiceAvailable`. Start a video call with `client.call(connectionId, {kind: 'video'})`. Ordinary `client.call(connectionId)` remains voice-only. The host's media adapter receives `{kind, isCurrent}`; a video call requires one audio track and one video track. Use `onLocalStream` and `onRemoteStream` to render previews, `setCamera(enabled)` to control outgoing video, and `setVideoForeground(active)` when application visibility changes. Disposal stops all captured tracks.
134
+
135
+ Both apps and the relationship must allow video. Voice-only sessions do not receive video invitations. When using your own WebRTC client, send `mediaKinds: ['voice', 'video']` with `/state`, `kind: 'video'` with `/ice` and `/start`, and supply one audio and one video SDP media section. Voice requests omit `kind` or use `voice`. No data channel or additional camera tracks are admitted. Media travels directly when possible, using the configured relay fallback when needed.
136
+
137
+ Final call history includes `kind`; older records without it are voice calls. Usage records distinguish voice and video and retain connection duration, not the media itself.
138
+
139
+ ## Usage protection and operational visibility
140
+
141
+ The service applies the same protections to own-network and cross-network calls.
142
+ Preserve the request ID when retrying a lost call-start response. Honor
143
+ `Retry-After`; a deliberate new call needs a new request ID. Call status and
144
+ hangup have separate limits so a start loop does not block cleanup.
145
+
146
+ The SDK sends bounded, best-effort transport byte counters every fifteen seconds
147
+ while connected. They contain no audio, video, transcripts, IP addresses or ICE
148
+ candidates. Counters are approximate and are not used to calculate charges.
149
+
150
+ Use the Server SDK from your backend for `monitoring()` and revision-numbered
151
+ `setUserPermissions()`. See [limits, costs and alerts](https://robono.com/docs#calling-reliability).
152
+
131
153
  ## Event-based listening
132
154
 
133
155
  Use `@robono/calling@0.1.3` with `@robono/calling-react-native@0.2.2` when using the optional mobile interface. Existing custom interfaces remain supported. See https://robono.com/docs#calling-events for the lifecycle and recovery contract.
134
156
 
135
157
  - Foreground, idle: an authenticated WebSocket listens for incoming calls. No periodic state polling. A ping/pong every 20 seconds maintains presence.
136
- - Active calls: events trigger state reconciliation immediately; a state health check runs every 3 seconds. The native background fallback also checks every 3 seconds; overlapping checks are coalesced.
158
+ - Active calls: events trigger state reconciliation immediately; a state health check runs every 3 seconds. The optional native runtime also provides a three-second background recovery check when device timers are suspended; simultaneous refreshes share one request.
137
159
  - Idle in background: call `setForeground(false)`. The event socket and state checks stop. Your backend delivers Robono's signed incoming-call events through your app's APNs/FCM notifications. A WebSocket does not replace push delivery.
138
160
  - Return to foreground: call `setForeground(true)`. The socket reconnects and current state is checked. Do not create a new call to recover a listening session. The optional controller forwards this lifecycle method to the engine.
139
161
  - Event reconnection: delays start at 1 second, double to 30 seconds (plus up to 20% jitter), honor Retry-After, and stop after eight retries. Foreground re-entry can restart recovery. State recovery stops after six consecutive failed idle checks.
package/SBOM.spdx.json CHANGED
@@ -2,24 +2,24 @@
2
2
  "spdxVersion": "SPDX-2.3",
3
3
  "dataLicense": "CC0-1.0",
4
4
  "SPDXID": "SPDXRef-DOCUMENT",
5
- "name": "@robono/calling@0.1.3",
6
- "documentNamespace": "http://spdx.org/spdxdocs/@robono%2fcalling-0.1.3-***",
5
+ "name": "@robono/calling@0.2.0",
6
+ "documentNamespace": "http://spdx.org/spdxdocs/@robono%2fcalling-0.2.0-***",
7
7
  "creationInfo": {
8
- "created": "2026-10-25T10:32:11.266Z",
8
+ "created": "2026-10-28T21:07:59.755Z",
9
9
  "creators": [
10
10
  "Tool: npm/cli-11.6.2"
11
11
  ]
12
12
  },
13
13
  "documentDescribes": [
14
- "SPDXRef-Package-robono.calling-0.1.3"
14
+ "SPDXRef-Package-robono.calling-0.2.0"
15
15
  ],
16
16
  "packages": [
17
17
  {
18
18
  "name": "@robono/calling",
19
- "SPDXID": "SPDXRef-Package-robono.calling-0.1.3",
20
- "versionInfo": "0.1.3",
19
+ "SPDXID": "SPDXRef-Package-robono.calling-0.2.0",
20
+ "versionInfo": "0.2.0",
21
21
  "packageFileName": "",
22
- "description": "One-to-one WebRTC voice calling for connected Robono Bridge networks.",
22
+ "description": "WebRTC voice, video, group calling and screen sharing within apps and across Robono Bridge networks.",
23
23
  "primaryPackagePurpose": "LIBRARY",
24
24
  "downloadLocation": "NOASSERTION",
25
25
  "filesAnalyzed": false,
@@ -29,7 +29,7 @@
29
29
  {
30
30
  "referenceCategory": "PACKAGE-MANAGER",
31
31
  "referenceType": "purl",
32
- "referenceLocator": "pkg:npm/%40robono/calling@0.1.3"
32
+ "referenceLocator": "pkg:npm/%40robono/calling@0.2.0"
33
33
  }
34
34
  ]
35
35
  }
@@ -37,7 +37,7 @@
37
37
  "relationships": [
38
38
  {
39
39
  "spdxElementId": "SPDXRef-DOCUMENT",
40
- "relatedSpdxElement": "SPDXRef-Package-robono.calling-0.1.3",
40
+ "relatedSpdxElement": "SPDXRef-Package-robono.calling-0.2.0",
41
41
  "relationshipType": "DESCRIBES"
42
42
  }
43
43
  ]
@@ -0,0 +1,7 @@
1
+ import type {Request,EventStatus} from './index.js';
2
+ export interface GroupCall {screenSharingAvailable?:boolean;sharing?:boolean[];id:string;connectionId:string;group:true;kind:'voice'|'video';version:number;state:'ringing'|'connecting'|'connected'|'ended';reason?:string;createdAt:number;connectedAt:number|null;endedAt:number|null;ringRemainingMs:number;holdExpiresAt:number|null;self:number;controlled:boolean;memberState:'invited'|'joined'|'left'|'declined';localHeld:boolean;members:Array<{id:number;appId:string;userId:string;state:string;held:boolean;connectedAt:number|null}>;peers:Array<{id:string;participant:number;offerer:boolean;remoteDescription:RTCSessionDescriptionInit|null;remoteCandidates:RTCIceCandidateInit[];connected:boolean}>}
3
+ export interface GroupSnapshot {listeningStatus?:EventStatus;screenSharing?:boolean;screenSharePending?:boolean;displayStream?:MediaStream|null;call:GroupCall|null;muted:boolean;cameraEnabled:boolean;localHeld:boolean;localStream:MediaStream|null;remoteStreams:Record<number,MediaStream>;error:string|null;preparing:boolean;connectedAt:number|null}
4
+ export class RobonoGroupCallingClient {
5
+ constructor(options:{videoQuality?:'480p'|'720p';request:Request;onSessionExpired?:(error:Error)=>void;onChange?:(state:GroupSnapshot)=>void;getDisplayMedia?:(context?:{isCurrent:()=>boolean})=>Promise<MediaStream>;getUserMedia?:(context:{kind:'voice'|'video';videoQuality?:'480p'|'720p';isCurrent:()=>boolean})=>Promise<MediaStream>;createPeerConnection?:(config:RTCConfiguration)=>RTCPeerConnection;createRemoteStream?:(track:MediaStreamTrack)=>MediaStream;createId?:()=>string;forceRelay?:boolean;pollMs?:number});
6
+ availability(connectionId:string,kind?:'voice'|'video'):Promise<{available:boolean;maxParticipants:number}>;startScreenSharing():Promise<void>;stopScreenSharing():Promise<void>;state:GroupSnapshot;closed:boolean;listen():Promise<void>;refresh():Promise<boolean>;call(connectionId:string,options?:{kind?:'voice'|'video'}):Promise<void>;answer():Promise<void>;leave():Promise<void>;hold():Promise<void>;resume(prepareAudio?:()=>Promise<void>):Promise<void>;mute(value?:boolean):void;setCamera(value:boolean):void;setForeground(value:boolean):void;setVideoForeground(value:boolean):void;dispose():Promise<void>;
7
+ }
package/dist/groups.js ADDED
@@ -0,0 +1,153 @@
1
+ import {createTransportTelemetry} from './telemetry.js';
2
+ import {mediaProfile,videoConstraints,limitSender} from './media-profile.js';
3
+ import {createScreenSharing} from './sharing.js';
4
+ /** Optional multiparty engine. It shares admission/transport with direct calls, not their UI. */
5
+ export class RobonoGroupCallingClient {
6
+ constructor({request,onSessionExpired=()=>{},onChange=()=>{},videoQuality='720p',getUserMedia=({kind,videoQuality})=>navigator.mediaDevices.getUserMedia({audio:true,video:kind==='video'?videoConstraints(videoQuality):false}),createPeerConnection=c=>new RTCPeerConnection(c),createRemoteStream=track=>new MediaStream([track]),createId=()=>crypto.randomUUID(),forceRelay=false,pollMs=1000,getDisplayMedia=()=>navigator.mediaDevices.getDisplayMedia({video:true,audio:false})}={}){
7
+ Object.assign(this,{onSessionExpired,onChange,getUserMedia,createPeerConnection,createRemoteStream,createId,forceRelay,pollMs});
8
+ this.request=async(operation,input)=>{
9
+ const epoch=this.epoch,result=await request(operation,input);
10
+ // Negotiation also proves the call is authorized and the server is reachable.
11
+ // Availability checks and late replies from a previous call must not extend it.
12
+ if(!this.closed&&epoch===this.epoch&&['state','start','join','description','candidate','connected','hold','sharing'].some(name=>operation==='groups/'+name))this.lastSuccess=Date.now();
13
+ return result;
14
+ };
15
+ this.request.subscribe=request.subscribe;
16
+ this.videoQuality=mediaProfile(videoQuality).quality;
17
+ this.reportTransport=createTransportTelemetry(request);this.peers=new Map();this.pendingRTC=new Set();this.state={call:null,muted:false,cameraEnabled:true,localHeld:false,localStream:null,remoteStreams:{},error:null,preparing:false,connectedAt:null};
18
+ this.sharing=createScreenSharing({limitVideo:sender=>limitSender(sender,this.ice?.videoQuality==='480p'?'480p':this.videoQuality),request:input=>this.request('groups/sharing',input),call:()=>this.state.call,peers:()=>[...this.peers.values()].map(p=>p.pc),stream:()=>this.stream,capture:getDisplayMedia,emit:()=>this.emit(),fail:e=>{this.state.error=e.message;this.emit();}});
19
+ this.closed=false;this.epoch=0;this.foreground=true;this.failures=0;this.notBefore=0;this.lastSuccess=Date.now();this.holdRevision=0;
20
+ }
21
+ rtc(promise,phase){return new Promise((resolve,reject)=>{let finished=false;const finish=(fn,value)=>{if(finished)return;finished=true;clearTimeout(timer);this.pendingRTC.delete(cancel);fn(value);};const cancel=()=>finish(reject,Error('call_cancelled'));const timer=setTimeout(()=>finish(reject,Error(phase+'_timeout')),8000);this.pendingRTC.add(cancel);Promise.resolve(promise).then(v=>finish(resolve,v),e=>finish(reject,e));});}
22
+ emit(){if(this.events&&this.state.call&&this.state.call.state!=='ended'&&!this.timer&&!this.polling&&!this.closed)this.timer=setTimeout(()=>this.poll(),3000);this.scheduleRingDeadline();Object.assign(this.state,this.sharing.state());this.onChange({...this.state,remoteStreams:{...this.state.remoteStreams}});}
23
+ scheduleRingDeadline(){
24
+ const call=this.state.call;
25
+ if(!call?.controlled||call.state==='ended'||!(call.memberState==='invited'||call.state==='ringing')){clearTimeout(this.ringTimer);this.ringCallId=null;return;}
26
+ if(!Number.isFinite(call.ringRemainingMs))return;
27
+ const remaining=Math.max(0,call.ringRemainingMs),deadline=Date.now()+remaining;
28
+ if(this.ringCallId===call.id&&this.ringDeadline<=deadline)return;
29
+ clearTimeout(this.ringTimer);this.ringCallId=call.id;this.ringDeadline=deadline;
30
+ this.ringTimer=setTimeout(()=>{
31
+ const current=this.state.call;if(current?.id!==call.id||current.state==='ended'||!(current.memberState==='invited'||current.state==='ringing'))return;
32
+ this.ended??=new Set();this.ended.add(call.id);while(this.ended.size>256)this.ended.delete(this.ended.values().next().value);
33
+ this.reset();this.state.call={...current,state:'ended',reason:'no_answer',endedAt:Date.now()};this.emit();
34
+ },Math.min(2147483647,remaining));
35
+ }
36
+ syncTracks(){for(const t of this.stream?.getTracks()??[])t.enabled=!this.state.localHeld&&(t.kind==='audio'?!this.state.muted:this.state.cameraEnabled&&this.foreground);}
37
+ mute(value=true){this.state.muted=value;this.syncTracks();this.emit();}
38
+ setCamera(value){this.state.cameraEnabled=value;this.syncTracks();this.emit();}
39
+ setVideoForeground(value){this.foreground=value;this.syncTracks();}
40
+ async availability(connectionId,kind='voice'){if(this.closed)throw Error('calling_client_closed');return this.request('groups/availability',{connectionId,kind});}
41
+ async listen(){if(this.started||this.closed)return;this.started=true;this.watch=setInterval(()=>{if(!this.state.preparing&&this.state.call&&this.stream&&(!(this.state.localHeld&&this.holdDeadline>Date.now())&&Date.now()-this.lastSuccess>10000)){void this.leave().catch(()=>{});}},500);if(this.request.subscribe)this.events=this.request.subscribe({onEvent:()=>this.wake(),onStatus:status=>{this.state.listeningStatus=status;this.emit();},onError:e=>this.sessionError(e)});await this.poll();}
42
+ sessionError(e){const active=this.state.call&&this.state.call.state!=='ended';this.state.error=active?e.message:null;this.state.listeningStatus=e.status===403?'access_revoked':'session_expired';this.reset();this.closed=true;clearTimeout(this.timer);clearInterval(this.watch);this.events?.close();this.emit();if(e.status===401)this.onSessionExpired(e);}
43
+ wake(){if(this.closed)return;clearTimeout(this.timer);if(this.polling||this.state.preparing){this.eventPending=true;return;}void this.poll();}
44
+ setForeground(value){this.foreground=value;this.setVideoForeground(value);this.events?.setEnabled(value||!!this.state.call&&this.state.call.state!=='ended');if(value)this.wake();else if(!this.state.call||this.state.call.state==='ended')clearTimeout(this.timer);}
45
+ async poll(){
46
+ if(this.closed||this.polling)return;this.polling=true;this.timer=null;
47
+ try{await this.refresh();this.reportTransport(this.state.call?.state==='connected'?this.state.call.id:null,[...this.peers].map(([id,p])=>[id,p.pc]));}finally{this.polling=false;}
48
+ if(this.closed)return;const active=this.state.call&&this.state.call.state!=='ended';
49
+ if(!this.foreground&&!active){this.events?.setEnabled(false);return;}
50
+ const pending=this.eventPending;this.eventPending=false;
51
+ if(active||!this.events||this.failures>0&&this.failures<6||pending)this.timer=setTimeout(()=>this.poll(),Math.min(2147483647,Math.max(pending?0:this.events?3000:this.pollMs,this.notBefore-Date.now())));
52
+ }
53
+ async refresh(){
54
+ if(this.closed||this.state.preparing||Date.now()<this.notBefore)return false;
55
+ if(this.refreshing)return this.refreshing;
56
+ const work=this.refreshState();this.refreshing=work;try{return await work;}finally{if(this.refreshing===work)this.refreshing=null;}
57
+ }
58
+ async refreshState(){
59
+ const epoch=this.epoch;
60
+ try{const result=await this.request('groups/state',{});if(this.closed)return false;this.failures=0;this.notBefore=0;
61
+ if(epoch!==this.epoch||this.state.preparing)return false;
62
+ await this.apply(result.call);this.state.error=null;this.emit();return true;
63
+ }catch(e){if(this.closed||epoch!==this.epoch||this.state.preparing)return false;this.notBefore=Date.now()+Math.max(Number(e.retryAfterMs)||0,Math.min(30000,1000*2**Math.min(this.failures++,5)));this.state.error=this.state.call&&this.state.call.state!=='ended'?e.message:null;
64
+ if(e.status===401||e.status===403){this.sessionError(e);return false;}
65
+ if(e.status===401){this.closed=true;clearTimeout(this.timer);clearInterval(this.watch);}
66
+ if([401,403].includes(e.status)||/_timeout$/.test(e.message)){const call=this.state.call;this.reset();this.state.call=null;if(call)void this.request('groups/leave',{callId:call.id}).catch(()=>{});}
67
+ this.emit();return false;}
68
+ }
69
+ async capture(kind,epoch){
70
+ const stream=await this.getUserMedia({kind,videoQuality:this.ice?.videoQuality==='480p'?'480p':this.videoQuality,isCurrent:()=>epoch===this.epoch&&!this.closed});
71
+ if(epoch!==this.epoch||this.closed){stream.getTracks().forEach(t=>t.stop());throw Error('call_cancelled');}
72
+ if(!stream.getAudioTracks().length||kind==='video'&&!stream.getVideoTracks().length){stream.getTracks().forEach(t=>t.stop());throw Error('media_unavailable');}
73
+ this.stream=stream;this.state.localStream=stream;this.syncTracks();
74
+ }
75
+ async call(connectionId,{kind='voice'}={}){
76
+ if(this.state.preparing||this.stream||this.closed)throw Error('busy');
77
+ const epoch=++this.epoch;this.state.preparing=true;this.state.error=null;this.state.connectedAt=null;this.emit();
78
+ try{
79
+ this.ice=await this.request('groups/ice',{connectionId,kind});await this.capture(kind,epoch);
80
+ if(epoch!==this.epoch)throw Error('call_cancelled');
81
+ const input={connectionId,kind,requestId:this.createId()};
82
+ // Same request ID on a lost response; never start a second group call.
83
+ let call;try{call=await this.request('groups/start',input);}catch(e){if(e.status&&e.status<500)throw e;call=await this.request('groups/start',input);}
84
+ if(epoch!==this.epoch){await this.request('groups/leave',{callId:call.id});return;}
85
+ await this.apply(call);
86
+ }catch(e){this.reset();this.state.call=null;this.state.error=e.message;throw e;}
87
+ finally{this.state.preparing=false;this.emit();if(this.eventPending){this.eventPending=false;this.wake();}}
88
+ }
89
+ async answer(){
90
+ const call=this.state.call;if(!call||call.memberState!=='invited'||!call.controlled||this.state.preparing)throw Error('call_unavailable');
91
+ const epoch=++this.epoch;this.state.preparing=true;this.emit();
92
+ try{this.ice=await this.request('groups/ice',{callId:call.id,kind:call.kind});await this.capture(call.kind,epoch);
93
+ if(epoch!==this.epoch)throw Error('call_cancelled');
94
+ let joined;try{joined=await this.request('groups/join',{callId:call.id});}catch(e){if(e.status&&e.status<500)throw e;joined=await this.request('groups/join',{callId:call.id});}
95
+ if(epoch!==this.epoch){await this.request('groups/leave',{callId:call.id});return;}
96
+ await this.apply(joined);
97
+ }catch(e){this.reset();this.state.call=null;void this.request('groups/leave',{callId:call.id}).catch(()=>{});this.state.error=e.message;throw e;}finally{this.state.preparing=false;this.emit();if(this.eventPending){this.eventPending=false;this.wake();}}
98
+ }
99
+ async apply(call){
100
+ if(call?.id&&this.ended?.has(call.id))return;
101
+ this.state.call=call;this.sharing.sync();
102
+ if(!call||call.state==='ended'||!call.controlled||['left','declined'].includes(call.memberState)){this.reset();this.emit();return;}
103
+ if(call.memberState!=='joined'||!this.stream){this.emit();return;}
104
+ const epoch=this.epoch;
105
+ for(const [id,p] of this.peers)if(!call.peers.some(x=>x.id===id)){p.pc.close();this.peers.delete(id);delete this.state.remoteStreams[p.participant];}
106
+ // Each peer negotiates independently; a slow leg must not hold up the others.
107
+ const negotiations=await Promise.allSettled(call.peers.map(async remote=>{
108
+ if(epoch!==this.epoch)return;
109
+ let p=this.peers.get(remote.id);
110
+ if(!p){
111
+ const pc=this.createPeerConnection({iceServers:this.ice?.iceServers??[],iceTransportPolicy:this.forceRelay?'relay':'all'});
112
+ p={pc,participant:remote.participant,remoteCount:0,candidates:[],localSent:false,connectedSent:false};this.peers.set(remote.id,p);
113
+ for(const track of this.stream.getTracks()){const sender=pc.addTrack(track.kind==='video'?this.sharing.track()||track:track,this.stream);if(track.kind==='video')await limitSender(sender,this.ice?.videoQuality==='480p'?'480p':this.videoQuality);}
114
+ pc.onicecandidate=e=>{if(e.candidate)p.candidates.push(e.candidate.toJSON?.()??e.candidate);};
115
+ pc.ontrack=e=>{if(epoch!==this.epoch)return;let stream=this.state.remoteStreams[p.participant];if(e.streams?.[0])stream=e.streams[0];else if(!stream)stream=this.createRemoteStream(e.track);else if(!stream.getTracks().some(t=>t.id===e.track.id))stream.addTrack(e.track);this.state.remoteStreams[p.participant]=stream;this.emit();};
116
+ pc.onconnectionstatechange=()=>{if(epoch!==this.epoch)return;if(pc.connectionState==='connected'){this.state.connectedAt??=Date.now();this.emit();}else if(pc.connectionState==='failed'){void this.request('groups/failed',{callId:call.id,peerId:remote.id}).catch(()=>{});this.reset();this.state.call=null;this.state.error='media_failed';this.emit();}};
117
+ }
118
+ const {pc}=p;
119
+ if(remote.offerer&&!pc.localDescription){const offer=await this.rtc(pc.createOffer(),'offer');if(epoch!==this.epoch)return;await this.rtc(pc.setLocalDescription(offer),'local_description');}
120
+ if(remote.remoteDescription&&!pc.remoteDescription){await this.rtc(pc.setRemoteDescription(remote.remoteDescription),'remote_description');if(epoch!==this.epoch)return;if(!remote.offerer){const answer=await this.rtc(pc.createAnswer(),'answer');if(epoch!==this.epoch)return;await this.rtc(pc.setLocalDescription(answer),'local_description');}}
121
+ if(epoch!==this.epoch)return;
122
+ if(pc.localDescription&&!p.localSent){p.description??={type:pc.localDescription.type,sdp:pc.localDescription.sdp};await this.request('groups/description',{callId:call.id,peerId:remote.id,description:p.description});p.localSent=true;}
123
+ await this.flushCandidates(p,call.id,remote.id,epoch);if(epoch!==this.epoch)return;
124
+ if(pc.remoteDescription)while(p.remoteCount<remote.remoteCandidates.length){await this.rtc(pc.addIceCandidate(remote.remoteCandidates[p.remoteCount]),'candidate');if(epoch!==this.epoch)return;p.remoteCount++;}
125
+ if(pc.connectionState==='connected'&&!p.connectedSent){await this.request('groups/connected',{callId:call.id,peerId:remote.id});p.connectedSent=true;}
126
+ }));
127
+ const failure=negotiations.find(result=>result.status==='rejected');if(failure)throw failure.reason;
128
+ this.emit();
129
+ }
130
+ async flushCandidates(peer,callId,peerId,epoch){
131
+ if(peer.sendingCandidates)return peer.sendingCandidates;
132
+ const work=Promise.resolve().then(async()=>{
133
+ while(peer.candidates.length&&epoch===this.epoch&&!this.closed){
134
+ // A bounded batch avoids one network round trip per ICE address. The server
135
+ // deduplicates retries, including a response lost after accepting a candidate.
136
+ const batch=peer.candidates.slice(0,4);
137
+ const results=await Promise.allSettled(batch.map(candidate=>this.request('groups/candidate',{callId,peerId,candidate})));
138
+ if(epoch!==this.epoch||this.closed)return;
139
+ results.forEach((result,index)=>{if(result.status==='fulfilled'){const i=peer.candidates.indexOf(batch[index]);if(i>=0)peer.candidates.splice(i,1);}});
140
+ const failed=results.find(result=>result.status==='rejected');if(failed)throw failed.reason;
141
+ }
142
+ });
143
+ peer.sendingCandidates=work;
144
+ try{return await work;}finally{if(peer.sendingCandidates===work)peer.sendingCandidates=null;}
145
+ }
146
+ async startScreenSharing(){const result=await this.sharing.start();if(result)await this.apply(result);}
147
+ stopScreenSharing(){return this.sharing.stop();}
148
+ async hold(){if(!this.state.call||this.state.localHeld)return;this.state.localHeld=true;this.syncTracks();this.emit();await this.sharing.stop().catch(()=>{});try{await this.request('groups/hold',{callId:this.state.call.id,held:true,revision:++this.holdRevision});this.holdDeadline=this.state.call.holdExpiresAt||Date.now()+300000;}catch(e){await this.leave();throw e;}}
149
+ async resume(prepareAudio){if(!this.state.localHeld)return;await prepareAudio?.();await this.request('groups/hold',{callId:this.state.call.id,held:false,revision:++this.holdRevision});this.lastSuccess=Date.now();this.state.localHeld=false;this.syncTracks();this.emit();}
150
+ async leave(){const call=this.state.call;++this.epoch;this.ended??=new Set();if(call)this.ended.add(call.id);while(this.ended.size>256)this.ended.delete(this.ended.values().next().value);this.reset();this.state.call=null;this.state.error=null;this.emit();if(call)await this.request('groups/leave',{callId:call.id});}
151
+ reset(){clearTimeout(this.ringTimer);this.ringCallId=null;this.sharing.reset();++this.epoch;for(const cancel of this.pendingRTC)cancel();this.pendingRTC.clear();this.holdDeadline=0;for(const p of this.peers.values())p.pc.close();this.peers.clear();this.stream?.getTracks().forEach(t=>t.stop());this.stream=null;Object.assign(this.state,{localStream:null,remoteStreams:{},localHeld:false,connectedAt:null});this.holdRevision=0;}
152
+ async dispose(){this.events?.close();if(this.closed)return;this.closed=true;clearTimeout(this.timer);clearInterval(this.watch);try{await this.leave();}finally{await this.request('close',{}).catch(()=>{});}}
153
+ }
package/dist/history.js CHANGED
@@ -18,10 +18,10 @@ export function mergeCallHistory(saved, page) {
18
18
  return { cursor: page.nextCursor, entries: [...byId.values()].sort((a,b) => a.sequence - b.sequence) };
19
19
  }
20
20
  export function describeCall(entry) {
21
- const incoming = entry.role === 'callee';
22
- if (entry.connectedAt != null) return { label: incoming ? 'Incoming voice call' : 'Outgoing voice call',
21
+ const incoming = entry.role === 'callee', kind = entry.kind === 'video' ? 'video' : 'voice';
22
+ if (entry.connectedAt != null) return { label: incoming ? `Incoming ${kind} call` : `Outgoing ${kind} call`,
23
23
  detail: `${Math.floor(entry.durationMs / 60000)}:${String(Math.floor(entry.durationMs / 1000) % 60).padStart(2, '0')}`, missed: false };
24
- if (entry.reason === 'declined') return { label: incoming ? 'Declined voice call' : 'Call declined', detail: '', missed: incoming };
25
- if (['cancelled', 'no_answer'].includes(entry.reason)) return { label: incoming ? 'Missed voice call' : entry.reason === 'cancelled' ? 'Cancelled voice call' : 'No answer', detail: '', missed: incoming };
24
+ if (entry.reason === 'declined') return { label: incoming ? `Declined ${kind} call` : 'Call declined', detail: '', missed: incoming };
25
+ if (['cancelled', 'no_answer'].includes(entry.reason)) return { label: incoming ? `Missed ${kind} call` : entry.reason === 'cancelled' ? `Cancelled ${kind} call` : 'No answer', detail: '', missed: incoming };
26
26
  return { label: 'Call couldn’t connect', detail: '', missed: incoming };
27
27
  }
package/dist/index.d.ts CHANGED
@@ -1,26 +1,30 @@
1
- export interface CallHistoryEntry { id: string; sequence: number; connectionId: string; role: 'caller' | 'callee'; reason: string; createdAt: number; answeredAt: number | null; connectedAt: number | null; endedAt: number; durationMs: number }
1
+ export interface CallHistoryEntry { id: string; sequence: number; connectionId: string; kind?: 'voice' | 'video'; role: 'caller' | 'callee'; reason: string; createdAt: number; answeredAt: number | null; connectedAt: number | null; endedAt: number; durationMs: number }
2
2
  export interface CallHistoryPage { entries: CallHistoryEntry[]; nextCursor: number; hasMore: boolean }
3
3
  export type CallState = 'ringing' | 'connecting' | 'connected' | 'ended';
4
- export interface Call { id: string; version: number; connectionId: string; state: CallState; role: 'caller' | 'callee'; controlled: boolean; reason?: string; remoteDescription: RTCSessionDescriptionInit | null; remoteCandidates: RTCIceCandidateInit[]; createdAt: number; ringExpiresAt?: number; ringRemainingMs?: number; localHeld?: boolean; remoteHeld?: boolean; holdExpiresAt?: number | null; holdRevision?: number; answeredAt?: number; connectedAt?: number; endedAt?: number }
5
- export interface Snapshot { listeningStatus?: EventStatus; voiceAvailable: boolean; call: Call | null; muted: boolean; localHeld?: boolean; error: string | null; preparing?: boolean; nativePresentation?: { mode?: string; dismissedCallIds?: string[]; speakerEnabled?: boolean; callerNames?: Record<string, string>; appOwnedCallIds?: string[]; appAnsweringCallIds?: string[]; appRingingCallIds: string[]; systemRingingCallIds: string[] } | null }
4
+ export interface Call {
5
+ self?:number;screenSharingAvailable?:boolean;sharing?:boolean[]; id: string; version: number; connectionId: string; kind?: 'voice' | 'video'; state: CallState; role: 'caller' | 'callee'; controlled: boolean; reason?: string; remoteDescription: RTCSessionDescriptionInit | null; remoteCandidates: RTCIceCandidateInit[]; createdAt: number; ringExpiresAt?: number; ringRemainingMs?: number; localHeld?: boolean; remoteHeld?: boolean; holdExpiresAt?: number | null; holdRevision?: number; answeredAt?: number; connectedAt?: number; endedAt?: number }
6
+ export interface Snapshot { listeningStatus?: EventStatus;screenSharing?:boolean;screenSharePending?:boolean;displayStream?:MediaStream|null; voiceAvailable: boolean; videoAvailable?: boolean; cameraEnabled?: boolean; call: Call | null; muted: boolean; localHeld?: boolean; error: string | null; preparing?: boolean; nativePresentation?: { mode?: string; dismissedCallIds?: string[]; speakerEnabled?: boolean; callerNames?: Record<string, string>; appOwnedCallIds?: string[]; appAnsweringCallIds?: string[]; appRingingCallIds: string[]; systemRingingCallIds: string[] } | null }
6
7
  export interface EventSubscription { setEnabled(enabled: boolean): void; close(): void }
7
8
  export type EventStatus = 'connecting' | 'listening' | 'reconnecting' | 'disconnected' | 'paused' | 'session_expired' | 'access_revoked';
8
9
  export interface Request { (operation: string, payload?: Record<string, unknown>): Promise<any>; subscribe?: (callbacks: {onEvent: () => void; onStatus?: (status: EventStatus) => void; onError?: (error: Error & {status?: number}) => void}) => EventSubscription }
9
10
  export function createHttpTransport(options: { baseUrl: string; token: string; fetcher?: typeof fetch; events?: boolean; WebSocketImpl?: typeof WebSocket }): Request;
10
11
  export class RobonoCallingClient {
11
- constructor(options: { request: Request; onSessionExpired?: (error: Error) => void; onChange?: (state: Snapshot) => void; onRemoteStream?: (stream: MediaStream | null) => void; getUserMedia?: (context?: { isCurrent: () => boolean }) => Promise<MediaStream>; createPeerConnection?: (config: RTCConfiguration) => RTCPeerConnection; pollMs?: number; forceRelay?: boolean; createId?: () => string; createRemoteStream?: (track: MediaStreamTrack) => MediaStream });
12
+ constructor(options: { videoQuality?: '480p' | '720p'; request: Request; onSessionExpired?: (error: Error) => void; onChange?: (state: Snapshot) => void; onLocalStream?: (stream: MediaStream | null) => void; onRemoteStream?: (stream: MediaStream | null) => void; getDisplayMedia?:(context?:{isCurrent:()=>boolean})=>Promise<MediaStream>;getUserMedia?: (context?: { kind?: 'voice' | 'video'; videoQuality?: '480p'|'720p'; isCurrent: () => boolean }) => Promise<MediaStream>; createPeerConnection?: (config: RTCConfiguration) => RTCPeerConnection; pollMs?: number; forceRelay?: boolean; createId?: () => string; createRemoteStream?: (track: MediaStreamTrack) => MediaStream });
12
13
  state: Snapshot;
13
14
  readonly closed: boolean;
14
- setForeground(value: boolean): void;
15
15
  listen(): Promise<void>;
16
16
  refresh(): Promise<boolean>;
17
- call(connectionId?: string): Promise<void>;
17
+ call(connectionId?: string, options?: {kind?: 'voice' | 'video'}): Promise<void>;
18
18
  history(query?: { after?: number; limit?: number; connectionId?: string }): Promise<CallHistoryPage>;
19
- availability(connectionId: string): Promise<{ voiceAvailable: boolean; reason?: 'ready' | 'calling_not_enabled' | 'contact_unavailable' }>;
19
+ availability(connectionId: string): Promise<{ voiceAvailable: boolean; videoAvailable?: boolean; reason?: 'ready' | 'calling_not_enabled' | 'contact_unavailable'; videoReason?: 'ready' | 'calling_not_enabled' | 'contact_unavailable' }>;
20
20
  answer(): Promise<void>;
21
21
  hangUp(): Promise<void>;
22
22
  hold(): Promise<void>;
23
23
  resume(prepareAudio?: () => Promise<void>): Promise<void>;
24
+ setForeground(value: boolean): void;
25
+ setVideoForeground(value: boolean): void;
26
+ startScreenSharing():Promise<void>;stopScreenSharing():Promise<void>;
27
+ setCamera(enabled: boolean): void;
24
28
  mute(muted?: boolean): void;
25
29
  dispose(): Promise<void>;
26
30
  }
package/dist/index.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import {subscribeCallingEvents} from './events.js';
2
- /** Audio-only calling client. Media and identity adapters are supplied by the host. */
2
+ import {createTransportTelemetry} from './telemetry.js';
3
+ import {mediaProfile,videoConstraints,limitSender} from './media-profile.js';
4
+ import {createScreenSharing} from './sharing.js';
5
+ /** Voice and video calling client. Media and identity adapters are supplied by the host. */
3
6
  export function createHttpTransport({ baseUrl, token, fetcher = fetch, events = true, WebSocketImpl = globalThis.WebSocket }) {
4
7
  const request = async (operation, payload = {}) => {
5
8
  const controller = new AbortController();
@@ -43,18 +46,23 @@ export function createHttpTransport({ baseUrl, token, fetcher = fetch, events =
43
46
  }
44
47
 
45
48
  export class RobonoCallingClient {
46
- constructor({ request, onSessionExpired = () => {}, onChange = () => {}, onRemoteStream = () => {},
47
- getUserMedia = () => navigator.mediaDevices.getUserMedia({ audio: true, video: false }),
49
+ constructor({ request, onSessionExpired = () => {}, onChange = () => {}, onRemoteStream = () => {}, onLocalStream = () => {},
50
+ getUserMedia = context => navigator.mediaDevices.getUserMedia({ audio: true, video: context?.kind === 'video' ? videoConstraints(context.videoQuality) : false }),
51
+ videoQuality = '720p',
48
52
  createPeerConnection = config => new RTCPeerConnection(config), pollMs = 1000, forceRelay = false, createId = () => crypto.randomUUID(),
53
+ getDisplayMedia = ()=>navigator.mediaDevices.getDisplayMedia({video:true,audio:false}),
49
54
  createRemoteStream = track => new MediaStream([track]) } = {}) {
50
- Object.assign(this, { request, onSessionExpired, onChange, onRemoteStream, getUserMedia, createPeerConnection, pollMs, forceRelay, createId, createRemoteStream });
51
- this.state = { voiceAvailable: false, call: null, muted: false, error: null };
55
+ Object.assign(this, { request, onSessionExpired, onChange, onRemoteStream, onLocalStream, getUserMedia, createPeerConnection, pollMs, forceRelay, createId, createRemoteStream });
56
+ this.videoQuality=mediaProfile(videoQuality).quality;
57
+ this.state = { voiceAvailable: false, call: null, muted: false, cameraEnabled: true, error: null };
52
58
  this.pc = null; this.stream = null; this.epoch = 0; this.busy = false; this.closed = false;
53
59
  this.lastSuccess = Date.now(); this.started = false; this.remoteCount = 0;
54
60
  this.pendingRTC = new Set(); this.mediaTimers = new Set();
55
61
  this.locallyEnded = new Set(); this.endRequests = new Map();
56
62
  this.expiredRings = new Set();
57
63
  this.pollFailures = 0; this.pollNotBefore = 0; this.foreground = true;
64
+ this.sharing=createScreenSharing({limitVideo:sender=>limitSender(sender,this.activeVideoQuality??this.videoQuality),request:input=>this.retry('sharing',input),call:()=>this.state.call,peers:()=>this.pc?[this.pc]:[],stream:()=>this.stream,capture:getDisplayMedia,emit:()=>this.emit(),fail:e=>{this.state.error=e.message;this.emit();}});
65
+ this.reportTransport=createTransportTelemetry(request);
58
66
  this.localHeld = false; this.holdRevision = 0; this.holdDeadline = 0;
59
67
  }
60
68
  rtc(promise, phase, timeoutMs = 8000) {
@@ -73,7 +81,7 @@ export class RobonoCallingClient {
73
81
  // device can communicate. Keep the server snapshot separate for sequencing.
74
82
  const connected = this.mediaConnectedAt != null && this.mediaCallId === call?.id &&
75
83
  call.controlled && ['connecting', 'connected'].includes(call.state);
76
- this.onChange({ ...this.state, preparing: this.busy, localHeld: this.localHeld,
84
+ this.onChange({ ...this.state,...this.sharing.state(), preparing: this.busy, localHeld: this.localHeld,
77
85
  call: connected ? { ...call, state: this.pc?.connectionState === 'disconnected' ? 'connecting' : 'connected', connectedAt: this.mediaConnectedAt } : call });
78
86
  }
79
87
  scheduleRingDeadline(call) {
@@ -124,7 +132,7 @@ export class RobonoCallingClient {
124
132
  if(e.status===401)this.onSessionExpired(e);
125
133
  }
126
134
  wake(){if(this.closed)return;clearTimeout(this.timer);if(this.polling||this.busy){this.eventPending=true;return;}void this.poll();}
127
- setForeground(value){this.foreground=value;this.events?.setEnabled(value||!!this.state.call&&this.state.call.state!=='ended');if(value)this.wake();else if(!this.state.call||this.state.call.state==='ended')clearTimeout(this.timer);}
135
+ setForeground(value){this.foreground=value;this.setVideoForeground(value);this.events?.setEnabled(value||!!this.state.call&&this.state.call.state!=='ended');if(value)this.wake();else if(!this.state.call||this.state.call.state==='ended')clearTimeout(this.timer);}
128
136
 
129
137
  // Refresh without scheduling a second poll loop (also used for native wake races).
130
138
  // False means the cached snapshot is not authoritative for this attempt.
@@ -138,14 +146,14 @@ export class RobonoCallingClient {
138
146
  if (this.closed || Date.now() < this.pollNotBefore) return false;
139
147
  const epoch = this.epoch;
140
148
  try {
141
- const state = await this.request('state');
149
+ const state = await this.request('state', {mediaKinds:['voice','video']});
142
150
  if (this.closed) return false;
143
151
  this.lastSuccess = Date.now();
144
152
  this.pollFailures = 0; this.pollNotBefore = 0;
145
153
  if (epoch === this.epoch) {
146
154
  this.state.error = null;
147
155
  if (!this.busy && !(state.version < (this.state.call?.version ?? -1))) {
148
- this.state.voiceAvailable = state.voiceAvailable;
156
+ this.state.voiceAvailable = state.voiceAvailable; this.state.videoAvailable = state.videoAvailable === true;
149
157
  await this.apply(state.call);
150
158
  if (this.pendingHold) await this.confirmHold(this.pendingHold);
151
159
  return true;
@@ -168,6 +176,7 @@ export class RobonoCallingClient {
168
176
  if(this.closed||this.polling)return;
169
177
  this.polling=true;this.timer=null;
170
178
  try{await this.refresh();
179
+ this.reportTransport(this.state.call?.state==='connected'?this.state.call.id:null,this.pc?[['direct',this.pc]]:[]);
171
180
  }finally{this.polling=false;}
172
181
  if(this.closed)return;
173
182
  const active=this.state.call&&this.state.call.state!=='ended';
@@ -200,12 +209,13 @@ export class RobonoCallingClient {
200
209
  }
201
210
  }
202
211
  if (wasHeld && !call.remoteHeld && !this.localHeld) this.checkResumedTransport();
203
- this.syncAudioTracks();
212
+ this.sharing.sync();this.syncAudioTracks();
204
213
  this.emit();
205
214
  }
206
215
  syncAudioTracks() {
207
216
  const held = this.localHeld || this.state.call?.localHeld || this.state.call?.remoteHeld;
208
217
  this.stream?.getAudioTracks().forEach(t => { t.enabled = !held && !this.state.muted; });
218
+ this.stream?.getVideoTracks().forEach(t => { t.enabled = !held && this.videoForeground !== false && this.state.cameraEnabled; });
209
219
  this.remoteStream?.getAudioTracks().forEach(t => { t.enabled = !held; });
210
220
  }
211
221
  /** Silence immediately; only a server-acknowledged hold relaxes the lease. */
@@ -213,6 +223,7 @@ export class RobonoCallingClient {
213
223
  const call = this.state.call;
214
224
  if (!call?.controlled || call.state !== 'connected' || !this.pc) throw Error('call_not_connected');
215
225
  this.localHeld = true; this.syncAudioTracks(); this.emit();
226
+ await this.sharing.stop().catch(()=>{});
216
227
  const revision = this.holdRevision = Math.max(this.holdRevision, call.holdRevision || 0) + 1;
217
228
  const intent = this.pendingHold = {callId: call.id, held: true, revision, at: Date.now()};
218
229
  await this.confirmHold(intent);
@@ -256,6 +267,7 @@ export class RobonoCallingClient {
256
267
  }
257
268
  }
258
269
  resetMedia() {
270
+ this.sharing.reset();
259
271
  this.epoch++;
260
272
  for (const timer of this.mediaTimers) clearTimeout(timer); this.mediaTimers.clear();
261
273
  for (const cancel of this.pendingRTC) cancel();
@@ -263,25 +275,31 @@ export class RobonoCallingClient {
263
275
  if (pc) { pc.onicecandidate = pc.onconnectionstatechange = pc.ontrack = null; pc.close(); }
264
276
  this.stream?.getTracks().forEach(t => t.stop()); this.stream = null;
265
277
  this.remoteStream = null; this.localHeld = false; this.holdDeadline = 0; this.holdRevision = 0; this.pendingHold = null;
266
- this.candidates = []; this.remoteCount = 0; this.mediaCallId = null; this.mediaConnectedAt = null; this.state.muted = false;
267
- this.onRemoteStream(null);
278
+ this.candidates = []; this.remoteCount = 0; this.mediaCallId = null; this.mediaConnectedAt = null; this.state.muted = false; this.state.cameraEnabled = true;
279
+ this.onRemoteStream(null); this.onLocalStream(null);
268
280
  }
269
- async prepare(connectionId) {
281
+ async prepare(connectionId, kind = 'voice') {
270
282
  this.resetMedia(); const epoch = this.epoch;
271
- const ice = await this.request('ice', connectionId ? { connectionId } : {});
283
+ const ice = await this.request('ice', { ...(connectionId ? { connectionId } : {}), kind });
272
284
  if (this.closed || epoch !== this.epoch) throw Error('call_cancelled');
273
285
  if (this.forceRelay && !ice.relayAvailable) throw Error('relay_not_configured');
274
- const stream = await this.rtc(this.getUserMedia({ isCurrent: () => !this.closed && epoch === this.epoch }).then(stream => {
286
+ this.activeVideoQuality=ice.videoQuality==='480p'?'480p':this.videoQuality;
287
+ const stream = await this.rtc(this.getUserMedia({ kind, videoQuality: ice.videoQuality === '480p' ? '480p' : this.videoQuality, isCurrent: () => !this.closed && epoch === this.epoch }).then(stream => {
275
288
  if (this.closed || epoch !== this.epoch) stream.getTracks().forEach(t => t.stop());
276
289
  return stream;
277
290
  }), 'microphone', 30000);
278
291
  if (this.closed || epoch !== this.epoch) { stream.getTracks().forEach(t => t.stop()); throw Error('call_cancelled'); }
279
- if (!stream.getAudioTracks().length || stream.getVideoTracks().length) { stream.getTracks().forEach(t => t.stop()); throw Error('audio_only'); }
280
- this.stream = stream;
292
+ if (stream.getAudioTracks().length !== 1 || stream.getVideoTracks().length !== (kind === 'video' ? 1 : 0)) { stream.getTracks().forEach(t => t.stop()); throw Error(kind === 'video' ? 'camera_required' : 'audio_only'); }
293
+ this.stream = stream; this.syncAudioTracks(); this.onLocalStream(stream);
281
294
  const pc = this.pc = this.createPeerConnection({ iceServers: ice.iceServers, iceTransportPolicy: this.forceRelay ? 'relay' : 'all' });
282
295
  this.candidates = []; this.sendTail = Promise.resolve();
283
- for (const track of stream.getAudioTracks()) pc.addTrack(track, stream);
284
- pc.ontrack = e => { if (this.pc === pc) { this.remoteStream = e.streams[0] || this.createRemoteStream(e.track); this.syncAudioTracks(); this.onRemoteStream(this.remoteStream); } };
296
+ for (const track of stream.getTracks()){const sender=pc.addTrack(track, stream);if(track.kind==='video')await limitSender(sender,ice.videoQuality==='480p'?'480p':this.videoQuality);}
297
+ pc.ontrack = e => { if (this.pc === pc) {
298
+ if (e.streams?.[0]) this.remoteStream = e.streams[0];
299
+ else if (!this.remoteStream) this.remoteStream = this.createRemoteStream(e.track);
300
+ else if (!this.remoteStream.getTracks().some(t => t.id === e.track.id)) this.remoteStream.addTrack(e.track);
301
+ this.syncAudioTracks(); this.onRemoteStream(this.remoteStream);
302
+ } };
285
303
  pc.onicecandidate = e => {
286
304
  if (!e.candidate || this.pc !== pc) return;
287
305
  this.candidates.push(e.candidate.toJSON()); this.flushCandidates();
@@ -344,13 +362,14 @@ export class RobonoCallingClient {
344
362
  try { return await this.request(operation, payload); }
345
363
  catch (e) { if (e.status && e.status < 500) throw e; return this.request(operation, payload); }
346
364
  }
347
- async call(connectionId) {
365
+ async call(connectionId, {kind = 'voice'} = {}) {
366
+ if (!['voice','video'].includes(kind)) throw Error('invalid_call_kind');
348
367
  if (this.state.call && this.state.call.state !== 'ended') throw Error('busy');
349
368
  return this.action(async () => {
350
- const pc = await this.prepare(connectionId); const epoch = this.epoch;
369
+ const pc = await this.prepare(connectionId, kind); const epoch = this.epoch;
351
370
  await this.rtc(pc.setLocalDescription(await this.rtc(pc.createOffer(), 'create_offer')), 'local_description');
352
371
  if (this.epoch !== epoch || this.closed) throw Error('call_cancelled');
353
- const payload = { ...(connectionId ? { connectionId } : {}), requestId: this.createId(), description: { type: 'offer', sdp: pc.localDescription.sdp } };
372
+ const payload = { ...(connectionId ? { connectionId } : {}), kind, requestId: this.createId(), description: { type: 'offer', sdp: pc.localDescription.sdp } };
354
373
  // Retrying this exact request is safe: the service deduplicates requestId.
355
374
  const call = await this.retry('start', payload); this.lastSuccess = Date.now();
356
375
  if (this.epoch !== epoch) { await this.retry('end', { callId: call.id }); return; }
@@ -361,7 +380,7 @@ export class RobonoCallingClient {
361
380
  const call = this.state.call;
362
381
  if (!call || call.role !== 'callee' || call.state !== 'ringing' || !call.controlled) throw Error('no_incoming_call');
363
382
  return this.action(async () => {
364
- const pc = await this.prepare(call.connectionId), epoch = this.epoch;
383
+ const pc = await this.prepare(call.connectionId, call.kind || 'voice'), epoch = this.epoch;
365
384
  await this.rtc(pc.setRemoteDescription(call.remoteDescription), 'remote_description');
366
385
  await this.rtc(pc.setLocalDescription(await this.rtc(pc.createAnswer(), 'create_answer')), 'local_description');
367
386
  if (this.epoch !== epoch || this.closed) throw Error('call_cancelled');
@@ -391,6 +410,13 @@ export class RobonoCallingClient {
391
410
  }
392
411
  this.emit();
393
412
  }
413
+ async startScreenSharing(){const result=await this.sharing.start();if(result&&this.state.call?.id===result.id)this.state.call=result;this.emit();}
414
+ stopScreenSharing(){return this.sharing.stop();}
415
+ setVideoForeground(value) { this.videoForeground = value; this.syncAudioTracks(); }
416
+ setCamera(enabled) {
417
+ if (typeof enabled !== 'boolean' || !this.stream?.getVideoTracks().length) return;
418
+ this.state.cameraEnabled = enabled; this.syncAudioTracks(); this.emit();
419
+ }
394
420
  mute(muted = true) { this.state.muted = muted; this.syncAudioTracks(); this.emit(); }
395
421
  async dispose() {
396
422
  this.events?.close();
@@ -0,0 +1,18 @@
1
+ /** Shared capture and per-peer send limits. Resolution is a ceiling, not a promise. */
2
+ export function mediaProfile(quality='720p') {
3
+ if(!['480p','720p'].includes(quality))throw Error('invalid_video_quality');
4
+ return quality==='480p'?{quality,width:854,height:480,frameRate:24,maxBitrate:600000}:{quality,width:1280,height:720,frameRate:24,maxBitrate:1500000};
5
+ }
6
+ export function videoConstraints(quality){const p=mediaProfile(quality);return {facingMode:'user',width:{ideal:p.width,max:p.width},height:{ideal:p.height,max:p.height},frameRate:{ideal:p.frameRate,max:p.frameRate}};}
7
+ export async function limitSender(sender,quality){
8
+ // Some hosts supply a custom transport. A real RTCRtpSender supports these APIs.
9
+ if(!sender?.getParameters||!sender?.setParameters)return;
10
+ const profile=mediaProfile(quality),settings=sender.track?.getSettings?.()??{};
11
+ // Native cameras may select a supported capture size above the requested one.
12
+ // Limit what is transmitted, including portrait and replacement display tracks.
13
+ const width=Number(settings.width)||0,height=Number(settings.height)||0;
14
+ const scale=Math.max(1,Math.max(width,height)/profile.width,Math.min(width,height)/profile.height);
15
+ const p=sender.getParameters();if(!p.encodings?.length)p.encodings=[{}];
16
+ for(const encoding of p.encodings){encoding.maxBitrate=profile.maxBitrate;encoding.maxFramerate=profile.frameRate;encoding.scaleResolutionDownBy=scale;}
17
+ await sender.setParameters(p);
18
+ }
@@ -0,0 +1,46 @@
1
+ /** Replaces the existing video track. Microphone routing and call transport stay intact. */
2
+ export function createScreenSharing({request,call,peers,stream,capture,emit,fail,limitVideo=async()=>{}}) {
3
+ let display=null,camera=null,generation=0,revision=0,pending=null,stopping=null,active=false,replaceTail=Promise.resolve();
4
+ const stopTracks=s=>s?.getTracks().forEach(t=>{t.onended=null;t.stop();});
5
+ const state=()=>({screenSharing:active,screenSharePending:!!pending||!!stopping,displayStream:display});
6
+ const replace=track=>{const targets=[...peers()];const work=replaceTail.catch(()=>{}).then(async()=>{for(const pc of targets){const sender=pc.getSenders().find(s=>s.track?.kind==='video');if(!sender)throw Error('video_track_required');await sender.replaceTrack(track);await limitVideo(sender);}});replaceTail=work;return work;};
7
+ const stop=(notify=true)=>{
8
+ if(stopping)return stopping;
9
+ if(!display&&!pending&&!active)return Promise.resolve();
10
+ ++generation;const old=display,restore=camera,c=call();display=null;camera=null;active=false;
11
+ stopTracks(old);
12
+ const work=Promise.resolve().then(async()=>{
13
+ // Serialize against an in-flight replacement; never restore over a newer share.
14
+ if(restore&&stream()?.getVideoTracks().includes(restore))try{await replace(restore);}catch(e){fail(e);}
15
+ if(notify&&c?.id&&c.state!=='ended')await request({callId:c.id,sharing:false,revision:++revision});
16
+ }).finally(()=>{if(stopping===work){stopping=null;emit();}});
17
+ stopping=work;emit();return work;
18
+ };
19
+ return {state,
20
+ async start(){
21
+ const c=call();if(pending||stopping||active)throw Error('screen_share_busy');
22
+ if(!c||c.kind!=='video'||c.state!=='connected'||!c.controlled||!c.screenSharingAvailable||c.localHeld||c.remoteHeld)throw Error('screen_share_unavailable');
23
+ if(c.sharing?.some(Boolean))throw Error('screen_already_shared');
24
+ if(!capture)throw Error('screen_capture_adapter_required');
25
+ const epoch=++generation,token={};pending=token;emit();let captured;
26
+ try{
27
+ // Invoked immediately from the person's tap; browsers require user activation.
28
+ captured=await capture({isCurrent:()=>epoch===generation&&call()?.id===c.id});
29
+ if(epoch!==generation||call()?.id!==c.id){stopTracks(captured);throw Error('call_cancelled');}
30
+ const track=captured.getVideoTracks()[0];if(!track||track.readyState==='ended')throw Error('screen_capture_unavailable');
31
+ // Never accidentally include system sound or another microphone track.
32
+ captured.getAudioTracks().forEach(t=>{t.stop();captured.removeTrack(t);});
33
+ const granted=await request({callId:c.id,sharing:true,revision:++revision});
34
+ if(epoch!==generation||call()?.id!==c.id){stopTracks(captured);await request({callId:c.id,sharing:false,revision:++revision});throw Error('call_cancelled');}
35
+ camera=stream()?.getVideoTracks()[0];if(!camera)throw Error('video_track_required');
36
+ display=captured;await replace(track);
37
+ if(epoch!==generation){stopTracks(captured);return;}
38
+ active=true;track.onended=()=>{void stop().catch(fail);};emit();return granted;
39
+ }catch(e){stopTracks(captured);if(epoch===generation)await stop().catch(fail);throw e;}
40
+ finally{if(pending===token){pending=null;emit();}}
41
+ },stop,
42
+ sync(){const c=call();if(active&&(!c?.screenSharingAvailable||!c.sharing?.[c.self]||c.localHeld||c.remoteHeld||c.state==='ended'))void stop(false).catch(fail);},
43
+ reset(){++generation;stopTracks(display);display=camera=null;active=false;pending=stopping=null;revision=0;},
44
+ track(){return active?display?.getVideoTracks()[0]:null;}
45
+ };
46
+ }
@@ -0,0 +1,24 @@
1
+ /** Transport counters only. Never sends candidates, addresses, media, or transcripts. */
2
+ export function createTransportTelemetry(request){
3
+ let after=0,busy=false;
4
+ return function report(callId,peers){
5
+ if(!callId||busy||Date.now()<after||!peers.length)return;
6
+ after=Date.now()+15000;busy=true;
7
+ void (async()=>{
8
+ const samples=[];
9
+ for(const [id,pc] of peers){
10
+ if(typeof pc?.getStats!=='function'||pc.connectionState!=='connected')continue;
11
+ let timer;const stats=await Promise.race([pc.getStats(),new Promise((_,reject)=>{timer=setTimeout(()=>reject(Error('stats_timeout')),1000);})]).finally(()=>clearTimeout(timer));
12
+ const rows=[];stats.forEach(r=>rows.push(r));const byId=new Map(rows.map(r=>[r.id,r]));
13
+ const sample={id,sentBytes:0,receivedBytes:0,relaySentBytes:0,relayReceivedBytes:0};
14
+ for(const r of rows)if(r.type==='candidate-pair'&&r.state==='succeeded'){
15
+ const sent=Number.isSafeInteger(r.bytesSent)&&r.bytesSent>=0?r.bytesSent:0,received=Number.isSafeInteger(r.bytesReceived)&&r.bytesReceived>=0?r.bytesReceived:0;
16
+ sample.sentBytes+=sent;sample.receivedBytes+=received;
17
+ if([r.localCandidateId,r.remoteCandidateId].some(key=>byId.get(key)?.candidateType==='relay')){sample.relaySentBytes+=sent;sample.relayReceivedBytes+=received;}
18
+ }
19
+ samples.push(sample);
20
+ }
21
+ if(samples.length)await request('telemetry',{callId,samples});
22
+ })().catch(()=>{}).finally(()=>{busy=false;});
23
+ };
24
+ }
package/package.json CHANGED
@@ -17,8 +17,8 @@
17
17
  "provenance": true
18
18
  },
19
19
  "name": "@robono/calling",
20
- "version": "0.1.3",
21
- "description": "One-to-one WebRTC voice calling for connected Robono Bridge networks.",
20
+ "version": "0.2.0",
21
+ "description": "WebRTC voice, video, group calling and screen sharing within apps and across Robono Bridge networks.",
22
22
  "homepage": "https://robono.com/docs#calling",
23
23
  "repository": {
24
24
  "type": "git",
@@ -35,6 +35,10 @@
35
35
  "./history": {
36
36
  "types": "./dist/history.d.ts",
37
37
  "import": "./dist/history.js"
38
+ },
39
+ "./groups": {
40
+ "types": "./dist/groups.d.ts",
41
+ "import": "./dist/groups.js"
38
42
  }
39
43
  },
40
44
  "files": [
@@ -46,5 +50,11 @@
46
50
  ],
47
51
  "engines": {
48
52
  "node": ">=22"
53
+ },
54
+ "scripts": {
55
+ "build": "node build.mjs",
56
+ "test": "npm run build && node --test test/client.test.mjs test/package.test.mjs test/group-client.test.mjs test/sharing.test.mjs test/telemetry.test.mjs test/events.test.mjs",
57
+ "prepack": "npm run build && node ../../scripts/generate-package-sbom.mjs",
58
+ "prepublishOnly": "npm test"
49
59
  }
50
60
  }