diff --git a/.gitignore b/.gitignore index 5e60302d3..13d3fa750 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ dist .serena .claude .vscode +.docs-review .env .env.* !.env.example diff --git a/fern/apis/signalwire-rest/openapi.yaml b/fern/apis/signalwire-rest/openapi.yaml index fad774522..d5300170b 100644 --- a/fern/apis/signalwire-rest/openapi.yaml +++ b/fern/apis/signalwire-rest/openapi.yaml @@ -40907,6 +40907,37 @@ components: description: Set or update the subscriber's password. Omit this field or pass an empty string if you don't want to update the password. examples: - password123 + fingerprint: + type: string + minLength: 43 + maxLength: 43 + pattern: ^[A-Za-z0-9_-]+$ + description: |- + Binds the token to a specific device or browser session, letting the + holder refresh it without going through your backend. The [Browser SDK](/docs/browser-sdk/v4) + generates this value automatically when starting a session — forward it + to your backend when requesting a token, so tie the token to that client. + + Without `fingerprint`, your backend can still refresh the token using + the companion [`refresh_token`](/docs/apis/rest/subscribers/tokens/refresh-subscriber-token) + returned in this response. + examples: + - Vg1h7IDV3AR6kTpCkZPHOVs32B81DX1naHiHbYoKXgY + scope: + type: string + enum: + - sat:refresh + description: |- + Grants the token's holder permission to refresh it directly from the + Browser SDK client. Pair with `fingerprint` to bind the token to a + device. + + Without this scope, your backend can still refresh the token using the + companion [`refresh_token`](/docs/apis/rest/subscribers/tokens/refresh-subscriber-token). + If `sat:refresh` is set without `fingerprint`, the token's lifetime is + limited to 60 seconds. + examples: + - sat:refresh first_name: type: string description: Set or update the first name of the subscriber. diff --git a/fern/docs.yml b/fern/docs.yml index 57172c6d8..d628ee582 100644 --- a/fern/docs.yml +++ b/fern/docs.yml @@ -67,6 +67,10 @@ products: icon: fa-regular fa-browser subtitle: Build voice, video and chat applications for the browser versions: + - display-name: v4 + path: products/browser-sdk/versions/v4.yml + availability: beta + slug: v4 - display-name: v3 path: products/browser-sdk/versions/latest.yml availability: stable @@ -170,7 +174,6 @@ css: - brand-overrides.css - styles.css - redirects: - source: /docs/agents-sdk destination: /docs/server-sdks @@ -180,6 +183,14 @@ redirects: destination: /docs/server-sdks - source: /docs/server-sdk/:slug* destination: /docs/server-sdks + - source: /docs/browser-sdk/js + destination: /docs/browser-sdk/v3/js + - source: /docs/browser-sdk/js/:slug* + destination: /docs/browser-sdk/v3/js/:slug* + - source: /docs/browser-sdk/click-to-call + destination: /docs/browser-sdk/v3/click-to-call + - source: /docs/browser-sdk/click-to-call/:slug* + destination: /docs/browser-sdk/v3/click-to-call/:slug* # SWML methods reorganized into calling/ and messaging/ subsections. # The old methods overview lived at /docs/swml/reference, which was diff --git a/fern/fern.config.json b/fern/fern.config.json index 82cd17f93..0efa328fb 100644 --- a/fern/fern.config.json +++ b/fern/fern.config.json @@ -1,4 +1,4 @@ { "organization": "signalwire", - "version": "5.23.3" + "version": "5.26.4" } diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/_live-streaming.mdx.draft b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/_live-streaming.mdx.draft new file mode 100644 index 000000000..94365ab2c --- /dev/null +++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/_live-streaming.mdx.draft @@ -0,0 +1,138 @@ +--- +title: "Live Streaming" +slug: /guides/live-streaming +sidebar-title: "Live Streaming" +position: 7 +max-toc-depth: 3 +--- + +Live streaming on the SignalWire platform pushes a room's mixed feed +to one or more RTMP destinations (YouTube Live, Twitch, a custom +ingest server). It's a server-side feature — like +[recording](/docs/browser-sdk/v4/guides/recording), the SDK's role is to observe +streaming state on the active call. + + +**Client-side `startStreaming()` is not yet implemented in v4.** The +SDK ships an `async startStreaming()` stub on `Call` that throws +`UnimplementedError`. Trigger streams server-side (REST or SWML); use +`streaming$` to reflect status in your UI. + + +## What you can do today + +- **Observe** whether streaming is active: `call.streaming$`. +- **Render a "LIVE" indicator** driven by that observable. +- **Start / stop streams** via: + - The REST API. + - A SWML script on the Resource. + - The SignalWire Dashboard. + +## Observing streaming state + +`streaming$` is a boolean BehaviorSubject — `true` whenever any +stream is active on the call's session, `false` otherwise. + +```js +call.streaming$.subscribe((isStreaming) => { + liveBadge.classList.toggle("visible", isStreaming); + liveBadge.textContent = isStreaming ? "● LIVE" : ""; +}); +``` + +The badge updates instantly when streaming starts or stops, regardless +of which client started it. + +## Starting a stream: server-side + +### From the REST API + +Issue a request against the call's session ID with the RTMP target: + +```bash +curl -X POST "https://yourspace.signalwire.com/api/video/room_sessions/{id}/streams" \ + -u "PROJECT_ID:API_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "url": "rtmps://a.rtmps.youtube.com/live2/STREAM_KEY" + }' +``` + +The full set of stream endpoints (create, list, stop, etc.) is in +the [REST API reference](/docs/apis). + +### From SWML + +A `stream` verb in the room's SWML automatically starts a stream when +the session begins: + +```yaml +sections: + main: + - stream: + url: rtmps://a.rtmps.youtube.com/live2/STREAM_KEY +``` + +### From the Dashboard + +For ad-hoc stream targets, configure the Resource's streaming +settings and toggle on join. + +## Multiple destinations + +The session can run multiple streams concurrently — useful for +simulcasting to YouTube and Twitch at the same time. Each is created +through a separate REST call (or a separate `stream` verb in SWML). +The `streaming$` observable reports a single boolean: `true` if any +of them are active. + +If you need to enumerate the individual stream targets, read them +from `call.signalingEvent$` payloads (the room session state +includes the current `streams` list): + +```js +call.signalingEvent$.subscribe((event) => { + if (event.event_type === "call.updated") { + console.log("streams:", event.params?.room_session?.streams); + } +}); +``` + +## Capability flag + +`call.self.capabilities` does not include a streaming-specific flag +in v4 — streaming is a session-level operation governed by +server-side policy. Anyone in the room sees `streaming$` flip, but +only authorized callers (typically your backend) can start or stop +streams via the REST API. + +## Roadmap + +[`Call.startStreaming()`] and `Call.stopStreaming()` are planned. +Until they land, the SDK contract is: + +- **Read**: `streaming$` / `streaming` (works today). +- **Write**: server-side only (works today, via REST / SWML / + Dashboard). + +For per-call recording, see [Recording](/docs/browser-sdk/v4/guides/recording). The +two features behave similarly today — observe-only on the SDK, +write-only on the platform. + +## Viewer-side: interactive live streams + +If your app is *consuming* a stream (rather than producing one), the +playback flow is plain HTTP — point an HLS player at the URL the +platform publishes, no SDK involved. The Browser SDK is only relevant +when the viewer needs to *participate* in the room (chat, raise hand, +join as a video participant). For that flow, treat them as a normal +inbound or outbound call against the Resource Address; the platform +mixes their media into the same session that's being streamed. + +## Reference + +- [`Call.streaming$`] — reactive streaming state +- [`Call.startStreaming()`] — server-side mutator (not yet implemented in v4) + +[`Call.streaming$`]: /docs/browser-sdk/v4/reference/webrtc-call/streaming$ +[`Call.startStreaming()`]: /docs/browser-sdk/v4/reference/webrtc-call/start-streaming diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/_recording.mdx.draft b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/_recording.mdx.draft new file mode 100644 index 000000000..ebe069bf6 --- /dev/null +++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/_recording.mdx.draft @@ -0,0 +1,143 @@ +--- +title: "Recording" +slug: /guides/recording +sidebar-title: "Recording" +position: 6 +max-toc-depth: 3 +--- + +Recording on the SignalWire platform is a server-side capability — +the media mix is recorded in the cloud, not in the browser. The +Browser SDK's role today is to **observe** recording state on the +active call. It surfaces a `recording$` boolean observable; the +recording itself is started, stopped, and downloaded through the +platform (Dashboard, REST API, or SWML). + + +**Client-side `startRecording()` is not yet implemented in v4.** The +SDK ships an `async startRecording()` stub on `Call` that throws +`UnimplementedError`. Until it lands, trigger recording through one +of the server-side paths below and use the observables to reflect +status in your UI. + + +## What you can do today + +- **Observe** whether a recording is active: `call.recording$`. +- **Render a "REC" badge** in your UI driven by that observable. +- **Trigger recording** server-side via: + - Configuring the Resource (room, SWML script) to auto-record on + join. + - The SignalWire REST API. + - A SWML script that runs on the call leg. + +## Observing recording state + +`recording$` emits a boolean — `true` whenever any recording is +active on the call's session, `false` otherwise. It's a +BehaviorSubject, so late subscribers get the current state +synchronously. + +```js +call.recording$.subscribe((isRecording) => { + recordingBadge.classList.toggle("visible", isRecording); + recordingBadge.textContent = isRecording ? "● REC" : ""; +}); +``` + +That's all most apps need — a visible indicator so participants know +the session is being recorded. + +## Pulling more detail + +The full recording state (file IDs, current duration, format, paused +status, etc.) is sent by the server inside `call.joined` and +`call.updated` events. The SDK exposes the resulting state via +`call.signalingEvent$` if you need to inspect the raw payload: + +```js +call.signalingEvent$.subscribe((event) => { + if (event.event_type === "call.updated") { + // event.params.room_session.recordings has the list + console.log("recordings:", event.params?.room_session?.recordings); + } +}); +``` + +This is escape-hatch territory — most apps shouldn't need it. When +the `start/stop/pause` mutator methods land on the SDK, you'll get a +cleaner API for the same data. + +## Starting a recording: server-side options + +### From a SWML script on the Resource + +Use the `record` verb in the room's SWML to begin recording as soon +as the call connects: + +```yaml +sections: + main: + - record: + stereo: true + format: mp4 +``` + +### From the REST API + +Issue a request against the call's session ID. The platform +identifies the active room session and starts recording. + +```bash +curl -X POST "https://yourspace.signalwire.com/api/video/room_sessions/{id}/recordings" \ + -u "PROJECT_ID:API_TOKEN" \ + -H "Content-Type: application/json" +``` + +Refer to the [REST API reference](/docs/apis) for the full set of +recording endpoints (start, stop, list, download). + +### From the Dashboard + +For ad-hoc recording on a specific Resource, toggle "Record on join" +in the Resource configuration. Useful for support / sales workflows +where every call should be archived. + +## Capability flag + +`call.self.capabilities` does **not** currently include a `record` +flag — recording isn't a per-participant capability in v4. Anyone in +a room may know about a recording in progress (via the `recording$` +observable), but starting / stopping is governed entirely by +server-side policy. + +## What about stopping? + +When recording is started server-side, the same channel stops it. The +SDK will see `recording$` flip to `false` and your "REC" badge will +clear. Don't try to call `call.startRecording()` / no equivalent +client-side stop is reachable today. + +## Roadmap + +[`Call.startRecording()`] / `Call.stopRecording()` / pause/resume are +planned. Until they ship, the contract is: + +- **Read**: `recording$` / `recording` (works today). +- **Write**: server-side only (works today, via REST / SWML / + Dashboard). + +When the SDK methods become available, this guide will grow examples +for starting and stopping inline. Watch the [changelog](/docs/browser-sdk/v4/changelog) +or the JS reference for the introduction of the methods. + +For livestreaming an active call (which behaves similarly), see +[Live Streaming](/docs/browser-sdk/v4/guides/live-streaming). + +## Reference + +- [`Call.recording$`] — reactive recording state +- [`Call.startRecording()`] — server-side mutator (not yet implemented in v4) + +[`Call.recording$`]: /docs/browser-sdk/v4/reference/webrtc-call/recording$ +[`Call.startRecording()`]: /docs/browser-sdk/v4/reference/webrtc-call/start-recording diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/call-controls.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/call-controls.mdx new file mode 100644 index 000000000..c22a909d2 --- /dev/null +++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/call-controls.mdx @@ -0,0 +1,260 @@ +--- +title: "Call Controls" +slug: /guides/call-controls +sidebar-title: "Call Controls" +position: 8 +max-toc-depth: 3 +--- + +Every control on a call follows the same shape: **call a mutator, +listen on the matching `$` observable for state.** Don't track local +state in a `let isMuted` variable — the server is the source of truth +and can flip it (a moderator can mute you, the room can lock itself, +the platform can disconnect). The observable is the only honest +answer to "are we muted right now." + +Once you internalize that pattern, every control on the SDK is the +same code. This page teaches the pattern; the [reference] has the +full surface. + +## The pattern + +```js +call.self$.subscribe((self) => { + if (!self) return; + + // Trigger + muteBtn.onclick = () => self.toggleMute(); + + // Reflect — fires once with the current state, then on every change + self.audioMuted$.subscribe((muted) => { + muteBtn.classList.toggle("muted", muted); + muteBtn.textContent = muted ? "Unmute" : "Mute"; + }); +}); +``` + +That snippet is the canonical shape for *every* button on a call UI. +Swap `toggleMute` / `audioMuted$` for whichever pair you need: +[`toggleMuteVideo`] / [`videoMuted$`], [`toggleDeaf`] / [`deaf$`], +[`toggleHandraise`] / [`handraised$`], and so on. + +A few things make this pattern work cleanly: + +- **Toggles are idempotent.** Calling [`toggleMute`] while a network + round-trip is in flight is safe — the SDK serializes. +- **`$` observables emit on subscribe.** You get the current value + immediately, no "wait for an event" dance. +- **The mutator only triggers the change.** The observable is what + closes the loop. If a moderator mutes you, your `toggleMute` button + click was never sent, but `audioMuted$` still emits `true` — and + your UI updates with no extra code. + +## Where each control lives + +Three objects own the controls: + +- **`Call`** — session-level: [hangup][`Call.hangup()`], + [send DTMF][`Call.sendDigits()`], [lock][`Call.toggleLock()`], + [hold][`Call.toggleHold()`], [transfer][`Call.transfer()`], + layout (see [Layouts](/docs/browser-sdk/v4/guides/layouts)). +- **`SelfParticipant`** (`call.self`) — your own state: mute, deaf, + hand raise, [screen share](/docs/browser-sdk/v4/guides/screen-sharing), + audio processing, your volume. +- **`Participant`** (entries in `call.participants$`) — moderation + actions on *other* members. Gated by capabilities — see below. + +If you can't find a control, it's because it doesn't live where you +expected. The split exists because the server-side authorization +model is different for each layer: ending a call needs `end` +capability, kicking someone needs `member.remove`, muting yourself is +unconditional. + +## Two non-obvious distinctions + +### Mute vs. deaf + +[Mute][`audioMuted$`] silences what *you* send. [Deaf][`deaf$`] +silences what *you* hear. They're independent — you can be deaf +without being muted (you keep talking, but you can't hear responses). +Useful when the user steps away briefly without leaving the room. + +### Mute vs. hold vs. push-to-talk + +Three ways to stop transmitting audio, and they're not interchangeable: + +| Action | What it does | Latency | Use for | +| ---------------------------- | -------------------------------------------------- | -------------------------------- | ----------------------------- | +| [`toggleMute`][`audioMuted$`] | Disables the audio track server-side | Round-trip | Standard mute button | +| [`toggleHold`][`Call.toggleHold()`] | Pauses media transmission for the whole call | Round-trip | "Be right back" / call park | +| Push-to-talk (local pipeline) | Sets local mic gain to 0 — track stays alive | Instant (no round-trip) | Walkie-talkie UIs | + +For instant talk/silence transitions (e.g. holding spacebar), use +push-to-talk — mute would feel laggy because the round-trip is visible +to the user: + +```js +call.enablePushToTalk(); +document.addEventListener("keydown", (e) => { + if (e.code === "Space") call.setPushToTalkActive(true); +}); +document.addEventListener("keyup", (e) => { + if (e.code === "Space") call.setPushToTalkActive(false); +}); +``` + +The local audio pipeline also gives you [`localAudioLevel$`] for a +real-time meter and [`localSpeaking$`] for VAD-based speaking +detection — both are observables of the local mic, computed +client-side, fast enough for ~30fps UI updates. + +## DTMF, timing matters + +[`sendDigits`][`Call.sendDigits()`] only succeeds once `status$` is +`'connected'`. Sending before media is negotiated will fail or be +dropped: + +```js +import { filter, take } from "rxjs"; + +call.status$ + .pipe(filter((s) => s === "connected"), take(1)) + .subscribe(async () => { + await call.sendDigits("1234#"); + }); +``` + +For interactive dialpads (digits sent as the user presses), wire +the button click directly — by that point the call is connected. + +## Moderation — check the capability first + +Methods on other participants exist (`participant.mute()`, +`participant.remove()`, `participant.setPosition()`), but calling them +without the corresponding capability throws server-side. Drive the UI +off [`SelfCapabilities.member$`]: + +```js +call.self?.capabilities.member$.subscribe((member) => { + kickButton.hidden = !member.remove; + muteOthersButton.disabled = !member.muteAudio.on; +}); +``` + +The flag is the server's authoritative answer. If it's false, hide +the button — don't show a button that will error out. The +[Capabilities](/docs/browser-sdk/v4/guides/capabilities) guide +covers the full model. + +## Putting it together + +A minimal but realistic control bar — every button uses the same +mutator+observable pattern as the canonical snippet at the top: + +```js +call.self$.subscribe((self) => { + if (!self) return; + + // Triggers + muteBtn.onclick = () => self.toggleMute(); + videoBtn.onclick = () => self.toggleMuteVideo(); + deafBtn.onclick = () => self.toggleDeaf(); + handBtn.onclick = () => self.toggleHandraise(); + hangupBtn.onclick = () => call.hangup(); + + // Reflections + self.audioMuted$.subscribe((m) => muteBtn.classList.toggle("muted", m)); + self.videoMuted$.subscribe((m) => videoBtn.classList.toggle("muted", m)); + self.deaf$.subscribe((d) => deafBtn.classList.toggle("active", d)); + self.handraised$.subscribe((h) => handBtn.classList.toggle("active", h)); +}); +``` + +That's the muscle. Everything else (volume sliders, audio processing +toggles, the screen share button, moderation actions) is the same +shape with different names — and every name is on the [reference]. + +## Reference + +[reference]: /docs/browser-sdk/v4/reference + +**Self mute / audio state** + +- [`Participant.toggleMute()`] / [`mute()`] / [`unmute()`] · [`audioMuted$`] +- [`Participant.toggleMuteVideo()`] / [`muteVideo()`] / [`unmuteVideo()`] · [`videoMuted$`] +- [`Participant.toggleDeaf()`] · [`deaf$`] +- [`Participant.toggleHandraise()`] · [`handraised$`] · [`Call.raiseHandPriority$`] + +**Audio processing** + +- [`Participant.toggleEchoCancellation()`] · [`echoCancellation$`] +- [`Participant.toggleAudioInputAutoGain()`] · [`autoGain$`] +- [`Participant.toggleNoiseSuppression()`] · [`noiseSuppression$`] +- [`SelfParticipant.enableStudioAudio()`] / [`disableStudioAudio()`] · [`studioAudio$`] + +**Volumes (server-mixed)** + +- [`Participant.setAudioInputVolume()`] · [`inputVolume$`] +- [`Participant.setAudioOutputVolume()`] · [`outputVolume$`] +- [`Participant.setAudioInputSensitivity()`] · [`inputSensitivity$`] + +**Call-level** + +- [`Call.hangup()`], [`Call.sendDigits()`], [`Call.toggleLock()`] · [`locked$`], [`Call.toggleHold()`], [`Call.transfer()`] +- [`Call.setLocalMicrophoneGain()`], [`Call.localAudioLevel$`], [`Call.localSpeaking$`], [`Call.enablePushToTalk()`], [`Call.setPushToTalkActive()`] + +**Moderation** + +- [`Participant.remove()`], [`Participant.end()`], [`Participant.setPosition()`] +- Gated by [`SelfCapabilities`] · see [Capabilities](/docs/browser-sdk/v4/guides/capabilities) + +[`Participant.toggleMute()`]: /docs/browser-sdk/v4/reference/participant/toggle-mute +[`toggleMute`]: /docs/browser-sdk/v4/reference/participant/toggle-mute +[`mute()`]: /docs/browser-sdk/v4/reference/participant/mute +[`unmute()`]: /docs/browser-sdk/v4/reference/participant/unmute +[`audioMuted$`]: /docs/browser-sdk/v4/reference/participant/audio-muted$ +[`Participant.toggleMuteVideo()`]: /docs/browser-sdk/v4/reference/participant/toggle-mute-video +[`toggleMuteVideo`]: /docs/browser-sdk/v4/reference/participant/toggle-mute-video +[`muteVideo()`]: /docs/browser-sdk/v4/reference/participant/mute-video +[`unmuteVideo()`]: /docs/browser-sdk/v4/reference/participant/unmute-video +[`videoMuted$`]: /docs/browser-sdk/v4/reference/participant/video-muted$ +[`Participant.toggleDeaf()`]: /docs/browser-sdk/v4/reference/participant/toggle-deaf +[`toggleDeaf`]: /docs/browser-sdk/v4/reference/participant/toggle-deaf +[`deaf$`]: /docs/browser-sdk/v4/reference/participant/deaf$ +[`Participant.toggleHandraise()`]: /docs/browser-sdk/v4/reference/participant/toggle-handraise +[`toggleHandraise`]: /docs/browser-sdk/v4/reference/participant/toggle-handraise +[`handraised$`]: /docs/browser-sdk/v4/reference/participant/handraised$ +[`Call.raiseHandPriority$`]: /docs/browser-sdk/v4/reference/webrtc-call/raise-hand-priority$ +[`Participant.toggleEchoCancellation()`]: /docs/browser-sdk/v4/reference/participant/toggle-echo-cancellation +[`echoCancellation$`]: /docs/browser-sdk/v4/reference/participant/echo-cancellation$ +[`Participant.toggleAudioInputAutoGain()`]: /docs/browser-sdk/v4/reference/participant/toggle-audio-input-auto-gain +[`autoGain$`]: /docs/browser-sdk/v4/reference/participant/auto-gain$ +[`Participant.toggleNoiseSuppression()`]: /docs/browser-sdk/v4/reference/participant/toggle-noise-suppression +[`noiseSuppression$`]: /docs/browser-sdk/v4/reference/participant/noise-suppression$ +[`SelfParticipant.enableStudioAudio()`]: /docs/browser-sdk/v4/reference/self-participant/enable-studio-audio +[`disableStudioAudio()`]: /docs/browser-sdk/v4/reference/self-participant/disable-studio-audio +[`studioAudio$`]: /docs/browser-sdk/v4/reference/self-participant/studio-audio$ +[`Participant.setAudioInputVolume()`]: /docs/browser-sdk/v4/reference/participant/set-audio-input-volume +[`inputVolume$`]: /docs/browser-sdk/v4/reference/participant/input-volume$ +[`Participant.setAudioOutputVolume()`]: /docs/browser-sdk/v4/reference/participant/set-audio-output-volume +[`outputVolume$`]: /docs/browser-sdk/v4/reference/participant/output-volume$ +[`Participant.setAudioInputSensitivity()`]: /docs/browser-sdk/v4/reference/participant/set-audio-input-sensitivity +[`inputSensitivity$`]: /docs/browser-sdk/v4/reference/participant/input-sensitivity$ +[`Call.hangup()`]: /docs/browser-sdk/v4/reference/webrtc-call/hangup +[`Call.sendDigits()`]: /docs/browser-sdk/v4/reference/webrtc-call/send-digits +[`Call.toggleLock()`]: /docs/browser-sdk/v4/reference/webrtc-call/toggle-lock +[`locked$`]: /docs/browser-sdk/v4/reference/webrtc-call/locked$ +[`Call.toggleHold()`]: /docs/browser-sdk/v4/reference/webrtc-call/toggle-hold +[`Call.transfer()`]: /docs/browser-sdk/v4/reference/webrtc-call/transfer +[`Call.setLocalMicrophoneGain()`]: /docs/browser-sdk/v4/reference/webrtc-call/set-local-microphone-gain +[`Call.localAudioLevel$`]: /docs/browser-sdk/v4/reference/webrtc-call/local-audio-level$ +[`localAudioLevel$`]: /docs/browser-sdk/v4/reference/webrtc-call/local-audio-level$ +[`Call.localSpeaking$`]: /docs/browser-sdk/v4/reference/webrtc-call/local-speaking$ +[`localSpeaking$`]: /docs/browser-sdk/v4/reference/webrtc-call/local-speaking$ +[`Call.enablePushToTalk()`]: /docs/browser-sdk/v4/reference/webrtc-call/enable-push-to-talk +[`Call.setPushToTalkActive()`]: /docs/browser-sdk/v4/reference/webrtc-call/set-push-to-talk-active +[`Participant.remove()`]: /docs/browser-sdk/v4/reference/participant/remove +[`Participant.end()`]: /docs/browser-sdk/v4/reference/participant/end +[`Participant.setPosition()`]: /docs/browser-sdk/v4/reference/participant/set-position +[`SelfCapabilities`]: /docs/browser-sdk/v4/reference/self-capabilities +[`SelfCapabilities.member$`]: /docs/browser-sdk/v4/reference/self-capabilities/member$ diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/device-management.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/device-management.mdx new file mode 100644 index 000000000..b9a6aa799 --- /dev/null +++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/device-management.mdx @@ -0,0 +1,466 @@ +--- +title: "Device Management" +subtitle: "Pick the mic, camera, and speaker — before a call and on the fly" +slug: /guides/device-management +sidebar-title: "Device Management" +position: 4 +max-toc-depth: 3 +--- + +To let users pick which microphone, camera, and speaker a call uses, subscribe to the device lists, render a picker, and apply the user's choice — either as a preference for the next call or as a live swap during one. The same APIs cover hot-plug events, so a new headset shows up in the list as soon as it's connected. + +```ts Browser +import { SignalWire, StaticCredentialProvider } from "@signalwire/js"; + +const client = new SignalWire(new StaticCredentialProvider({ token: SAT })); + +// 1. Prompt for permission so devices come back with real labels. +await client.requestMediaPermissions({ audio: true, video: true }); + +// 2. Render the lists; they re-emit when devices are plugged or unplugged. +client.audioInputDevices$.subscribe((mics) => renderMicOptions(mics)); +client.videoInputDevices$.subscribe((cams) => renderCamOptions(cams)); +client.audioOutputDevices$.subscribe((speakers) => renderSpeakerOptions(speakers)); + +// 3. Apply the user's choice. Before a call, set the preference on the client; +// during a call, switch the live track on `call.self`. +micSelect.onchange = () => { + const mic = client.audioInputDevices.find((d) => d.deviceId === micSelect.value); + if (activeCall?.self) activeCall.self.selectAudioInputDevice(mic); + else client.selectAudioInputDevice(mic); +}; +``` + +The sections below cover requesting permission, reading the device lists, applying the user's pick before a call and again mid-call, routing remote audio to the chosen speaker, and reacting when a device disappears. + + +**Before you start.** Devices live behind the browser's permission gate. `getUserMedia` only runs on secure origins (HTTPS or `localhost`), and device labels are empty strings until the user has granted access at least once. Plan the UX so picking a device comes *after* the prompt — not the other way around. + + +## The three device kinds + +Each kind comes with its own pair of observables: one for the list of available devices, and one for the current selection. Subscribe to both whenever you render a picker so the `` reflects the right value when the SDK switches devices on its own: + +```ts Browser +let mics = []; +let selectedMic = null; + +function renderMics() { + micSelect.innerHTML = ""; + for (const mic of mics) { + const option = new Option(mic.label || `Microphone ${mic.deviceId.slice(0, 6)}`, mic.deviceId); + option.selected = mic.deviceId === selectedMic?.deviceId; + micSelect.append(option); + } +} + +client.audioInputDevices$.subscribe((list) => { + mics = list; + renderMics(); +}); + +client.selectedAudioInputDevice$.subscribe((device) => { + selectedMic = device; + renderMics(); +}); +``` + +The same pattern fits `videoInputDevices$` and `audioOutputDevices$` — only the property names change. Caching the latest of each list in module-scope state keeps the rendering pure and avoids pulling in observable combinators. + +## Apply the user's choice + +There are two scopes for "use this device." Match the scope to the moment the user picks: + + + + Set a preference on the client. The selection sticks for every future [`dial()`](/docs/browser-sdk/v4/reference/signalwire/dial) and [`answer()`](/docs/browser-sdk/v4/reference/interfaces/call), so the next outbound call is captured from the right mic and camera without any extra wiring. Wire the picker's `change` event to look up the chosen `MediaDeviceInfo` from the live list and pass it to [`selectAudioInputDevice()`](/docs/browser-sdk/v4/reference/signalwire/select-audio-input-device) or [`selectVideoInputDevice()`](/docs/browser-sdk/v4/reference/signalwire/select-video-input-device): + + ```ts Browser + // ` elements, dials a test destination on demand, and switches the mic, camera, or speaker on the live call from the same picker. The handler routes preference changes through `client.*` before the call and live-track swaps through `activeCall.self.*` while the call is up — so the same dropdowns exercise both branches of the [Apply the user's choice](#apply-the-users-choice) section. + + + +### Issue a Subscriber Access Token + +Create a SAT for your project — the [Authentication guide](/docs/browser-sdk/v4/guides/authentication) covers the production version of this flow; the [Create Subscriber Token](/docs/apis/rest/subscribers/tokens/create-subscriber-token) reference will send the request for you. + + + +### Open the demo and request permission + +Save the page below as `devices-demo.html` and open it over HTTPS (or `localhost`). Paste the SAT and click **Connect** — the log reports the WebSocket coming up. Click **Request permission** to drive the browser's prompt; the three dropdowns populate with labelled devices. + +Pick a different mic, camera, or speaker before dialing. The log records each pick as `preference: …` — that's the `client.select*` branch in action. Plug or unplug a USB headset to see `deviceRecovered$` fire. + + +```html + + + + + SignalWire SDK device-management demo + + + +

SignalWire SDK device-management demo

+ + + + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + + + + +
+ + +
+ +

+
+    
+  
+
+```
+
+ +### Switch a device mid-call + +Enter a destination in the **Destination** field (a `/public/`, `/private/`, PSTN number, or SIP URI the token can reach — see [Outbound Calls](/docs/browser-sdk/v4/guides/outbound-calls#pick-a-destination) for the destination shapes) and click **Dial**. The local and remote tiles populate once the call connects. + +With the call connected, change the **Microphone** or **Camera** dropdown again. The log now records the change as `live: …` — the picker is routing through `activeCall.self.selectAudioInputDevice()` instead of the client-level preference, and the remote side hears or sees the new device immediately without renegotiation. Picking a new **Speaker** triggers `applySelectedAudioOutputDevice()` on the remote `
+ +## Next steps + + + + Black video, missing audio, denied permissions. + + + Every device-related property and method on the client. + + diff --git a/fern/products/browser-sdk/pages/v4/guides/build-voice-video/inbound-calls.mdx b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/inbound-calls.mdx new file mode 100644 index 000000000..202a72eec --- /dev/null +++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/inbound-calls.mdx @@ -0,0 +1,425 @@ +--- +title: "Inbound Calls" +slug: /guides/inbound-calls +sidebar-title: "Inbound Calls" +position: 3 +max-toc-depth: 3 +--- + +To receive calls in a web app, bring a signed-in user online, show a ringing UI when someone calls them, and let them accept or decline. The result is a receiver you can call from any phone, SIP endpoint, or another browser tab. + +```ts Browser +import { SignalWire, StaticCredentialProvider } from "@signalwire/js"; +import { filter, take } from "rxjs"; + +// Constructing the client authenticates and registers the subscriber +// automatically. `await register()` here gives a sync point before we +// subscribe — see the Authentication guide for the credential lifecycle. +const client = new SignalWire(new StaticCredentialProvider({ token: SAT })); +await client.register(); + +// `showIncomingCall` / `hideIncomingCall` are your own UI helpers; `localVideo` +// and `remoteVideo` are references to your `