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/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/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..11d7f7f61 --- /dev/null +++ b/fern/products/browser-sdk/pages/v4/guides/build-voice-video/device-management.mdx @@ -0,0 +1,194 @@ +--- +title: "Device Management" +slug: /guides/device-management +sidebar-title: "Device Management" +position: 4 +max-toc-depth: 3 +--- + +The Browser SDK enumerates the user's microphones, cameras, and +speakers for you, surfaces them as observables, and reactively +updates when the OS reports a `devicechange` event. Pick a device, +hand it to the SDK, and the SDK applies it to any active call. + +The device API lives on the client (`client.audioInputDevices$`, +etc.) — not on the call. Picks made before dialing are used for the +next call; picks made mid-call are applied immediately. + +## Permission first + +Device labels are only populated after the user has granted +microphone/camera permission at least once. Until then, `MediaDeviceInfo.label` +is empty. To unlock labels at app start, request a transient stream: + +```js +async function unlockDeviceLabels() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ + audio: true, + // Add `video: true` if your app also needs camera labels. + }); + stream.getTracks().forEach((t) => t.stop()); // release immediately + } catch (err) { + // User denied — labels stay empty, but device IDs still work. + } +} +``` + +Do this on a user gesture (click), not on page load — browsers +penalize cold prompts. + +## The pattern + +Three things every device picker does: + +1. **Enumerate** — subscribe to the kind's observable + ([`audioInputDevices$`][`SignalWire.audioInputDevices$`], + [`videoInputDevices$`][`SignalWire.videoInputDevices$`], + [`audioOutputDevices$`][`SignalWire.audioOutputDevices$`]) and render + options. The streams re-emit on every OS device change. +2. **Reflect the active choice** — subscribe to the matching + `selected…Device$` and keep your `