For AI agents: a documentation index is available at the root level at /llms.txt and /llms-full.txt. Append /llms.txt to any URL for a page-level index, or .md for the markdown version of any page.
Log inSign up
Support
GuidesReferenceClick-to-Call
GuidesReferenceClick-to-Call
  • Getting Started
    • Overview
    • Authentication
    • RxJS Primer
    • Migrate from v3
  • Web Components
    • Overview
    • Click-to-Call
    • Theming
    • Customization
  • Build Voice & Video Apps
    • Overview
    • Outbound Calls
    • Inbound Calls
    • Device Management
    • Screen Sharing
    • Call Controls
    • Layouts
    • Messaging & Chat
  • Manage Resources
    • Overview
    • Users
    • Address Book
    • Client Preferences
    • Capabilities
  • Deploy
    • Overview
    • Framework Integration
    • SSR & Next.js
    • Troubleshooting
LogoLogoSignalWire Docs
Log inSign up
Support
On this page
  • Start / stop
  • Handling permission and cancel
  • Detect support before showing the button
  • Observing share state
  • How the share appears to other participants
  • Audio with the share
  • Browser quirks
  • Stopping from outside the page
  • Reference
Build Voice & Video Apps

Screen Sharing

|View as Markdown|Open in Claude|
Was this page helpful?
Edit this page
Previous

Call Controls

Next
Built with

Screen sharing is a method on the SelfParticipant. Call startScreenShare() to add a screen-share track to the active call; call stopScreenShare() to remove it. The SDK calls getDisplayMedia() under the hood, negotiates the additional track, and pushes status changes through an observable.

There’s no separate “screen share call” — the share is added to the existing call alongside the camera, as a second video stream.

You’ll need an active call. Screen share attaches to an existing Call — start one of these first.

Set up the client

Install the SDK and create a SignalWire client with a credential provider.

Place an outbound call

Dial a destination with client.dial() to get a Call instance.

Answer an inbound call

Subscribe to client.session.incomingCalls$ and call.answer().

Start / stop

1call.self$.subscribe(async (self) => {
2 if (!self) return;
3
4 shareButton.onclick = async () => {
5 try {
6 if (self.screenShareStatus === "started") {
7 await self.stopScreenShare();
8 } else {
9 await self.startScreenShare();
10 }
11 } catch (err) {
12 handleShareError(err);
13 }
14 };
15});

startScreenShare() triggers the browser’s screen-picker. The promise rejects on cancel and on permission denial — you need to tell them apart to avoid showing a scary error toast for what was a deliberate user choice.

Handling permission and cancel

The browser surfaces both outcomes as a DOMException from getDisplayMedia(). Use err.name to distinguish:

err.nameWhat happenedUX
NotAllowedErrorUser clicked Cancel in the picker, or the OS / browser denied the permission entirelySilent — no toast on cancel
NotFoundErrorNo source was available to share (rare — usually a misconfigured kiosk environment)Tell the user “no shareable screen”
NotReadableErrorOS-level capture failure (another app holds the screen, hardware error)Suggest retry / closing other apps
AbortErrorThe session ended before capture startedSilent
NotSupportedErrorgetDisplayMedia() isn’t available (iOS Safari, some embedded WebViews)Hide the share button entirely

NotAllowedError is the one to watch — its message contains "Permission denied" when the OS or browser blocked the prompt, vs "Permission denied by user" (Chromium) or an empty message (Firefox / Safari) when the user clicked Cancel. The distinction is fuzzy across browsers, so the safest UX is: silent on NotAllowedError and rely on the user re-trying, since they just made an explicit choice.

1function handleShareError(err) {
2 if (err?.name === "NotAllowedError") {
3 // Cancel or denied — don't surface a toast either way.
4 return;
5 }
6 if (err?.name === "NotFoundError" || err?.name === "NotReadableError") {
7 showToast("Couldn't start screen share. Close other capture apps and try again.");
8 return;
9 }
10 if (err?.name === "NotSupportedError") {
11 showToast("Screen sharing isn't supported in this browser.");
12 return;
13 }
14 console.error("Unexpected screen share error:", err);
15}

Detect support before showing the button

getDisplayMedia() is unavailable on iOS Safari and some embedded WebViews. Gate the button on the capability and the API existence so the user never sees a control that can’t work:

1const supported =
2 typeof navigator.mediaDevices?.getDisplayMedia === "function";
3
4call.self?.capabilities.screenshare$.subscribe((canShare) => {
5 shareButton.hidden = !(supported && canShare);
6});

Observing share state

screenShareStatus$ is the reactive form. Use it instead of polling screenShareStatus so your UI reflects auto-stop (user clicked “Stop sharing” in the browser bar, OS revoked permission, etc.):

1type ScreenShareStatus = 'idle' | 'starting' | 'started' | 'stopping';
1self.screenShareStatus$.subscribe((status) => {
2 shareButton.classList.toggle("active", status === "started");
3 shareButton.disabled = status === "starting" || status === "stopping";
4});

How the share appears to other participants

The screen-share track is delivered as a separate participant entry in call.participants$ — usually with a name like Screen or the sharer’s name suffixed with (Screen). The local participant doesn’t need to render their own share to see it; the SDK doesn’t mirror the local capture into remoteStream$.

To detect which participants are screen shares specifically, check the participant’s metadata or type. In the kitchen-sink demo, the share state is read from self.screenShareStatus directly:

1const isSharing = call.self?.screenShareStatus === "started";

Audio with the share

getDisplayMedia() can capture system / tab audio on some platforms (Chromium-based browsers on macOS / Windows). The SDK forwards whatever the browser provides — there’s no separate “share audio” toggle. If the user’s browser doesn’t support display audio, only the video is shared.

In practice:

  • Chrome / Edge on macOS or Windows: works for tab audio, may work for system audio depending on OS version.
  • Firefox: video only.
  • Safari: video only (and screen sharing requires explicit user-initiated permission per session).

Browser quirks

  • User gesture required. startScreenShare() must originate from a click or keyboard event — calling it on a timer or after an async chain that didn’t start from a gesture will fail.
  • iOS Safari. Tab screen sharing isn’t supported. iOS has its own system-wide screen-broadcast flow which is outside the browser’s reach.
  • Multiple displays. The picker lists each display separately; selecting “Entire screen” on a multi-monitor setup shares only the picked display.

Stopping from outside the page

The user can stop sharing from the browser’s “Stop sharing” toolbar. When they do, the underlying track ends and the SDK transitions screenShareStatus$ to 'idle' automatically — no action required on your side. Subscribe to screenShareStatus$ and your UI will update without polling.

Reference

  • SelfParticipant.startScreenShare() — open picker, add the share track
  • SelfParticipant.stopScreenShare() — remove the share track
  • SelfParticipant.screenShareStatus$ / screenShareStatus — reactive status ('idle' | 'starting' | 'started' | 'stopping')
  • SelfCapabilities.screenshare$ — capability gate