@broberg/call 0.1.0 → 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/README.md +14 -3
- package/dist/client.cjs +117 -2
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +46 -1
- package/dist/client.d.ts +46 -1
- package/dist/client.js +117 -3
- package/dist/client.js.map +1 -1
- package/dist/server.cjs +19 -2
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +15 -3
- package/dist/server.d.ts +15 -3
- package/dist/server.js +20 -3
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# @broberg/call
|
|
2
2
|
|
|
3
|
-
Voice calls for 2–10 people, like a Slack huddle: the app decides who may enter a room, the audio goes through a self-hosted LiveKit media server (SFU) in the EU, and the browser gets mute, output mute, device choice, per-person volume, who-is-speaking and the participant list.
|
|
3
|
+
Voice calls for 2–10 people, like a Slack huddle: the app decides who may enter a room, the audio goes through a self-hosted LiveKit media server (SFU) in the EU, and the browser gets mute, output mute, device choice, per-person volume, who-is-speaking and the participant list. Camera and screen share since 0.2.0 (opt-in per ticket). Epic F104.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
npm i @broberg/call
|
|
7
7
|
```
|
|
8
8
|
|
|
9
|
-
> **Renamed from `@broberg/voice` (F104.6).** `@broberg/call` 0.1.0 is `@broberg/voice` 0.2.0 under a new name — same API, same server. Moving over means changing the import path only: `@broberg/voice/server` → `@broberg/call/server`, `@broberg/voice/client` → `@broberg/call/client`. Camera and screen share
|
|
9
|
+
> **Renamed from `@broberg/voice` (F104.6).** `@broberg/call` 0.1.0 is `@broberg/voice` 0.2.0 under a new name — same API, same server. Moving over means changing the import path only: `@broberg/voice/server` → `@broberg/call/server`, `@broberg/voice/client` → `@broberg/call/client`. Camera and screen share arrived in 0.2.0 (F104.7); recording is planned (F104.9).
|
|
10
10
|
>
|
|
11
11
|
> **Server:** the shared LiveKit media server `broberg-voice` (Fly `arn`, voice.broberg.ai, F104.2) with TURN over TLS on 443 in `broberg-voice-turn` (F104.5); both owned by components.
|
|
12
12
|
|
|
@@ -31,7 +31,8 @@ if (event.event === "participant_joined") { /* … */ }
|
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
- **Listen-only (0.2.0):** `createRoomToken({ …, canPublish: false })` gives a ticket that can join and hear everyone but publish nothing — pair it with `joinVoice({ url, token, publish: false })`, which never opens the microphone (`call.muted` is `true`; `unmute()` would be refused by the server).
|
|
34
|
-
- The ticket admits to **one room**, may **only publish the microphone**
|
|
34
|
+
- The ticket admits to **one room**, may **only publish the microphone** unless you pass `sources` (never the data channel), and may listen to everyone in the room.
|
|
35
|
+
- **Camera and screen share (0.2.0):** `createRoomToken({ …, sources: ["microphone", "camera", "screen_share", "screen_share_audio"] })` — the ticket allows exactly the sources named. Leave `sources` out and it is microphone-only, as before, so an app that never asks for video keeps a sound-only ticket. Empty or unknown sources, and `sources` together with `canPublish: false`, are refused (`VoiceError` `invalid_sources`).
|
|
35
36
|
- Missing `apiKey`/`apiSecret` is refused (`VoiceError` `missing_credentials`) — LiveKit's SDK would otherwise silently fall back to `LIVEKIT_*` from the environment.
|
|
36
37
|
- Every rejection of a webhook (changed body, wrong secret, missing header) is `VoiceError` `invalid_webhook`.
|
|
37
38
|
|
|
@@ -52,10 +53,20 @@ call.setVolume(identity, 0.5); // … each person's own vo
|
|
|
52
53
|
await call.setInputDevice(deviceId); // from navigator.mediaDevices.enumerateDevices()
|
|
53
54
|
await call.setOutputDevice(deviceId);
|
|
54
55
|
await call.leave(); // disconnects; no callback fires after this
|
|
56
|
+
|
|
57
|
+
// 0.2.0 — camera and screen share (the ticket must allow them, see `sources`)
|
|
58
|
+
await call.setScreenShare(true); // the browser asks which screen/window/tab; { audio: true } offers tab sound
|
|
59
|
+
await call.setCamera(true, { deviceId }); // deviceId optional
|
|
60
|
+
call.localMedia; // { camera, screenShare } — what you send now
|
|
61
|
+
call.onLocalMedia((m) => setButtons(m)); // at once + on every change, INCLUDING the browser's own «stop sharing» button
|
|
62
|
+
call.onVideos((videos) => renderTiles(videos)); // [{ id, identity, name, isLocal, source: "camera"|"screen_share", attach(el), detach(el) }]
|
|
63
|
+
videos[0].attach(videoEl); // sets autoplay + playsinline; your own picture is muted
|
|
64
|
+
call.setAudioOnly(true); // weak connection: stop receiving others' video, keep sound
|
|
55
65
|
```
|
|
56
66
|
|
|
57
67
|
- The microphone opens with **noise suppression, echo cancellation and auto gain** on.
|
|
58
68
|
- If joining fails — the microphone is refused, the ticket is rejected — `joinVoice` rejects AND disconnects first, so nobody is left in the room listening without a way to leave.
|
|
59
69
|
- Other people's audio is attached to hidden `<audio>` elements and removed when they leave.
|
|
60
70
|
- Reconnection after a network drop (ICE restart, resume) is LiveKit's own.
|
|
71
|
+
- **2–10 people with video:** the room runs with simulcast (each camera is sent in several sizes), adaptive stream (you only receive the size your tile shows) and dynacast (sizes nobody watches are paused) — `VIDEO_ROOM_OPTIONS`. Video is never added to the page by the package; the app places each picture with `attach`.
|
|
61
72
|
- The package draws no UI. If you build one, give every control a `data-testid`.
|
package/dist/client.cjs
CHANGED
|
@@ -3,9 +3,14 @@
|
|
|
3
3
|
var livekitClient = require('livekit-client');
|
|
4
4
|
|
|
5
5
|
// src/client.ts
|
|
6
|
+
var VIDEO_ROOM_OPTIONS = { adaptiveStream: true, dynacast: true, publishDefaults: { simulcast: true } };
|
|
6
7
|
var AUDIO_CAPTURE = { noiseSuppression: true, echoCancellation: true, autoGainControl: true };
|
|
7
8
|
async function joinVoice({ url, token, publish = true }) {
|
|
8
|
-
const room = new livekitClient.Room({ audioCaptureDefaults: { ...AUDIO_CAPTURE } });
|
|
9
|
+
const room = new livekitClient.Room({ audioCaptureDefaults: { ...AUDIO_CAPTURE }, ...VIDEO_ROOM_OPTIONS, publishDefaults: { ...VIDEO_ROOM_OPTIONS.publishDefaults } });
|
|
10
|
+
const localMediaCbs = /* @__PURE__ */ new Set();
|
|
11
|
+
const videoCbs = /* @__PURE__ */ new Set();
|
|
12
|
+
const remoteVideos = /* @__PURE__ */ new Map();
|
|
13
|
+
let audioOnly = false;
|
|
9
14
|
const speakingCbs = /* @__PURE__ */ new Set();
|
|
10
15
|
const participantCbs = /* @__PURE__ */ new Set();
|
|
11
16
|
const volumes = /* @__PURE__ */ new Map();
|
|
@@ -23,6 +28,35 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
23
28
|
participantCbs.forEach((cb) => cb(list));
|
|
24
29
|
};
|
|
25
30
|
const applyVolume = (p) => p.setVolume(outputMuted ? 0 : volumes.get(p.identity) ?? 1);
|
|
31
|
+
const localMedia = () => ({
|
|
32
|
+
camera: room.localParticipant.isCameraEnabled,
|
|
33
|
+
screenShare: room.localParticipant.isScreenShareEnabled
|
|
34
|
+
});
|
|
35
|
+
const emitLocalMedia = () => {
|
|
36
|
+
if (left) return;
|
|
37
|
+
const m = localMedia();
|
|
38
|
+
localMediaCbs.forEach((cb) => cb(m));
|
|
39
|
+
};
|
|
40
|
+
const videos = () => {
|
|
41
|
+
const local = [];
|
|
42
|
+
for (const pub of room.localParticipant.videoTrackPublications.values()) {
|
|
43
|
+
const v = localVideo(pub, room.localParticipant);
|
|
44
|
+
if (v) local.push(v);
|
|
45
|
+
}
|
|
46
|
+
return [...local, ...remoteVideos.values()];
|
|
47
|
+
};
|
|
48
|
+
const emitVideos = () => {
|
|
49
|
+
if (left) return;
|
|
50
|
+
const list = videos();
|
|
51
|
+
videoCbs.forEach((cb) => cb(list));
|
|
52
|
+
};
|
|
53
|
+
const onLocalChange = () => {
|
|
54
|
+
emitLocalMedia();
|
|
55
|
+
emitVideos();
|
|
56
|
+
};
|
|
57
|
+
const applyAudioOnly = (pub) => {
|
|
58
|
+
if (pub.kind === livekitClient.Track.Kind.Video) pub.setSubscribed(!audioOnly);
|
|
59
|
+
};
|
|
26
60
|
const handlers = [
|
|
27
61
|
[livekitClient.RoomEvent.ParticipantConnected, (p) => {
|
|
28
62
|
applyVolume(p);
|
|
@@ -35,6 +69,13 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
35
69
|
speakingCbs.forEach((cb) => cb(ids));
|
|
36
70
|
}],
|
|
37
71
|
[livekitClient.RoomEvent.TrackSubscribed, (track, _pub, p) => {
|
|
72
|
+
if (track.kind === livekitClient.Track.Kind.Video) {
|
|
73
|
+
const source = toVideoSource(track.source);
|
|
74
|
+
if (!source) return;
|
|
75
|
+
remoteVideos.set(track, videoOf(`${p.identity}:${track.sid ?? source}`, p, false, source, track));
|
|
76
|
+
emitVideos();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
38
79
|
if (track.kind !== livekitClient.Track.Kind.Audio) return;
|
|
39
80
|
const el = track.attach();
|
|
40
81
|
document.body.appendChild(el);
|
|
@@ -42,9 +83,22 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
42
83
|
applyVolume(p);
|
|
43
84
|
}],
|
|
44
85
|
[livekitClient.RoomEvent.TrackUnsubscribed, (track) => {
|
|
86
|
+
if (remoteVideos.delete(track)) {
|
|
87
|
+
track.detach();
|
|
88
|
+
emitVideos();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
45
91
|
track.detach().forEach((el) => el.remove());
|
|
46
92
|
audioEls.delete(track);
|
|
47
|
-
}]
|
|
93
|
+
}],
|
|
94
|
+
[livekitClient.RoomEvent.TrackPublished, (pub) => {
|
|
95
|
+
if (audioOnly) applyAudioOnly(pub);
|
|
96
|
+
}],
|
|
97
|
+
// Fired for our own camera/screen going on or off — including when the
|
|
98
|
+
// person ends a screen share with the BROWSER's stop button, which never
|
|
99
|
+
// passes through setScreenShare.
|
|
100
|
+
[livekitClient.RoomEvent.LocalTrackPublished, onLocalChange],
|
|
101
|
+
[livekitClient.RoomEvent.LocalTrackUnpublished, onLocalChange]
|
|
48
102
|
];
|
|
49
103
|
for (const [event, fn] of handlers) room.on(event, fn);
|
|
50
104
|
try {
|
|
@@ -93,22 +147,83 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
93
147
|
if (!left) cb(participants());
|
|
94
148
|
return () => participantCbs.delete(cb);
|
|
95
149
|
},
|
|
150
|
+
async setCamera(on, opts) {
|
|
151
|
+
await room.localParticipant.setCameraEnabled(on, opts?.deviceId ? { deviceId: opts.deviceId } : void 0);
|
|
152
|
+
onLocalChange();
|
|
153
|
+
},
|
|
154
|
+
async setScreenShare(on, opts) {
|
|
155
|
+
await room.localParticipant.setScreenShareEnabled(on, on ? { audio: opts?.audio === true } : void 0);
|
|
156
|
+
onLocalChange();
|
|
157
|
+
},
|
|
158
|
+
get localMedia() {
|
|
159
|
+
return localMedia();
|
|
160
|
+
},
|
|
161
|
+
onLocalMedia(cb) {
|
|
162
|
+
localMediaCbs.add(cb);
|
|
163
|
+
if (!left) cb(localMedia());
|
|
164
|
+
return () => localMediaCbs.delete(cb);
|
|
165
|
+
},
|
|
166
|
+
onVideos(cb) {
|
|
167
|
+
videoCbs.add(cb);
|
|
168
|
+
if (!left) cb(videos());
|
|
169
|
+
return () => videoCbs.delete(cb);
|
|
170
|
+
},
|
|
171
|
+
get audioOnly() {
|
|
172
|
+
return audioOnly;
|
|
173
|
+
},
|
|
174
|
+
setAudioOnly(on) {
|
|
175
|
+
audioOnly = on;
|
|
176
|
+
room.remoteParticipants.forEach((p) => p.trackPublications.forEach(applyAudioOnly));
|
|
177
|
+
},
|
|
96
178
|
async leave() {
|
|
97
179
|
left = true;
|
|
98
180
|
for (const [event, fn] of handlers) room.off(event, fn);
|
|
99
181
|
speakingCbs.clear();
|
|
100
182
|
participantCbs.clear();
|
|
183
|
+
localMediaCbs.clear();
|
|
184
|
+
videoCbs.clear();
|
|
185
|
+
remoteVideos.forEach((_v, track) => track.detach());
|
|
186
|
+
remoteVideos.clear();
|
|
101
187
|
audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));
|
|
102
188
|
audioEls.clear();
|
|
103
189
|
await room.disconnect();
|
|
104
190
|
}
|
|
105
191
|
};
|
|
106
192
|
}
|
|
193
|
+
function toVideoSource(source) {
|
|
194
|
+
if (source === livekitClient.Track.Source.Camera) return "camera";
|
|
195
|
+
if (source === livekitClient.Track.Source.ScreenShare) return "screen_share";
|
|
196
|
+
return void 0;
|
|
197
|
+
}
|
|
198
|
+
function localVideo(pub, p) {
|
|
199
|
+
const source = toVideoSource(pub.source);
|
|
200
|
+
if (!pub.track || !source) return void 0;
|
|
201
|
+
return videoOf(`${p.identity}:${pub.trackSid}`, p, true, source, pub.track);
|
|
202
|
+
}
|
|
203
|
+
function videoOf(id, p, isLocal, source, track) {
|
|
204
|
+
return {
|
|
205
|
+
id,
|
|
206
|
+
identity: p.identity,
|
|
207
|
+
name: p.name || p.identity,
|
|
208
|
+
isLocal,
|
|
209
|
+
source,
|
|
210
|
+
attach(el) {
|
|
211
|
+
el.autoplay = true;
|
|
212
|
+
el.playsInline = true;
|
|
213
|
+
if (isLocal) el.muted = true;
|
|
214
|
+
track.attach(el);
|
|
215
|
+
},
|
|
216
|
+
detach(el) {
|
|
217
|
+
track.detach(el);
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
}
|
|
107
221
|
function toVoiceParticipant(p, isLocal) {
|
|
108
222
|
return { identity: p.identity, name: p.name || p.identity, isLocal };
|
|
109
223
|
}
|
|
110
224
|
|
|
111
225
|
exports.AUDIO_CAPTURE = AUDIO_CAPTURE;
|
|
226
|
+
exports.VIDEO_ROOM_OPTIONS = VIDEO_ROOM_OPTIONS;
|
|
112
227
|
exports.joinVoice = joinVoice;
|
|
113
228
|
//# sourceMappingURL=client.cjs.map
|
|
114
229
|
//# sourceMappingURL=client.cjs.map
|
package/dist/client.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/client.ts"],"names":["Room","RoomEvent","Track"],"mappings":"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","file":"client.cjs","sourcesContent":["// @broberg/call/client — the browser's half of a voice call (F104.3).\n//\n// A thin layer over livekit-client that does the four things every app would\n// otherwise get slightly wrong on its own: open the microphone with noise\n// suppression, echo cancellation and auto gain; actually PLAY the other people\n// (a subscribed audio track is silent until it is attached to an element);\n// keep \"mute everyone\" and per-person volume apart so unmuting restores what\n// each person was set to; and stop calling the app back after leave().\n// No node: imports.\n\nimport { Room, RoomEvent, Track } from \"livekit-client\";\nimport type { Participant, RemoteParticipant, RemoteTrack } from \"livekit-client\";\n\nexport interface VoiceParticipant {\n identity: string;\n name: string;\n isLocal: boolean;\n}\n\nexport interface JoinOptions {\n /** The media server, e.g. `wss://voice.broberg.ai`. */\n url: string;\n /** A ticket from `createRoomToken` on the app's server. */\n token: string;\n /**\n * F104.4 — `false` joins as a listener: the microphone is never opened\n * (a listen-only ticket would be refused if it tried). Default `true`.\n */\n publish?: boolean;\n}\n\nexport interface VoiceCall {\n mute(): Promise<void>;\n unmute(): Promise<void>;\n readonly muted: boolean;\n /** Silence everyone else (true) or bring back each person's own volume (false). */\n setOutputMuted(muted: boolean): void;\n setInputDevice(deviceId: string): Promise<void>;\n setOutputDevice(deviceId: string): Promise<void>;\n /** 0–1 for one person. Kept while output is muted and applied on unmute. */\n setVolume(identity: string, volume: number): void;\n /** Identities of the people speaking right now, loudest first. Returns an unsubscribe. */\n onSpeaking(cb: (identities: string[]) => void): () => void;\n /** Everyone in the call, you included; called at once and on every join/leave. Returns an unsubscribe. */\n onParticipants(cb: (participants: VoiceParticipant[]) => void): () => void;\n leave(): Promise<void>;\n}\n\nexport const AUDIO_CAPTURE = { noiseSuppression: true, echoCancellation: true, autoGainControl: true } as const;\n\nexport async function joinVoice({ url, token, publish = true }: JoinOptions): Promise<VoiceCall> {\n const room = new Room({ audioCaptureDefaults: { ...AUDIO_CAPTURE } });\n const speakingCbs = new Set<(ids: string[]) => void>();\n const participantCbs = new Set<(ps: VoiceParticipant[]) => void>();\n const volumes = new Map<string, number>();\n const audioEls = new Map<RemoteTrack, HTMLMediaElement[]>();\n let outputMuted = false;\n let muted = !publish;\n let left = false;\n\n const participants = (): VoiceParticipant[] => [\n toVoiceParticipant(room.localParticipant, true),\n ...[...room.remoteParticipants.values()].map((p) => toVoiceParticipant(p, false)),\n ];\n const emitParticipants = () => {\n if (left) return;\n const list = participants();\n participantCbs.forEach((cb) => cb(list));\n };\n const applyVolume = (p: RemoteParticipant) => p.setVolume(outputMuted ? 0 : (volumes.get(p.identity) ?? 1));\n\n const handlers: [RoomEvent, (...args: any[]) => void][] = [\n [RoomEvent.ParticipantConnected, (p: RemoteParticipant) => { applyVolume(p); emitParticipants(); }],\n [RoomEvent.ParticipantDisconnected, () => emitParticipants()],\n [RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {\n if (left) return;\n const ids = speakers.map((s) => s.identity);\n speakingCbs.forEach((cb) => cb(ids));\n }],\n [RoomEvent.TrackSubscribed, (track: RemoteTrack, _pub: unknown, p: RemoteParticipant) => {\n if (track.kind !== Track.Kind.Audio) return;\n const el = track.attach();\n document.body.appendChild(el);\n audioEls.set(track, [el]);\n applyVolume(p);\n }],\n [RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {\n track.detach().forEach((el) => el.remove());\n audioEls.delete(track);\n }],\n ];\n for (const [event, fn] of handlers) room.on(event, fn);\n\n try {\n await room.connect(url, token);\n if (publish) await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE });\n // joinVoice is called from a click; this is the gesture browsers require before audio may play.\n await room.startAudio();\n } catch (e) {\n // A refused microphone must not leave the person connected and listening\n // with no handle to leave — the caller only gets the rejection.\n left = true;\n for (const [event, fn] of handlers) room.off(event, fn);\n await room.disconnect();\n throw e;\n }\n\n return {\n get muted() { return muted; },\n async mute() { await room.localParticipant.setMicrophoneEnabled(false); muted = true; },\n async unmute() { await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE }); muted = false; },\n setOutputMuted(m) {\n outputMuted = m;\n room.remoteParticipants.forEach(applyVolume);\n },\n async setInputDevice(deviceId) { await room.switchActiveDevice(\"audioinput\", deviceId); },\n async setOutputDevice(deviceId) { await room.switchActiveDevice(\"audiooutput\", deviceId); },\n setVolume(identity, volume) {\n volumes.set(identity, Math.min(1, Math.max(0, volume)));\n const p = room.remoteParticipants.get(identity);\n if (p) applyVolume(p);\n },\n onSpeaking(cb) {\n speakingCbs.add(cb);\n return () => speakingCbs.delete(cb);\n },\n onParticipants(cb) {\n participantCbs.add(cb);\n if (!left) cb(participants());\n return () => participantCbs.delete(cb);\n },\n async leave() {\n left = true;\n for (const [event, fn] of handlers) room.off(event, fn);\n speakingCbs.clear();\n participantCbs.clear();\n audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));\n audioEls.clear();\n await room.disconnect();\n },\n };\n}\n\nfunction toVoiceParticipant(p: Participant, isLocal: boolean): VoiceParticipant {\n return { identity: p.identity, name: p.name || p.identity, isLocal };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/client.ts"],"names":["Room","Track","RoomEvent"],"mappings":"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","file":"client.cjs","sourcesContent":["// @broberg/call/client — the browser's half of a call (F104.3; camera + screen share F104.7).\n//\n// A thin layer over livekit-client that does the four things every app would\n// otherwise get slightly wrong on its own: open the microphone with noise\n// suppression, echo cancellation and auto gain; actually PLAY the other people\n// (a subscribed audio track is silent until it is attached to an element);\n// keep \"mute everyone\" and per-person volume apart so unmuting restores what\n// each person was set to; and stop calling the app back after leave().\n// F104.7 adds camera and screen share: simulcast + adaptive stream + dynacast\n// so 2–10 people with video stay usable, the browser's own «stop sharing»\n// button is reported back to the app, and audio-only drops incoming video.\n// No node: imports.\n\nimport { Room, RoomEvent, Track } from \"livekit-client\";\nimport type { LocalTrackPublication, Participant, RemoteParticipant, RemoteTrack, RemoteTrackPublication } from \"livekit-client\";\n\nexport interface VoiceParticipant {\n identity: string;\n name: string;\n isLocal: boolean;\n}\n\n/** F104.7 — one video picture in the call: a camera or a shared screen, yours or someone else's. */\nexport interface CallVideo {\n /** Stable for as long as this picture exists — use it as a render key. */\n id: string;\n identity: string;\n name: string;\n isLocal: boolean;\n source: \"camera\" | \"screen_share\";\n /** Show it in a `<video>` element (autoplay + playsinline are set for you). */\n attach(el: HTMLVideoElement): void;\n detach(el: HTMLVideoElement): void;\n}\n\n/** F104.7 — what you are sending right now. */\nexport interface LocalMedia {\n camera: boolean;\n screenShare: boolean;\n}\n\nexport interface JoinOptions {\n /** The media server, e.g. `wss://voice.broberg.ai`. */\n url: string;\n /** A ticket from `createRoomToken` on the app's server. */\n token: string;\n /**\n * F104.4 — `false` joins as a listener: the microphone is never opened\n * (a listen-only ticket would be refused if it tried). Default `true`.\n */\n publish?: boolean;\n}\n\nexport interface VoiceCall {\n mute(): Promise<void>;\n unmute(): Promise<void>;\n readonly muted: boolean;\n /** Silence everyone else (true) or bring back each person's own volume (false). */\n setOutputMuted(muted: boolean): void;\n setInputDevice(deviceId: string): Promise<void>;\n setOutputDevice(deviceId: string): Promise<void>;\n /** 0–1 for one person. Kept while output is muted and applied on unmute. */\n setVolume(identity: string, volume: number): void;\n /** Identities of the people speaking right now, loudest first. Returns an unsubscribe. */\n onSpeaking(cb: (identities: string[]) => void): () => void;\n /** Everyone in the call, you included; called at once and on every join/leave. Returns an unsubscribe. */\n onParticipants(cb: (participants: VoiceParticipant[]) => void): () => void;\n /** F104.7 — turn your camera on or off. Needs a ticket with `\"camera\"` in `sources`. */\n setCamera(on: boolean, opts?: { deviceId?: string }): Promise<void>;\n /**\n * F104.7 — share your screen (the browser asks which screen/window/tab) or stop.\n * `audio: true` also offers the tab's sound (needs `\"screen_share_audio\"` in the ticket).\n * Stopping with the browser's own «stop sharing» button is reported through `onLocalMedia`.\n */\n setScreenShare(on: boolean, opts?: { audio?: boolean }): Promise<void>;\n readonly localMedia: LocalMedia;\n /** Called at once and whenever your camera/screen share turns on or off — also from the browser's own stop button. Returns an unsubscribe. */\n onLocalMedia(cb: (media: LocalMedia) => void): () => void;\n /** Every video picture in the call (yours included), at once and whenever one comes or goes. Returns an unsubscribe. */\n onVideos(cb: (videos: CallVideo[]) => void): () => void;\n /** F104.7 — `true` stops receiving other people's video (weak connection); sound is untouched. `false` brings it back. */\n setAudioOnly(on: boolean): void;\n readonly audioOnly: boolean;\n leave(): Promise<void>;\n}\n\n/** F104.7 — the room settings that keep 2–10 people with video usable. */\nexport const VIDEO_ROOM_OPTIONS = { adaptiveStream: true, dynacast: true, publishDefaults: { simulcast: true } } as const;\n\nexport const AUDIO_CAPTURE = { noiseSuppression: true, echoCancellation: true, autoGainControl: true } as const;\n\nexport async function joinVoice({ url, token, publish = true }: JoinOptions): Promise<VoiceCall> {\n const room = new Room({ audioCaptureDefaults: { ...AUDIO_CAPTURE }, ...VIDEO_ROOM_OPTIONS, publishDefaults: { ...VIDEO_ROOM_OPTIONS.publishDefaults } });\n const localMediaCbs = new Set<(m: LocalMedia) => void>();\n const videoCbs = new Set<(v: CallVideo[]) => void>();\n const remoteVideos = new Map<RemoteTrack, CallVideo>();\n let audioOnly = false;\n const speakingCbs = new Set<(ids: string[]) => void>();\n const participantCbs = new Set<(ps: VoiceParticipant[]) => void>();\n const volumes = new Map<string, number>();\n const audioEls = new Map<RemoteTrack, HTMLMediaElement[]>();\n let outputMuted = false;\n let muted = !publish;\n let left = false;\n\n const participants = (): VoiceParticipant[] => [\n toVoiceParticipant(room.localParticipant, true),\n ...[...room.remoteParticipants.values()].map((p) => toVoiceParticipant(p, false)),\n ];\n const emitParticipants = () => {\n if (left) return;\n const list = participants();\n participantCbs.forEach((cb) => cb(list));\n };\n const applyVolume = (p: RemoteParticipant) => p.setVolume(outputMuted ? 0 : (volumes.get(p.identity) ?? 1));\n\n const localMedia = (): LocalMedia => ({\n camera: room.localParticipant.isCameraEnabled,\n screenShare: room.localParticipant.isScreenShareEnabled,\n });\n const emitLocalMedia = () => {\n if (left) return;\n const m = localMedia();\n localMediaCbs.forEach((cb) => cb(m));\n };\n const videos = (): CallVideo[] => {\n const local: CallVideo[] = [];\n for (const pub of room.localParticipant.videoTrackPublications.values()) {\n const v = localVideo(pub, room.localParticipant);\n if (v) local.push(v);\n }\n return [...local, ...remoteVideos.values()];\n };\n const emitVideos = () => {\n if (left) return;\n const list = videos();\n videoCbs.forEach((cb) => cb(list));\n };\n const onLocalChange = () => { emitLocalMedia(); emitVideos(); };\n // Audio-only only ever touches VIDEO publications — sound keeps flowing.\n const applyAudioOnly = (pub: RemoteTrackPublication) => {\n if (pub.kind === Track.Kind.Video) pub.setSubscribed(!audioOnly);\n };\n\n const handlers: [RoomEvent, (...args: any[]) => void][] = [\n [RoomEvent.ParticipantConnected, (p: RemoteParticipant) => { applyVolume(p); emitParticipants(); }],\n [RoomEvent.ParticipantDisconnected, () => emitParticipants()],\n [RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {\n if (left) return;\n const ids = speakers.map((s) => s.identity);\n speakingCbs.forEach((cb) => cb(ids));\n }],\n [RoomEvent.TrackSubscribed, (track: RemoteTrack, _pub: unknown, p: RemoteParticipant) => {\n if (track.kind === Track.Kind.Video) {\n const source = toVideoSource(track.source);\n if (!source) return;\n remoteVideos.set(track, videoOf(`${p.identity}:${track.sid ?? source}`, p, false, source, track));\n emitVideos();\n return;\n }\n if (track.kind !== Track.Kind.Audio) return;\n const el = track.attach();\n document.body.appendChild(el);\n audioEls.set(track, [el]);\n applyVolume(p);\n }],\n [RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {\n if (remoteVideos.delete(track)) {\n track.detach();\n emitVideos();\n return;\n }\n track.detach().forEach((el) => el.remove());\n audioEls.delete(track);\n }],\n [RoomEvent.TrackPublished, (pub: RemoteTrackPublication) => { if (audioOnly) applyAudioOnly(pub); }],\n // Fired for our own camera/screen going on or off — including when the\n // person ends a screen share with the BROWSER's stop button, which never\n // passes through setScreenShare.\n [RoomEvent.LocalTrackPublished, onLocalChange],\n [RoomEvent.LocalTrackUnpublished, onLocalChange],\n ];\n for (const [event, fn] of handlers) room.on(event, fn);\n\n try {\n await room.connect(url, token);\n if (publish) await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE });\n // joinVoice is called from a click; this is the gesture browsers require before audio may play.\n await room.startAudio();\n } catch (e) {\n // A refused microphone must not leave the person connected and listening\n // with no handle to leave — the caller only gets the rejection.\n left = true;\n for (const [event, fn] of handlers) room.off(event, fn);\n await room.disconnect();\n throw e;\n }\n\n return {\n get muted() { return muted; },\n async mute() { await room.localParticipant.setMicrophoneEnabled(false); muted = true; },\n async unmute() { await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE }); muted = false; },\n setOutputMuted(m) {\n outputMuted = m;\n room.remoteParticipants.forEach(applyVolume);\n },\n async setInputDevice(deviceId) { await room.switchActiveDevice(\"audioinput\", deviceId); },\n async setOutputDevice(deviceId) { await room.switchActiveDevice(\"audiooutput\", deviceId); },\n setVolume(identity, volume) {\n volumes.set(identity, Math.min(1, Math.max(0, volume)));\n const p = room.remoteParticipants.get(identity);\n if (p) applyVolume(p);\n },\n onSpeaking(cb) {\n speakingCbs.add(cb);\n return () => speakingCbs.delete(cb);\n },\n onParticipants(cb) {\n participantCbs.add(cb);\n if (!left) cb(participants());\n return () => participantCbs.delete(cb);\n },\n async setCamera(on, opts) {\n await room.localParticipant.setCameraEnabled(on, opts?.deviceId ? { deviceId: opts.deviceId } : undefined);\n onLocalChange();\n },\n async setScreenShare(on, opts) {\n await room.localParticipant.setScreenShareEnabled(on, on ? { audio: opts?.audio === true } : undefined);\n onLocalChange();\n },\n get localMedia() { return localMedia(); },\n onLocalMedia(cb) {\n localMediaCbs.add(cb);\n if (!left) cb(localMedia());\n return () => localMediaCbs.delete(cb);\n },\n onVideos(cb) {\n videoCbs.add(cb);\n if (!left) cb(videos());\n return () => videoCbs.delete(cb);\n },\n get audioOnly() { return audioOnly; },\n setAudioOnly(on) {\n audioOnly = on;\n room.remoteParticipants.forEach((p) => p.trackPublications.forEach(applyAudioOnly));\n },\n async leave() {\n left = true;\n for (const [event, fn] of handlers) room.off(event, fn);\n speakingCbs.clear();\n participantCbs.clear();\n localMediaCbs.clear();\n videoCbs.clear();\n remoteVideos.forEach((_v, track) => track.detach());\n remoteVideos.clear();\n audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));\n audioEls.clear();\n await room.disconnect();\n },\n };\n}\n\nfunction toVideoSource(source: Track.Source): CallVideo[\"source\"] | undefined {\n if (source === Track.Source.Camera) return \"camera\";\n if (source === Track.Source.ScreenShare) return \"screen_share\";\n return undefined;\n}\n\nfunction localVideo(pub: LocalTrackPublication, p: Participant): CallVideo | undefined {\n const source = toVideoSource(pub.source);\n if (!pub.track || !source) return undefined;\n return videoOf(`${p.identity}:${pub.trackSid}`, p, true, source, pub.track);\n}\n\nfunction videoOf(\n id: string,\n p: Participant,\n isLocal: boolean,\n source: CallVideo[\"source\"],\n track: { attach(el: HTMLMediaElement): HTMLMediaElement; detach(el: HTMLMediaElement): HTMLMediaElement },\n): CallVideo {\n return {\n id,\n identity: p.identity,\n name: p.name || p.identity,\n isLocal,\n source,\n attach(el) {\n el.autoplay = true;\n el.playsInline = true;\n // Your own camera would otherwise echo the room back through the page.\n if (isLocal) el.muted = true;\n track.attach(el);\n },\n detach(el) { track.detach(el); },\n };\n}\n\nfunction toVoiceParticipant(p: Participant, isLocal: boolean): VoiceParticipant {\n return { identity: p.identity, name: p.name || p.identity, isLocal };\n}\n"]}
|
package/dist/client.d.cts
CHANGED
|
@@ -3,6 +3,23 @@ interface VoiceParticipant {
|
|
|
3
3
|
name: string;
|
|
4
4
|
isLocal: boolean;
|
|
5
5
|
}
|
|
6
|
+
/** F104.7 — one video picture in the call: a camera or a shared screen, yours or someone else's. */
|
|
7
|
+
interface CallVideo {
|
|
8
|
+
/** Stable for as long as this picture exists — use it as a render key. */
|
|
9
|
+
id: string;
|
|
10
|
+
identity: string;
|
|
11
|
+
name: string;
|
|
12
|
+
isLocal: boolean;
|
|
13
|
+
source: "camera" | "screen_share";
|
|
14
|
+
/** Show it in a `<video>` element (autoplay + playsinline are set for you). */
|
|
15
|
+
attach(el: HTMLVideoElement): void;
|
|
16
|
+
detach(el: HTMLVideoElement): void;
|
|
17
|
+
}
|
|
18
|
+
/** F104.7 — what you are sending right now. */
|
|
19
|
+
interface LocalMedia {
|
|
20
|
+
camera: boolean;
|
|
21
|
+
screenShare: boolean;
|
|
22
|
+
}
|
|
6
23
|
interface JoinOptions {
|
|
7
24
|
/** The media server, e.g. `wss://voice.broberg.ai`. */
|
|
8
25
|
url: string;
|
|
@@ -28,8 +45,36 @@ interface VoiceCall {
|
|
|
28
45
|
onSpeaking(cb: (identities: string[]) => void): () => void;
|
|
29
46
|
/** Everyone in the call, you included; called at once and on every join/leave. Returns an unsubscribe. */
|
|
30
47
|
onParticipants(cb: (participants: VoiceParticipant[]) => void): () => void;
|
|
48
|
+
/** F104.7 — turn your camera on or off. Needs a ticket with `"camera"` in `sources`. */
|
|
49
|
+
setCamera(on: boolean, opts?: {
|
|
50
|
+
deviceId?: string;
|
|
51
|
+
}): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* F104.7 — share your screen (the browser asks which screen/window/tab) or stop.
|
|
54
|
+
* `audio: true` also offers the tab's sound (needs `"screen_share_audio"` in the ticket).
|
|
55
|
+
* Stopping with the browser's own «stop sharing» button is reported through `onLocalMedia`.
|
|
56
|
+
*/
|
|
57
|
+
setScreenShare(on: boolean, opts?: {
|
|
58
|
+
audio?: boolean;
|
|
59
|
+
}): Promise<void>;
|
|
60
|
+
readonly localMedia: LocalMedia;
|
|
61
|
+
/** Called at once and whenever your camera/screen share turns on or off — also from the browser's own stop button. Returns an unsubscribe. */
|
|
62
|
+
onLocalMedia(cb: (media: LocalMedia) => void): () => void;
|
|
63
|
+
/** Every video picture in the call (yours included), at once and whenever one comes or goes. Returns an unsubscribe. */
|
|
64
|
+
onVideos(cb: (videos: CallVideo[]) => void): () => void;
|
|
65
|
+
/** F104.7 — `true` stops receiving other people's video (weak connection); sound is untouched. `false` brings it back. */
|
|
66
|
+
setAudioOnly(on: boolean): void;
|
|
67
|
+
readonly audioOnly: boolean;
|
|
31
68
|
leave(): Promise<void>;
|
|
32
69
|
}
|
|
70
|
+
/** F104.7 — the room settings that keep 2–10 people with video usable. */
|
|
71
|
+
declare const VIDEO_ROOM_OPTIONS: {
|
|
72
|
+
readonly adaptiveStream: true;
|
|
73
|
+
readonly dynacast: true;
|
|
74
|
+
readonly publishDefaults: {
|
|
75
|
+
readonly simulcast: true;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
33
78
|
declare const AUDIO_CAPTURE: {
|
|
34
79
|
readonly noiseSuppression: true;
|
|
35
80
|
readonly echoCancellation: true;
|
|
@@ -37,4 +82,4 @@ declare const AUDIO_CAPTURE: {
|
|
|
37
82
|
};
|
|
38
83
|
declare function joinVoice({ url, token, publish }: JoinOptions): Promise<VoiceCall>;
|
|
39
84
|
|
|
40
|
-
export { AUDIO_CAPTURE, type JoinOptions, type VoiceCall, type VoiceParticipant, joinVoice };
|
|
85
|
+
export { AUDIO_CAPTURE, type CallVideo, type JoinOptions, type LocalMedia, VIDEO_ROOM_OPTIONS, type VoiceCall, type VoiceParticipant, joinVoice };
|
package/dist/client.d.ts
CHANGED
|
@@ -3,6 +3,23 @@ interface VoiceParticipant {
|
|
|
3
3
|
name: string;
|
|
4
4
|
isLocal: boolean;
|
|
5
5
|
}
|
|
6
|
+
/** F104.7 — one video picture in the call: a camera or a shared screen, yours or someone else's. */
|
|
7
|
+
interface CallVideo {
|
|
8
|
+
/** Stable for as long as this picture exists — use it as a render key. */
|
|
9
|
+
id: string;
|
|
10
|
+
identity: string;
|
|
11
|
+
name: string;
|
|
12
|
+
isLocal: boolean;
|
|
13
|
+
source: "camera" | "screen_share";
|
|
14
|
+
/** Show it in a `<video>` element (autoplay + playsinline are set for you). */
|
|
15
|
+
attach(el: HTMLVideoElement): void;
|
|
16
|
+
detach(el: HTMLVideoElement): void;
|
|
17
|
+
}
|
|
18
|
+
/** F104.7 — what you are sending right now. */
|
|
19
|
+
interface LocalMedia {
|
|
20
|
+
camera: boolean;
|
|
21
|
+
screenShare: boolean;
|
|
22
|
+
}
|
|
6
23
|
interface JoinOptions {
|
|
7
24
|
/** The media server, e.g. `wss://voice.broberg.ai`. */
|
|
8
25
|
url: string;
|
|
@@ -28,8 +45,36 @@ interface VoiceCall {
|
|
|
28
45
|
onSpeaking(cb: (identities: string[]) => void): () => void;
|
|
29
46
|
/** Everyone in the call, you included; called at once and on every join/leave. Returns an unsubscribe. */
|
|
30
47
|
onParticipants(cb: (participants: VoiceParticipant[]) => void): () => void;
|
|
48
|
+
/** F104.7 — turn your camera on or off. Needs a ticket with `"camera"` in `sources`. */
|
|
49
|
+
setCamera(on: boolean, opts?: {
|
|
50
|
+
deviceId?: string;
|
|
51
|
+
}): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* F104.7 — share your screen (the browser asks which screen/window/tab) or stop.
|
|
54
|
+
* `audio: true` also offers the tab's sound (needs `"screen_share_audio"` in the ticket).
|
|
55
|
+
* Stopping with the browser's own «stop sharing» button is reported through `onLocalMedia`.
|
|
56
|
+
*/
|
|
57
|
+
setScreenShare(on: boolean, opts?: {
|
|
58
|
+
audio?: boolean;
|
|
59
|
+
}): Promise<void>;
|
|
60
|
+
readonly localMedia: LocalMedia;
|
|
61
|
+
/** Called at once and whenever your camera/screen share turns on or off — also from the browser's own stop button. Returns an unsubscribe. */
|
|
62
|
+
onLocalMedia(cb: (media: LocalMedia) => void): () => void;
|
|
63
|
+
/** Every video picture in the call (yours included), at once and whenever one comes or goes. Returns an unsubscribe. */
|
|
64
|
+
onVideos(cb: (videos: CallVideo[]) => void): () => void;
|
|
65
|
+
/** F104.7 — `true` stops receiving other people's video (weak connection); sound is untouched. `false` brings it back. */
|
|
66
|
+
setAudioOnly(on: boolean): void;
|
|
67
|
+
readonly audioOnly: boolean;
|
|
31
68
|
leave(): Promise<void>;
|
|
32
69
|
}
|
|
70
|
+
/** F104.7 — the room settings that keep 2–10 people with video usable. */
|
|
71
|
+
declare const VIDEO_ROOM_OPTIONS: {
|
|
72
|
+
readonly adaptiveStream: true;
|
|
73
|
+
readonly dynacast: true;
|
|
74
|
+
readonly publishDefaults: {
|
|
75
|
+
readonly simulcast: true;
|
|
76
|
+
};
|
|
77
|
+
};
|
|
33
78
|
declare const AUDIO_CAPTURE: {
|
|
34
79
|
readonly noiseSuppression: true;
|
|
35
80
|
readonly echoCancellation: true;
|
|
@@ -37,4 +82,4 @@ declare const AUDIO_CAPTURE: {
|
|
|
37
82
|
};
|
|
38
83
|
declare function joinVoice({ url, token, publish }: JoinOptions): Promise<VoiceCall>;
|
|
39
84
|
|
|
40
|
-
export { AUDIO_CAPTURE, type JoinOptions, type VoiceCall, type VoiceParticipant, joinVoice };
|
|
85
|
+
export { AUDIO_CAPTURE, type CallVideo, type JoinOptions, type LocalMedia, VIDEO_ROOM_OPTIONS, type VoiceCall, type VoiceParticipant, joinVoice };
|
package/dist/client.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { Room, RoomEvent, Track } from 'livekit-client';
|
|
2
2
|
|
|
3
3
|
// src/client.ts
|
|
4
|
+
var VIDEO_ROOM_OPTIONS = { adaptiveStream: true, dynacast: true, publishDefaults: { simulcast: true } };
|
|
4
5
|
var AUDIO_CAPTURE = { noiseSuppression: true, echoCancellation: true, autoGainControl: true };
|
|
5
6
|
async function joinVoice({ url, token, publish = true }) {
|
|
6
|
-
const room = new Room({ audioCaptureDefaults: { ...AUDIO_CAPTURE } });
|
|
7
|
+
const room = new Room({ audioCaptureDefaults: { ...AUDIO_CAPTURE }, ...VIDEO_ROOM_OPTIONS, publishDefaults: { ...VIDEO_ROOM_OPTIONS.publishDefaults } });
|
|
8
|
+
const localMediaCbs = /* @__PURE__ */ new Set();
|
|
9
|
+
const videoCbs = /* @__PURE__ */ new Set();
|
|
10
|
+
const remoteVideos = /* @__PURE__ */ new Map();
|
|
11
|
+
let audioOnly = false;
|
|
7
12
|
const speakingCbs = /* @__PURE__ */ new Set();
|
|
8
13
|
const participantCbs = /* @__PURE__ */ new Set();
|
|
9
14
|
const volumes = /* @__PURE__ */ new Map();
|
|
@@ -21,6 +26,35 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
21
26
|
participantCbs.forEach((cb) => cb(list));
|
|
22
27
|
};
|
|
23
28
|
const applyVolume = (p) => p.setVolume(outputMuted ? 0 : volumes.get(p.identity) ?? 1);
|
|
29
|
+
const localMedia = () => ({
|
|
30
|
+
camera: room.localParticipant.isCameraEnabled,
|
|
31
|
+
screenShare: room.localParticipant.isScreenShareEnabled
|
|
32
|
+
});
|
|
33
|
+
const emitLocalMedia = () => {
|
|
34
|
+
if (left) return;
|
|
35
|
+
const m = localMedia();
|
|
36
|
+
localMediaCbs.forEach((cb) => cb(m));
|
|
37
|
+
};
|
|
38
|
+
const videos = () => {
|
|
39
|
+
const local = [];
|
|
40
|
+
for (const pub of room.localParticipant.videoTrackPublications.values()) {
|
|
41
|
+
const v = localVideo(pub, room.localParticipant);
|
|
42
|
+
if (v) local.push(v);
|
|
43
|
+
}
|
|
44
|
+
return [...local, ...remoteVideos.values()];
|
|
45
|
+
};
|
|
46
|
+
const emitVideos = () => {
|
|
47
|
+
if (left) return;
|
|
48
|
+
const list = videos();
|
|
49
|
+
videoCbs.forEach((cb) => cb(list));
|
|
50
|
+
};
|
|
51
|
+
const onLocalChange = () => {
|
|
52
|
+
emitLocalMedia();
|
|
53
|
+
emitVideos();
|
|
54
|
+
};
|
|
55
|
+
const applyAudioOnly = (pub) => {
|
|
56
|
+
if (pub.kind === Track.Kind.Video) pub.setSubscribed(!audioOnly);
|
|
57
|
+
};
|
|
24
58
|
const handlers = [
|
|
25
59
|
[RoomEvent.ParticipantConnected, (p) => {
|
|
26
60
|
applyVolume(p);
|
|
@@ -33,6 +67,13 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
33
67
|
speakingCbs.forEach((cb) => cb(ids));
|
|
34
68
|
}],
|
|
35
69
|
[RoomEvent.TrackSubscribed, (track, _pub, p) => {
|
|
70
|
+
if (track.kind === Track.Kind.Video) {
|
|
71
|
+
const source = toVideoSource(track.source);
|
|
72
|
+
if (!source) return;
|
|
73
|
+
remoteVideos.set(track, videoOf(`${p.identity}:${track.sid ?? source}`, p, false, source, track));
|
|
74
|
+
emitVideos();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
36
77
|
if (track.kind !== Track.Kind.Audio) return;
|
|
37
78
|
const el = track.attach();
|
|
38
79
|
document.body.appendChild(el);
|
|
@@ -40,9 +81,22 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
40
81
|
applyVolume(p);
|
|
41
82
|
}],
|
|
42
83
|
[RoomEvent.TrackUnsubscribed, (track) => {
|
|
84
|
+
if (remoteVideos.delete(track)) {
|
|
85
|
+
track.detach();
|
|
86
|
+
emitVideos();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
43
89
|
track.detach().forEach((el) => el.remove());
|
|
44
90
|
audioEls.delete(track);
|
|
45
|
-
}]
|
|
91
|
+
}],
|
|
92
|
+
[RoomEvent.TrackPublished, (pub) => {
|
|
93
|
+
if (audioOnly) applyAudioOnly(pub);
|
|
94
|
+
}],
|
|
95
|
+
// Fired for our own camera/screen going on or off — including when the
|
|
96
|
+
// person ends a screen share with the BROWSER's stop button, which never
|
|
97
|
+
// passes through setScreenShare.
|
|
98
|
+
[RoomEvent.LocalTrackPublished, onLocalChange],
|
|
99
|
+
[RoomEvent.LocalTrackUnpublished, onLocalChange]
|
|
46
100
|
];
|
|
47
101
|
for (const [event, fn] of handlers) room.on(event, fn);
|
|
48
102
|
try {
|
|
@@ -91,21 +145,81 @@ async function joinVoice({ url, token, publish = true }) {
|
|
|
91
145
|
if (!left) cb(participants());
|
|
92
146
|
return () => participantCbs.delete(cb);
|
|
93
147
|
},
|
|
148
|
+
async setCamera(on, opts) {
|
|
149
|
+
await room.localParticipant.setCameraEnabled(on, opts?.deviceId ? { deviceId: opts.deviceId } : void 0);
|
|
150
|
+
onLocalChange();
|
|
151
|
+
},
|
|
152
|
+
async setScreenShare(on, opts) {
|
|
153
|
+
await room.localParticipant.setScreenShareEnabled(on, on ? { audio: opts?.audio === true } : void 0);
|
|
154
|
+
onLocalChange();
|
|
155
|
+
},
|
|
156
|
+
get localMedia() {
|
|
157
|
+
return localMedia();
|
|
158
|
+
},
|
|
159
|
+
onLocalMedia(cb) {
|
|
160
|
+
localMediaCbs.add(cb);
|
|
161
|
+
if (!left) cb(localMedia());
|
|
162
|
+
return () => localMediaCbs.delete(cb);
|
|
163
|
+
},
|
|
164
|
+
onVideos(cb) {
|
|
165
|
+
videoCbs.add(cb);
|
|
166
|
+
if (!left) cb(videos());
|
|
167
|
+
return () => videoCbs.delete(cb);
|
|
168
|
+
},
|
|
169
|
+
get audioOnly() {
|
|
170
|
+
return audioOnly;
|
|
171
|
+
},
|
|
172
|
+
setAudioOnly(on) {
|
|
173
|
+
audioOnly = on;
|
|
174
|
+
room.remoteParticipants.forEach((p) => p.trackPublications.forEach(applyAudioOnly));
|
|
175
|
+
},
|
|
94
176
|
async leave() {
|
|
95
177
|
left = true;
|
|
96
178
|
for (const [event, fn] of handlers) room.off(event, fn);
|
|
97
179
|
speakingCbs.clear();
|
|
98
180
|
participantCbs.clear();
|
|
181
|
+
localMediaCbs.clear();
|
|
182
|
+
videoCbs.clear();
|
|
183
|
+
remoteVideos.forEach((_v, track) => track.detach());
|
|
184
|
+
remoteVideos.clear();
|
|
99
185
|
audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));
|
|
100
186
|
audioEls.clear();
|
|
101
187
|
await room.disconnect();
|
|
102
188
|
}
|
|
103
189
|
};
|
|
104
190
|
}
|
|
191
|
+
function toVideoSource(source) {
|
|
192
|
+
if (source === Track.Source.Camera) return "camera";
|
|
193
|
+
if (source === Track.Source.ScreenShare) return "screen_share";
|
|
194
|
+
return void 0;
|
|
195
|
+
}
|
|
196
|
+
function localVideo(pub, p) {
|
|
197
|
+
const source = toVideoSource(pub.source);
|
|
198
|
+
if (!pub.track || !source) return void 0;
|
|
199
|
+
return videoOf(`${p.identity}:${pub.trackSid}`, p, true, source, pub.track);
|
|
200
|
+
}
|
|
201
|
+
function videoOf(id, p, isLocal, source, track) {
|
|
202
|
+
return {
|
|
203
|
+
id,
|
|
204
|
+
identity: p.identity,
|
|
205
|
+
name: p.name || p.identity,
|
|
206
|
+
isLocal,
|
|
207
|
+
source,
|
|
208
|
+
attach(el) {
|
|
209
|
+
el.autoplay = true;
|
|
210
|
+
el.playsInline = true;
|
|
211
|
+
if (isLocal) el.muted = true;
|
|
212
|
+
track.attach(el);
|
|
213
|
+
},
|
|
214
|
+
detach(el) {
|
|
215
|
+
track.detach(el);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
}
|
|
105
219
|
function toVoiceParticipant(p, isLocal) {
|
|
106
220
|
return { identity: p.identity, name: p.name || p.identity, isLocal };
|
|
107
221
|
}
|
|
108
222
|
|
|
109
|
-
export { AUDIO_CAPTURE, joinVoice };
|
|
223
|
+
export { AUDIO_CAPTURE, VIDEO_ROOM_OPTIONS, joinVoice };
|
|
110
224
|
//# sourceMappingURL=client.js.map
|
|
111
225
|
//# sourceMappingURL=client.js.map
|
package/dist/client.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/client.ts"],"names":[],"mappings":"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","file":"client.js","sourcesContent":["// @broberg/call/client — the browser's half of a voice call (F104.3).\n//\n// A thin layer over livekit-client that does the four things every app would\n// otherwise get slightly wrong on its own: open the microphone with noise\n// suppression, echo cancellation and auto gain; actually PLAY the other people\n// (a subscribed audio track is silent until it is attached to an element);\n// keep \"mute everyone\" and per-person volume apart so unmuting restores what\n// each person was set to; and stop calling the app back after leave().\n// No node: imports.\n\nimport { Room, RoomEvent, Track } from \"livekit-client\";\nimport type { Participant, RemoteParticipant, RemoteTrack } from \"livekit-client\";\n\nexport interface VoiceParticipant {\n identity: string;\n name: string;\n isLocal: boolean;\n}\n\nexport interface JoinOptions {\n /** The media server, e.g. `wss://voice.broberg.ai`. */\n url: string;\n /** A ticket from `createRoomToken` on the app's server. */\n token: string;\n /**\n * F104.4 — `false` joins as a listener: the microphone is never opened\n * (a listen-only ticket would be refused if it tried). Default `true`.\n */\n publish?: boolean;\n}\n\nexport interface VoiceCall {\n mute(): Promise<void>;\n unmute(): Promise<void>;\n readonly muted: boolean;\n /** Silence everyone else (true) or bring back each person's own volume (false). */\n setOutputMuted(muted: boolean): void;\n setInputDevice(deviceId: string): Promise<void>;\n setOutputDevice(deviceId: string): Promise<void>;\n /** 0–1 for one person. Kept while output is muted and applied on unmute. */\n setVolume(identity: string, volume: number): void;\n /** Identities of the people speaking right now, loudest first. Returns an unsubscribe. */\n onSpeaking(cb: (identities: string[]) => void): () => void;\n /** Everyone in the call, you included; called at once and on every join/leave. Returns an unsubscribe. */\n onParticipants(cb: (participants: VoiceParticipant[]) => void): () => void;\n leave(): Promise<void>;\n}\n\nexport const AUDIO_CAPTURE = { noiseSuppression: true, echoCancellation: true, autoGainControl: true } as const;\n\nexport async function joinVoice({ url, token, publish = true }: JoinOptions): Promise<VoiceCall> {\n const room = new Room({ audioCaptureDefaults: { ...AUDIO_CAPTURE } });\n const speakingCbs = new Set<(ids: string[]) => void>();\n const participantCbs = new Set<(ps: VoiceParticipant[]) => void>();\n const volumes = new Map<string, number>();\n const audioEls = new Map<RemoteTrack, HTMLMediaElement[]>();\n let outputMuted = false;\n let muted = !publish;\n let left = false;\n\n const participants = (): VoiceParticipant[] => [\n toVoiceParticipant(room.localParticipant, true),\n ...[...room.remoteParticipants.values()].map((p) => toVoiceParticipant(p, false)),\n ];\n const emitParticipants = () => {\n if (left) return;\n const list = participants();\n participantCbs.forEach((cb) => cb(list));\n };\n const applyVolume = (p: RemoteParticipant) => p.setVolume(outputMuted ? 0 : (volumes.get(p.identity) ?? 1));\n\n const handlers: [RoomEvent, (...args: any[]) => void][] = [\n [RoomEvent.ParticipantConnected, (p: RemoteParticipant) => { applyVolume(p); emitParticipants(); }],\n [RoomEvent.ParticipantDisconnected, () => emitParticipants()],\n [RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {\n if (left) return;\n const ids = speakers.map((s) => s.identity);\n speakingCbs.forEach((cb) => cb(ids));\n }],\n [RoomEvent.TrackSubscribed, (track: RemoteTrack, _pub: unknown, p: RemoteParticipant) => {\n if (track.kind !== Track.Kind.Audio) return;\n const el = track.attach();\n document.body.appendChild(el);\n audioEls.set(track, [el]);\n applyVolume(p);\n }],\n [RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {\n track.detach().forEach((el) => el.remove());\n audioEls.delete(track);\n }],\n ];\n for (const [event, fn] of handlers) room.on(event, fn);\n\n try {\n await room.connect(url, token);\n if (publish) await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE });\n // joinVoice is called from a click; this is the gesture browsers require before audio may play.\n await room.startAudio();\n } catch (e) {\n // A refused microphone must not leave the person connected and listening\n // with no handle to leave — the caller only gets the rejection.\n left = true;\n for (const [event, fn] of handlers) room.off(event, fn);\n await room.disconnect();\n throw e;\n }\n\n return {\n get muted() { return muted; },\n async mute() { await room.localParticipant.setMicrophoneEnabled(false); muted = true; },\n async unmute() { await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE }); muted = false; },\n setOutputMuted(m) {\n outputMuted = m;\n room.remoteParticipants.forEach(applyVolume);\n },\n async setInputDevice(deviceId) { await room.switchActiveDevice(\"audioinput\", deviceId); },\n async setOutputDevice(deviceId) { await room.switchActiveDevice(\"audiooutput\", deviceId); },\n setVolume(identity, volume) {\n volumes.set(identity, Math.min(1, Math.max(0, volume)));\n const p = room.remoteParticipants.get(identity);\n if (p) applyVolume(p);\n },\n onSpeaking(cb) {\n speakingCbs.add(cb);\n return () => speakingCbs.delete(cb);\n },\n onParticipants(cb) {\n participantCbs.add(cb);\n if (!left) cb(participants());\n return () => participantCbs.delete(cb);\n },\n async leave() {\n left = true;\n for (const [event, fn] of handlers) room.off(event, fn);\n speakingCbs.clear();\n participantCbs.clear();\n audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));\n audioEls.clear();\n await room.disconnect();\n },\n };\n}\n\nfunction toVoiceParticipant(p: Participant, isLocal: boolean): VoiceParticipant {\n return { identity: p.identity, name: p.name || p.identity, isLocal };\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/client.ts"],"names":[],"mappings":"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","file":"client.js","sourcesContent":["// @broberg/call/client — the browser's half of a call (F104.3; camera + screen share F104.7).\n//\n// A thin layer over livekit-client that does the four things every app would\n// otherwise get slightly wrong on its own: open the microphone with noise\n// suppression, echo cancellation and auto gain; actually PLAY the other people\n// (a subscribed audio track is silent until it is attached to an element);\n// keep \"mute everyone\" and per-person volume apart so unmuting restores what\n// each person was set to; and stop calling the app back after leave().\n// F104.7 adds camera and screen share: simulcast + adaptive stream + dynacast\n// so 2–10 people with video stay usable, the browser's own «stop sharing»\n// button is reported back to the app, and audio-only drops incoming video.\n// No node: imports.\n\nimport { Room, RoomEvent, Track } from \"livekit-client\";\nimport type { LocalTrackPublication, Participant, RemoteParticipant, RemoteTrack, RemoteTrackPublication } from \"livekit-client\";\n\nexport interface VoiceParticipant {\n identity: string;\n name: string;\n isLocal: boolean;\n}\n\n/** F104.7 — one video picture in the call: a camera or a shared screen, yours or someone else's. */\nexport interface CallVideo {\n /** Stable for as long as this picture exists — use it as a render key. */\n id: string;\n identity: string;\n name: string;\n isLocal: boolean;\n source: \"camera\" | \"screen_share\";\n /** Show it in a `<video>` element (autoplay + playsinline are set for you). */\n attach(el: HTMLVideoElement): void;\n detach(el: HTMLVideoElement): void;\n}\n\n/** F104.7 — what you are sending right now. */\nexport interface LocalMedia {\n camera: boolean;\n screenShare: boolean;\n}\n\nexport interface JoinOptions {\n /** The media server, e.g. `wss://voice.broberg.ai`. */\n url: string;\n /** A ticket from `createRoomToken` on the app's server. */\n token: string;\n /**\n * F104.4 — `false` joins as a listener: the microphone is never opened\n * (a listen-only ticket would be refused if it tried). Default `true`.\n */\n publish?: boolean;\n}\n\nexport interface VoiceCall {\n mute(): Promise<void>;\n unmute(): Promise<void>;\n readonly muted: boolean;\n /** Silence everyone else (true) or bring back each person's own volume (false). */\n setOutputMuted(muted: boolean): void;\n setInputDevice(deviceId: string): Promise<void>;\n setOutputDevice(deviceId: string): Promise<void>;\n /** 0–1 for one person. Kept while output is muted and applied on unmute. */\n setVolume(identity: string, volume: number): void;\n /** Identities of the people speaking right now, loudest first. Returns an unsubscribe. */\n onSpeaking(cb: (identities: string[]) => void): () => void;\n /** Everyone in the call, you included; called at once and on every join/leave. Returns an unsubscribe. */\n onParticipants(cb: (participants: VoiceParticipant[]) => void): () => void;\n /** F104.7 — turn your camera on or off. Needs a ticket with `\"camera\"` in `sources`. */\n setCamera(on: boolean, opts?: { deviceId?: string }): Promise<void>;\n /**\n * F104.7 — share your screen (the browser asks which screen/window/tab) or stop.\n * `audio: true` also offers the tab's sound (needs `\"screen_share_audio\"` in the ticket).\n * Stopping with the browser's own «stop sharing» button is reported through `onLocalMedia`.\n */\n setScreenShare(on: boolean, opts?: { audio?: boolean }): Promise<void>;\n readonly localMedia: LocalMedia;\n /** Called at once and whenever your camera/screen share turns on or off — also from the browser's own stop button. Returns an unsubscribe. */\n onLocalMedia(cb: (media: LocalMedia) => void): () => void;\n /** Every video picture in the call (yours included), at once and whenever one comes or goes. Returns an unsubscribe. */\n onVideos(cb: (videos: CallVideo[]) => void): () => void;\n /** F104.7 — `true` stops receiving other people's video (weak connection); sound is untouched. `false` brings it back. */\n setAudioOnly(on: boolean): void;\n readonly audioOnly: boolean;\n leave(): Promise<void>;\n}\n\n/** F104.7 — the room settings that keep 2–10 people with video usable. */\nexport const VIDEO_ROOM_OPTIONS = { adaptiveStream: true, dynacast: true, publishDefaults: { simulcast: true } } as const;\n\nexport const AUDIO_CAPTURE = { noiseSuppression: true, echoCancellation: true, autoGainControl: true } as const;\n\nexport async function joinVoice({ url, token, publish = true }: JoinOptions): Promise<VoiceCall> {\n const room = new Room({ audioCaptureDefaults: { ...AUDIO_CAPTURE }, ...VIDEO_ROOM_OPTIONS, publishDefaults: { ...VIDEO_ROOM_OPTIONS.publishDefaults } });\n const localMediaCbs = new Set<(m: LocalMedia) => void>();\n const videoCbs = new Set<(v: CallVideo[]) => void>();\n const remoteVideos = new Map<RemoteTrack, CallVideo>();\n let audioOnly = false;\n const speakingCbs = new Set<(ids: string[]) => void>();\n const participantCbs = new Set<(ps: VoiceParticipant[]) => void>();\n const volumes = new Map<string, number>();\n const audioEls = new Map<RemoteTrack, HTMLMediaElement[]>();\n let outputMuted = false;\n let muted = !publish;\n let left = false;\n\n const participants = (): VoiceParticipant[] => [\n toVoiceParticipant(room.localParticipant, true),\n ...[...room.remoteParticipants.values()].map((p) => toVoiceParticipant(p, false)),\n ];\n const emitParticipants = () => {\n if (left) return;\n const list = participants();\n participantCbs.forEach((cb) => cb(list));\n };\n const applyVolume = (p: RemoteParticipant) => p.setVolume(outputMuted ? 0 : (volumes.get(p.identity) ?? 1));\n\n const localMedia = (): LocalMedia => ({\n camera: room.localParticipant.isCameraEnabled,\n screenShare: room.localParticipant.isScreenShareEnabled,\n });\n const emitLocalMedia = () => {\n if (left) return;\n const m = localMedia();\n localMediaCbs.forEach((cb) => cb(m));\n };\n const videos = (): CallVideo[] => {\n const local: CallVideo[] = [];\n for (const pub of room.localParticipant.videoTrackPublications.values()) {\n const v = localVideo(pub, room.localParticipant);\n if (v) local.push(v);\n }\n return [...local, ...remoteVideos.values()];\n };\n const emitVideos = () => {\n if (left) return;\n const list = videos();\n videoCbs.forEach((cb) => cb(list));\n };\n const onLocalChange = () => { emitLocalMedia(); emitVideos(); };\n // Audio-only only ever touches VIDEO publications — sound keeps flowing.\n const applyAudioOnly = (pub: RemoteTrackPublication) => {\n if (pub.kind === Track.Kind.Video) pub.setSubscribed(!audioOnly);\n };\n\n const handlers: [RoomEvent, (...args: any[]) => void][] = [\n [RoomEvent.ParticipantConnected, (p: RemoteParticipant) => { applyVolume(p); emitParticipants(); }],\n [RoomEvent.ParticipantDisconnected, () => emitParticipants()],\n [RoomEvent.ActiveSpeakersChanged, (speakers: Participant[]) => {\n if (left) return;\n const ids = speakers.map((s) => s.identity);\n speakingCbs.forEach((cb) => cb(ids));\n }],\n [RoomEvent.TrackSubscribed, (track: RemoteTrack, _pub: unknown, p: RemoteParticipant) => {\n if (track.kind === Track.Kind.Video) {\n const source = toVideoSource(track.source);\n if (!source) return;\n remoteVideos.set(track, videoOf(`${p.identity}:${track.sid ?? source}`, p, false, source, track));\n emitVideos();\n return;\n }\n if (track.kind !== Track.Kind.Audio) return;\n const el = track.attach();\n document.body.appendChild(el);\n audioEls.set(track, [el]);\n applyVolume(p);\n }],\n [RoomEvent.TrackUnsubscribed, (track: RemoteTrack) => {\n if (remoteVideos.delete(track)) {\n track.detach();\n emitVideos();\n return;\n }\n track.detach().forEach((el) => el.remove());\n audioEls.delete(track);\n }],\n [RoomEvent.TrackPublished, (pub: RemoteTrackPublication) => { if (audioOnly) applyAudioOnly(pub); }],\n // Fired for our own camera/screen going on or off — including when the\n // person ends a screen share with the BROWSER's stop button, which never\n // passes through setScreenShare.\n [RoomEvent.LocalTrackPublished, onLocalChange],\n [RoomEvent.LocalTrackUnpublished, onLocalChange],\n ];\n for (const [event, fn] of handlers) room.on(event, fn);\n\n try {\n await room.connect(url, token);\n if (publish) await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE });\n // joinVoice is called from a click; this is the gesture browsers require before audio may play.\n await room.startAudio();\n } catch (e) {\n // A refused microphone must not leave the person connected and listening\n // with no handle to leave — the caller only gets the rejection.\n left = true;\n for (const [event, fn] of handlers) room.off(event, fn);\n await room.disconnect();\n throw e;\n }\n\n return {\n get muted() { return muted; },\n async mute() { await room.localParticipant.setMicrophoneEnabled(false); muted = true; },\n async unmute() { await room.localParticipant.setMicrophoneEnabled(true, { ...AUDIO_CAPTURE }); muted = false; },\n setOutputMuted(m) {\n outputMuted = m;\n room.remoteParticipants.forEach(applyVolume);\n },\n async setInputDevice(deviceId) { await room.switchActiveDevice(\"audioinput\", deviceId); },\n async setOutputDevice(deviceId) { await room.switchActiveDevice(\"audiooutput\", deviceId); },\n setVolume(identity, volume) {\n volumes.set(identity, Math.min(1, Math.max(0, volume)));\n const p = room.remoteParticipants.get(identity);\n if (p) applyVolume(p);\n },\n onSpeaking(cb) {\n speakingCbs.add(cb);\n return () => speakingCbs.delete(cb);\n },\n onParticipants(cb) {\n participantCbs.add(cb);\n if (!left) cb(participants());\n return () => participantCbs.delete(cb);\n },\n async setCamera(on, opts) {\n await room.localParticipant.setCameraEnabled(on, opts?.deviceId ? { deviceId: opts.deviceId } : undefined);\n onLocalChange();\n },\n async setScreenShare(on, opts) {\n await room.localParticipant.setScreenShareEnabled(on, on ? { audio: opts?.audio === true } : undefined);\n onLocalChange();\n },\n get localMedia() { return localMedia(); },\n onLocalMedia(cb) {\n localMediaCbs.add(cb);\n if (!left) cb(localMedia());\n return () => localMediaCbs.delete(cb);\n },\n onVideos(cb) {\n videoCbs.add(cb);\n if (!left) cb(videos());\n return () => videoCbs.delete(cb);\n },\n get audioOnly() { return audioOnly; },\n setAudioOnly(on) {\n audioOnly = on;\n room.remoteParticipants.forEach((p) => p.trackPublications.forEach(applyAudioOnly));\n },\n async leave() {\n left = true;\n for (const [event, fn] of handlers) room.off(event, fn);\n speakingCbs.clear();\n participantCbs.clear();\n localMediaCbs.clear();\n videoCbs.clear();\n remoteVideos.forEach((_v, track) => track.detach());\n remoteVideos.clear();\n audioEls.forEach((_els, track) => track.detach().forEach((el) => el.remove()));\n audioEls.clear();\n await room.disconnect();\n },\n };\n}\n\nfunction toVideoSource(source: Track.Source): CallVideo[\"source\"] | undefined {\n if (source === Track.Source.Camera) return \"camera\";\n if (source === Track.Source.ScreenShare) return \"screen_share\";\n return undefined;\n}\n\nfunction localVideo(pub: LocalTrackPublication, p: Participant): CallVideo | undefined {\n const source = toVideoSource(pub.source);\n if (!pub.track || !source) return undefined;\n return videoOf(`${p.identity}:${pub.trackSid}`, p, true, source, pub.track);\n}\n\nfunction videoOf(\n id: string,\n p: Participant,\n isLocal: boolean,\n source: CallVideo[\"source\"],\n track: { attach(el: HTMLMediaElement): HTMLMediaElement; detach(el: HTMLMediaElement): HTMLMediaElement },\n): CallVideo {\n return {\n id,\n identity: p.identity,\n name: p.name || p.identity,\n isLocal,\n source,\n attach(el) {\n el.autoplay = true;\n el.playsInline = true;\n // Your own camera would otherwise echo the room back through the page.\n if (isLocal) el.muted = true;\n track.attach(el);\n },\n detach(el) { track.detach(el); },\n };\n}\n\nfunction toVoiceParticipant(p: Participant, isLocal: boolean): VoiceParticipant {\n return { identity: p.identity, name: p.name || p.identity, isLocal };\n}\n"]}
|
package/dist/server.cjs
CHANGED
|
@@ -11,6 +11,12 @@ var VoiceError = class extends Error {
|
|
|
11
11
|
}
|
|
12
12
|
code;
|
|
13
13
|
};
|
|
14
|
+
var SOURCE_TO_LIVEKIT = {
|
|
15
|
+
microphone: livekitServerSdk.TrackSource.MICROPHONE,
|
|
16
|
+
camera: livekitServerSdk.TrackSource.CAMERA,
|
|
17
|
+
screen_share: livekitServerSdk.TrackSource.SCREEN_SHARE,
|
|
18
|
+
screen_share_audio: livekitServerSdk.TrackSource.SCREEN_SHARE_AUDIO
|
|
19
|
+
};
|
|
14
20
|
var DEFAULT_TTL_SECONDS = 600;
|
|
15
21
|
var MAX_TTL_SECONDS = 3600;
|
|
16
22
|
function requireCredentials({ apiKey, apiSecret }) {
|
|
@@ -26,18 +32,29 @@ async function createRoomToken(opts) {
|
|
|
26
32
|
if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {
|
|
27
33
|
throw new VoiceError("invalid_ttl", `ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}`);
|
|
28
34
|
}
|
|
29
|
-
const token = new livekitServerSdk.AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });
|
|
30
35
|
const speak = opts.canPublish !== false;
|
|
36
|
+
const sources = resolveSources(opts.sources, speak);
|
|
37
|
+
const token = new livekitServerSdk.AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });
|
|
31
38
|
token.addGrant({
|
|
32
39
|
room: opts.room,
|
|
33
40
|
roomJoin: true,
|
|
34
41
|
canSubscribe: true,
|
|
35
42
|
canPublish: speak,
|
|
36
|
-
...speak ? { canPublishSources: [
|
|
43
|
+
...speak ? { canPublishSources: sources.map((src) => SOURCE_TO_LIVEKIT[src]) } : {},
|
|
37
44
|
canPublishData: false
|
|
38
45
|
});
|
|
39
46
|
return token.toJwt();
|
|
40
47
|
}
|
|
48
|
+
function resolveSources(sources, speak) {
|
|
49
|
+
if (sources === void 0) return ["microphone"];
|
|
50
|
+
if (!speak) throw new VoiceError("invalid_sources", "sources cannot be given with canPublish: false \u2014 a listener sends nothing");
|
|
51
|
+
if (!Array.isArray(sources) || sources.length === 0) {
|
|
52
|
+
throw new VoiceError("invalid_sources", "sources must name at least one of microphone, camera, screen_share, screen_share_audio");
|
|
53
|
+
}
|
|
54
|
+
const unknown = sources.filter((src) => !Object.hasOwn(SOURCE_TO_LIVEKIT, src));
|
|
55
|
+
if (unknown.length) throw new VoiceError("invalid_sources", `unknown source(s): ${unknown.join(", ")}`);
|
|
56
|
+
return [...new Set(sources)];
|
|
57
|
+
}
|
|
41
58
|
async function verifyWebhook(body, authHeader, credentials) {
|
|
42
59
|
requireCredentials(credentials);
|
|
43
60
|
try {
|
package/dist/server.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts"],"names":["
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"names":["TrackSource","AccessToken","WebhookReceiver"],"mappings":"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","file":"server.cjs","sourcesContent":["// @broberg/call/server — the app's half of a call (F104.3; video and screen share F104.7).\n//\n// The app never carries audio. It decides who may enter a room, signs a short\n// ticket for that one room, and hears from the media server (LiveKit) through a\n// signed webhook when people join and leave. Built on LiveKit's own SDK so the\n// ticket format and the webhook signature are LiveKit's, not ours.\n\nimport { AccessToken, TrackSource, WebhookReceiver } from \"livekit-server-sdk\";\nimport type { WebhookEvent } from \"livekit-server-sdk\";\n\nexport type { WebhookEvent };\n\nexport type VoiceErrorCode =\n | \"missing_credentials\"\n | \"invalid_room\"\n | \"invalid_identity\"\n | \"invalid_ttl\"\n | \"invalid_sources\"\n | \"invalid_webhook\";\n\nexport class VoiceError extends Error {\n constructor(\n readonly code: VoiceErrorCode,\n message: string,\n ) {\n super(message);\n this.name = \"VoiceError\";\n }\n}\n\nexport interface Credentials {\n apiKey: string;\n apiSecret: string;\n}\n\nexport interface RoomTokenOptions extends Credentials {\n /** One room, e.g. `jam:<jam-id>`. The ticket admits to this room only. */\n room: string;\n /** Stable id of the person — the same person joining twice replaces the first connection. */\n identity: string;\n /** Display name other participants see. */\n name?: string;\n /** Seconds the ticket can be used to ENTER. Default 600, max 3600. A call in progress is not cut off when it expires. */\n ttl?: number;\n /**\n * F104.4 — `false` makes a LISTEN-ONLY ticket: the person can join and hear\n * everyone but publish nothing (no microphone, no data). Default `true`.\n * Pair it with `joinVoice({ publish: false })` in the browser.\n */\n canPublish?: boolean;\n /**\n * F104.7 — what the person may send. Default `[\"microphone\"]`, so an app that\n * never asks for video still mints a sound-only ticket. Add `\"camera\"`,\n * `\"screen_share\"` and/or `\"screen_share_audio\"` (the sound of a shared tab).\n * Refused together with `canPublish: false` — a listener sends nothing.\n */\n sources?: CallSource[];\n}\n\n/** F104.7 — the kinds of media a ticket may allow a person to send. */\nexport type CallSource = \"microphone\" | \"camera\" | \"screen_share\" | \"screen_share_audio\";\n\nconst SOURCE_TO_LIVEKIT: Record<CallSource, TrackSource> = {\n microphone: TrackSource.MICROPHONE,\n camera: TrackSource.CAMERA,\n screen_share: TrackSource.SCREEN_SHARE,\n screen_share_audio: TrackSource.SCREEN_SHARE_AUDIO,\n};\n\nexport const DEFAULT_TTL_SECONDS = 600;\nexport const MAX_TTL_SECONDS = 3600;\n\nfunction requireCredentials({ apiKey, apiSecret }: Partial<Credentials>): void {\n // LiveKit's AccessToken falls back to LIVEKIT_API_KEY/SECRET from the\n // environment when these are missing — a ticket signed with a key the caller\n // never chose. Refuse instead.\n if (!apiKey || !apiSecret) {\n throw new VoiceError(\"missing_credentials\", \"apiKey and apiSecret are required\");\n }\n}\n\n/**\n * Sign a ticket for one room — microphone only unless `sources` says more.\n * Check the person's access BEFORE calling this.\n */\nexport async function createRoomToken(opts: RoomTokenOptions): Promise<string> {\n requireCredentials(opts);\n if (!opts.room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n if (!opts.identity?.trim()) throw new VoiceError(\"invalid_identity\", \"identity is required\");\n const ttl = opts.ttl ?? DEFAULT_TTL_SECONDS;\n if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {\n throw new VoiceError(\"invalid_ttl\", `ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}`);\n }\n\n const speak = opts.canPublish !== false;\n const sources = resolveSources(opts.sources, speak);\n\n const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });\n token.addGrant({\n room: opts.room,\n roomJoin: true,\n canSubscribe: true,\n canPublish: speak,\n ...(speak ? { canPublishSources: sources.map((src) => SOURCE_TO_LIVEKIT[src]) } : {}),\n canPublishData: false,\n });\n return token.toJwt();\n}\n\nfunction resolveSources(sources: CallSource[] | undefined, speak: boolean): CallSource[] {\n if (sources === undefined) return [\"microphone\"];\n if (!speak) throw new VoiceError(\"invalid_sources\", \"sources cannot be given with canPublish: false — a listener sends nothing\");\n if (!Array.isArray(sources) || sources.length === 0) {\n throw new VoiceError(\"invalid_sources\", \"sources must name at least one of microphone, camera, screen_share, screen_share_audio\");\n }\n const unknown = sources.filter((src) => !Object.hasOwn(SOURCE_TO_LIVEKIT, src));\n if (unknown.length) throw new VoiceError(\"invalid_sources\", `unknown source(s): ${unknown.join(\", \")}`);\n return [...new Set(sources)];\n}\n\n/**\n * Verify a webhook from the media server and return its event.\n * `body` must be the RAW request body — re-serialised JSON changes the hash.\n */\nexport async function verifyWebhook(\n body: string,\n authHeader: string | null | undefined,\n credentials: Credentials,\n): Promise<WebhookEvent> {\n requireCredentials(credentials);\n try {\n // A missing header is refused by LiveKit itself (\"authorization header is empty\").\n return await new WebhookReceiver(credentials.apiKey, credentials.apiSecret).receive(body, authHeader ?? undefined);\n } catch (e) {\n throw new VoiceError(\"invalid_webhook\", `webhook rejected: ${(e as Error).message}`);\n }\n}\n"]}
|
package/dist/server.d.cts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WebhookEvent } from 'livekit-server-sdk';
|
|
2
2
|
export { WebhookEvent } from 'livekit-server-sdk';
|
|
3
3
|
|
|
4
|
-
type VoiceErrorCode = "missing_credentials" | "invalid_room" | "invalid_identity" | "invalid_ttl" | "invalid_webhook";
|
|
4
|
+
type VoiceErrorCode = "missing_credentials" | "invalid_room" | "invalid_identity" | "invalid_ttl" | "invalid_sources" | "invalid_webhook";
|
|
5
5
|
declare class VoiceError extends Error {
|
|
6
6
|
readonly code: VoiceErrorCode;
|
|
7
7
|
constructor(code: VoiceErrorCode, message: string);
|
|
@@ -25,10 +25,22 @@ interface RoomTokenOptions extends Credentials {
|
|
|
25
25
|
* Pair it with `joinVoice({ publish: false })` in the browser.
|
|
26
26
|
*/
|
|
27
27
|
canPublish?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* F104.7 — what the person may send. Default `["microphone"]`, so an app that
|
|
30
|
+
* never asks for video still mints a sound-only ticket. Add `"camera"`,
|
|
31
|
+
* `"screen_share"` and/or `"screen_share_audio"` (the sound of a shared tab).
|
|
32
|
+
* Refused together with `canPublish: false` — a listener sends nothing.
|
|
33
|
+
*/
|
|
34
|
+
sources?: CallSource[];
|
|
28
35
|
}
|
|
36
|
+
/** F104.7 — the kinds of media a ticket may allow a person to send. */
|
|
37
|
+
type CallSource = "microphone" | "camera" | "screen_share" | "screen_share_audio";
|
|
29
38
|
declare const DEFAULT_TTL_SECONDS = 600;
|
|
30
39
|
declare const MAX_TTL_SECONDS = 3600;
|
|
31
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Sign a ticket for one room — microphone only unless `sources` says more.
|
|
42
|
+
* Check the person's access BEFORE calling this.
|
|
43
|
+
*/
|
|
32
44
|
declare function createRoomToken(opts: RoomTokenOptions): Promise<string>;
|
|
33
45
|
/**
|
|
34
46
|
* Verify a webhook from the media server and return its event.
|
|
@@ -36,4 +48,4 @@ declare function createRoomToken(opts: RoomTokenOptions): Promise<string>;
|
|
|
36
48
|
*/
|
|
37
49
|
declare function verifyWebhook(body: string, authHeader: string | null | undefined, credentials: Credentials): Promise<WebhookEvent>;
|
|
38
50
|
|
|
39
|
-
export { type Credentials, DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, type RoomTokenOptions, VoiceError, type VoiceErrorCode, createRoomToken, verifyWebhook };
|
|
51
|
+
export { type CallSource, type Credentials, DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, type RoomTokenOptions, VoiceError, type VoiceErrorCode, createRoomToken, verifyWebhook };
|
package/dist/server.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { WebhookEvent } from 'livekit-server-sdk';
|
|
2
2
|
export { WebhookEvent } from 'livekit-server-sdk';
|
|
3
3
|
|
|
4
|
-
type VoiceErrorCode = "missing_credentials" | "invalid_room" | "invalid_identity" | "invalid_ttl" | "invalid_webhook";
|
|
4
|
+
type VoiceErrorCode = "missing_credentials" | "invalid_room" | "invalid_identity" | "invalid_ttl" | "invalid_sources" | "invalid_webhook";
|
|
5
5
|
declare class VoiceError extends Error {
|
|
6
6
|
readonly code: VoiceErrorCode;
|
|
7
7
|
constructor(code: VoiceErrorCode, message: string);
|
|
@@ -25,10 +25,22 @@ interface RoomTokenOptions extends Credentials {
|
|
|
25
25
|
* Pair it with `joinVoice({ publish: false })` in the browser.
|
|
26
26
|
*/
|
|
27
27
|
canPublish?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* F104.7 — what the person may send. Default `["microphone"]`, so an app that
|
|
30
|
+
* never asks for video still mints a sound-only ticket. Add `"camera"`,
|
|
31
|
+
* `"screen_share"` and/or `"screen_share_audio"` (the sound of a shared tab).
|
|
32
|
+
* Refused together with `canPublish: false` — a listener sends nothing.
|
|
33
|
+
*/
|
|
34
|
+
sources?: CallSource[];
|
|
28
35
|
}
|
|
36
|
+
/** F104.7 — the kinds of media a ticket may allow a person to send. */
|
|
37
|
+
type CallSource = "microphone" | "camera" | "screen_share" | "screen_share_audio";
|
|
29
38
|
declare const DEFAULT_TTL_SECONDS = 600;
|
|
30
39
|
declare const MAX_TTL_SECONDS = 3600;
|
|
31
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Sign a ticket for one room — microphone only unless `sources` says more.
|
|
42
|
+
* Check the person's access BEFORE calling this.
|
|
43
|
+
*/
|
|
32
44
|
declare function createRoomToken(opts: RoomTokenOptions): Promise<string>;
|
|
33
45
|
/**
|
|
34
46
|
* Verify a webhook from the media server and return its event.
|
|
@@ -36,4 +48,4 @@ declare function createRoomToken(opts: RoomTokenOptions): Promise<string>;
|
|
|
36
48
|
*/
|
|
37
49
|
declare function verifyWebhook(body: string, authHeader: string | null | undefined, credentials: Credentials): Promise<WebhookEvent>;
|
|
38
50
|
|
|
39
|
-
export { type Credentials, DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, type RoomTokenOptions, VoiceError, type VoiceErrorCode, createRoomToken, verifyWebhook };
|
|
51
|
+
export { type CallSource, type Credentials, DEFAULT_TTL_SECONDS, MAX_TTL_SECONDS, type RoomTokenOptions, VoiceError, type VoiceErrorCode, createRoomToken, verifyWebhook };
|
package/dist/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { TrackSource, AccessToken, WebhookReceiver } from 'livekit-server-sdk';
|
|
2
2
|
|
|
3
3
|
// src/server.ts
|
|
4
4
|
var VoiceError = class extends Error {
|
|
@@ -9,6 +9,12 @@ var VoiceError = class extends Error {
|
|
|
9
9
|
}
|
|
10
10
|
code;
|
|
11
11
|
};
|
|
12
|
+
var SOURCE_TO_LIVEKIT = {
|
|
13
|
+
microphone: TrackSource.MICROPHONE,
|
|
14
|
+
camera: TrackSource.CAMERA,
|
|
15
|
+
screen_share: TrackSource.SCREEN_SHARE,
|
|
16
|
+
screen_share_audio: TrackSource.SCREEN_SHARE_AUDIO
|
|
17
|
+
};
|
|
12
18
|
var DEFAULT_TTL_SECONDS = 600;
|
|
13
19
|
var MAX_TTL_SECONDS = 3600;
|
|
14
20
|
function requireCredentials({ apiKey, apiSecret }) {
|
|
@@ -24,18 +30,29 @@ async function createRoomToken(opts) {
|
|
|
24
30
|
if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {
|
|
25
31
|
throw new VoiceError("invalid_ttl", `ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}`);
|
|
26
32
|
}
|
|
27
|
-
const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });
|
|
28
33
|
const speak = opts.canPublish !== false;
|
|
34
|
+
const sources = resolveSources(opts.sources, speak);
|
|
35
|
+
const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });
|
|
29
36
|
token.addGrant({
|
|
30
37
|
room: opts.room,
|
|
31
38
|
roomJoin: true,
|
|
32
39
|
canSubscribe: true,
|
|
33
40
|
canPublish: speak,
|
|
34
|
-
...speak ? { canPublishSources: [
|
|
41
|
+
...speak ? { canPublishSources: sources.map((src) => SOURCE_TO_LIVEKIT[src]) } : {},
|
|
35
42
|
canPublishData: false
|
|
36
43
|
});
|
|
37
44
|
return token.toJwt();
|
|
38
45
|
}
|
|
46
|
+
function resolveSources(sources, speak) {
|
|
47
|
+
if (sources === void 0) return ["microphone"];
|
|
48
|
+
if (!speak) throw new VoiceError("invalid_sources", "sources cannot be given with canPublish: false \u2014 a listener sends nothing");
|
|
49
|
+
if (!Array.isArray(sources) || sources.length === 0) {
|
|
50
|
+
throw new VoiceError("invalid_sources", "sources must name at least one of microphone, camera, screen_share, screen_share_audio");
|
|
51
|
+
}
|
|
52
|
+
const unknown = sources.filter((src) => !Object.hasOwn(SOURCE_TO_LIVEKIT, src));
|
|
53
|
+
if (unknown.length) throw new VoiceError("invalid_sources", `unknown source(s): ${unknown.join(", ")}`);
|
|
54
|
+
return [...new Set(sources)];
|
|
55
|
+
}
|
|
39
56
|
async function verifyWebhook(body, authHeader, credentials) {
|
|
40
57
|
requireCredentials(credentials);
|
|
41
58
|
try {
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/server.ts"],"names":[],"mappings":";;;
|
|
1
|
+
{"version":3,"sources":["../src/server.ts"],"names":[],"mappings":"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","file":"server.js","sourcesContent":["// @broberg/call/server — the app's half of a call (F104.3; video and screen share F104.7).\n//\n// The app never carries audio. It decides who may enter a room, signs a short\n// ticket for that one room, and hears from the media server (LiveKit) through a\n// signed webhook when people join and leave. Built on LiveKit's own SDK so the\n// ticket format and the webhook signature are LiveKit's, not ours.\n\nimport { AccessToken, TrackSource, WebhookReceiver } from \"livekit-server-sdk\";\nimport type { WebhookEvent } from \"livekit-server-sdk\";\n\nexport type { WebhookEvent };\n\nexport type VoiceErrorCode =\n | \"missing_credentials\"\n | \"invalid_room\"\n | \"invalid_identity\"\n | \"invalid_ttl\"\n | \"invalid_sources\"\n | \"invalid_webhook\";\n\nexport class VoiceError extends Error {\n constructor(\n readonly code: VoiceErrorCode,\n message: string,\n ) {\n super(message);\n this.name = \"VoiceError\";\n }\n}\n\nexport interface Credentials {\n apiKey: string;\n apiSecret: string;\n}\n\nexport interface RoomTokenOptions extends Credentials {\n /** One room, e.g. `jam:<jam-id>`. The ticket admits to this room only. */\n room: string;\n /** Stable id of the person — the same person joining twice replaces the first connection. */\n identity: string;\n /** Display name other participants see. */\n name?: string;\n /** Seconds the ticket can be used to ENTER. Default 600, max 3600. A call in progress is not cut off when it expires. */\n ttl?: number;\n /**\n * F104.4 — `false` makes a LISTEN-ONLY ticket: the person can join and hear\n * everyone but publish nothing (no microphone, no data). Default `true`.\n * Pair it with `joinVoice({ publish: false })` in the browser.\n */\n canPublish?: boolean;\n /**\n * F104.7 — what the person may send. Default `[\"microphone\"]`, so an app that\n * never asks for video still mints a sound-only ticket. Add `\"camera\"`,\n * `\"screen_share\"` and/or `\"screen_share_audio\"` (the sound of a shared tab).\n * Refused together with `canPublish: false` — a listener sends nothing.\n */\n sources?: CallSource[];\n}\n\n/** F104.7 — the kinds of media a ticket may allow a person to send. */\nexport type CallSource = \"microphone\" | \"camera\" | \"screen_share\" | \"screen_share_audio\";\n\nconst SOURCE_TO_LIVEKIT: Record<CallSource, TrackSource> = {\n microphone: TrackSource.MICROPHONE,\n camera: TrackSource.CAMERA,\n screen_share: TrackSource.SCREEN_SHARE,\n screen_share_audio: TrackSource.SCREEN_SHARE_AUDIO,\n};\n\nexport const DEFAULT_TTL_SECONDS = 600;\nexport const MAX_TTL_SECONDS = 3600;\n\nfunction requireCredentials({ apiKey, apiSecret }: Partial<Credentials>): void {\n // LiveKit's AccessToken falls back to LIVEKIT_API_KEY/SECRET from the\n // environment when these are missing — a ticket signed with a key the caller\n // never chose. Refuse instead.\n if (!apiKey || !apiSecret) {\n throw new VoiceError(\"missing_credentials\", \"apiKey and apiSecret are required\");\n }\n}\n\n/**\n * Sign a ticket for one room — microphone only unless `sources` says more.\n * Check the person's access BEFORE calling this.\n */\nexport async function createRoomToken(opts: RoomTokenOptions): Promise<string> {\n requireCredentials(opts);\n if (!opts.room?.trim()) throw new VoiceError(\"invalid_room\", \"room is required\");\n if (!opts.identity?.trim()) throw new VoiceError(\"invalid_identity\", \"identity is required\");\n const ttl = opts.ttl ?? DEFAULT_TTL_SECONDS;\n if (!Number.isInteger(ttl) || ttl <= 0 || ttl > MAX_TTL_SECONDS) {\n throw new VoiceError(\"invalid_ttl\", `ttl must be a whole number of seconds between 1 and ${MAX_TTL_SECONDS}`);\n }\n\n const speak = opts.canPublish !== false;\n const sources = resolveSources(opts.sources, speak);\n\n const token = new AccessToken(opts.apiKey, opts.apiSecret, { identity: opts.identity, name: opts.name, ttl });\n token.addGrant({\n room: opts.room,\n roomJoin: true,\n canSubscribe: true,\n canPublish: speak,\n ...(speak ? { canPublishSources: sources.map((src) => SOURCE_TO_LIVEKIT[src]) } : {}),\n canPublishData: false,\n });\n return token.toJwt();\n}\n\nfunction resolveSources(sources: CallSource[] | undefined, speak: boolean): CallSource[] {\n if (sources === undefined) return [\"microphone\"];\n if (!speak) throw new VoiceError(\"invalid_sources\", \"sources cannot be given with canPublish: false — a listener sends nothing\");\n if (!Array.isArray(sources) || sources.length === 0) {\n throw new VoiceError(\"invalid_sources\", \"sources must name at least one of microphone, camera, screen_share, screen_share_audio\");\n }\n const unknown = sources.filter((src) => !Object.hasOwn(SOURCE_TO_LIVEKIT, src));\n if (unknown.length) throw new VoiceError(\"invalid_sources\", `unknown source(s): ${unknown.join(\", \")}`);\n return [...new Set(sources)];\n}\n\n/**\n * Verify a webhook from the media server and return its event.\n * `body` must be the RAW request body — re-serialised JSON changes the hash.\n */\nexport async function verifyWebhook(\n body: string,\n authHeader: string | null | undefined,\n credentials: Credentials,\n): Promise<WebhookEvent> {\n requireCredentials(credentials);\n try {\n // A missing header is refused by LiveKit itself (\"authorization header is empty\").\n return await new WebhookReceiver(credentials.apiKey, credentials.apiSecret).receive(body, authHeader ?? undefined);\n } catch (e) {\n throw new VoiceError(\"invalid_webhook\", `webhook rejected: ${(e as Error).message}`);\n }\n}\n"]}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@broberg/call",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Calls
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Calls for 2–10 people on a self-hosted LiveKit SFU in the EU — audio, camera and screen share: the app's server mints a short-lived, room-bound ticket that allows exactly the sources it names (microphone only by default) and verifies LiveKit's signed webhooks; the browser joins with noise suppression, echo cancellation and auto gain, and gets mute, devices, per-person volume, who-is-speaking, the participant list, camera and screen share on/off (incl. the browser's own stop button), every video picture to attach, and an audio-only mode — with simulcast, adaptive stream and dynacast.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"sideEffects": false,
|