oficina / meet Public

Merge branch 'fix/screen-share-motion' into main

Screen share keeps up with motion: "Optimise for" modes (balanced default),
screen sender tuning with the camera capped while sharing, H.264 preferred,
Mac capture queue 3 with stale-drop and idle repeat, screen send stats in the
device panel, the same on the web, and a motion gate in make native-browser.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev b414033945bc5bf97a4448111ef0c97436a7e7ca parent 6438911parent 76b0e22
17 files changed, +1543 −27
  • Makefile +1 −1
  • internal/calls/static/room.js +172 −1
  • internal/calls/templates/room.html +4 −0
  • internal/callstest/native_browser_test.go +40 −0
  • native/README.md +25 −2
  • native/apple/Sources/CallsInterop/Interop.swift +29 −3
  • native/apple/Sources/CallsInterop/SyntheticVideo.swift +139 −1
  • native/apple/Sources/CallsKit/Call.swift +73 −0
  • native/apple/Sources/CallsKit/DevicePanel.swift +28 −0
  • native/apple/Sources/CallsKit/MacScreenCapture.swift +69 −10
  • native/apple/Sources/CallsKit/Media.swift +77 −5
  • native/apple/Sources/CallsKit/ScreenSendPolicy.swift +181 −0
  • native/apple/Sources/CallsKit/ScreenSendStats.swift +182 −0
  • native/apple/Tests/CallsKitTests/ScreenSendTests.swift +214 −0
  • test/browser/native-motion.mjs +165 −0
  • test/browser/room-lane.test.mjs +12 −4
  • test/browser/room-share.test.mjs +132 −0

CI passed — run details

diff --git a/Makefile b/Makefile
index 14922c6..7e267e5 100644
--- a/Makefile
+++ b/Makefile
@@ -170,7 +170,7 @@ native-browser: native-mac
# The gate runs CallsInterop, which native-mac does not build; without
# this it runs whatever binary a previous build left behind.
swift build --package-path native/apple --product CallsInterop
- CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 5m -run 'TestNative(MacCallsBrowser|MacChat|BrowserAuthentication)' -v ./internal/callstest/
+ CALLS_DEEPFILTER_MODEL=$(CURDIR)/native/build/DeepFilterNet3_onnx.tar.gz CGO_ENABLED=0 go test -tags browser -count=1 -timeout 10m -run 'TestNative(MacCallsBrowser|MacChat|MacScreenMotion|BrowserAuthentication)' -v ./internal/callstest/
# Two apps in one call, sharing screens to each other. Separate from
# native-browser because two unsigned apps on one Mac do not reliably
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index 49e10a5..dce1300 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -92,6 +92,8 @@
const speakerRow = $('speaker-row');
const rawRow = $('raw-row');
const rawInput = rawRow ? rawRow.querySelector('input[type="checkbox"]') : null;
+ const shareModeRow = $('share-mode-row');
+ const pickShareMode = $('pick-share-mode');
const DEVICE_KEY = 'meet.devices';
const BACKOFF_MIN = 500;
@@ -213,6 +215,14 @@
const RAW_AUDIO_BITRATE = 128000;
// §5.4: the tile key a peer's screen gets, built by shareKey.
const SHARE_SUFFIX = '#share';
+ // §5.4, sending: "Optimise shared screen for". What a screen gives up
+ // first when the uplink or the encoder cannot carry all of it. The
+ // first is the default; sharePlan says what each one means.
+ const SHARE_MODES = [
+ ['balanced', 'Balanced'],
+ ['detail', 'Text and detail'],
+ ['motion', 'Motion and video'],
+ ];
const HAS_DISPLAY_MEDIA = typeof navigator !== 'undefined' &&
!!navigator.mediaDevices &&
typeof navigator.mediaDevices.getDisplayMedia === 'function';
@@ -452,6 +462,108 @@
});
}
+ // sharePlan is what a shared screen is sent as, for one "Optimise
+ // for" mode and the capture's size. Pure, and lifted by name in the
+ // node test; the native app's ScreenSendPolicy is its twin, and the two
+ // are meant to agree number for number.
+ //
+ // Why it exists (2026-09-24, "the screenshare isn't keeping up"): a
+ // screen capture left to the browser's defaults keeps every pixel and
+ // drops frames, at the encoder's default ceiling of about 2.5 Mbit/s
+ // for 1080p. On anything but a fast uplink that is a few frames a
+ // second of someone scrolling.
+ //
+ // detail full resolution, frames give way — slides, code, text
+ // balanced frame rate held, full size while the link carries it
+ // and smaller rather than a slideshow when it cannot
+ // motion frame rate held, at most 1280 on the long side
+ //
+ // Measured in the native gate (ScreenSendPolicy has the numbers): on a
+ // 1.5 Mbit/s uplink a scrolling 1080p page arrived at 2 fps as before,
+ // 13 in detail, 19 in balanced and 30 in motion.
+ //
+ // contentHint is the browser's own name for the same choice, and
+ // degradationPreference the sender's; both are set because browsers
+ // honour one or the other.
+ function sharePlan(mode, width, height) {
+ const longest = Math.max(width || 0, height || 0);
+ const fit = (side) => (longest > side ? longest / side : 1);
+ const high = { priority: 'high', networkPriority: 'high', maxFramerate: 30 };
+ if (mode === 'detail') {
+ return {
+ contentHint: 'detail', degradationPreference: 'maintain-resolution',
+ encoding: Object.assign({ maxBitrate: 3500000, scaleResolutionDownBy: fit(1920) }, high),
+ };
+ }
+ if (mode === 'motion') {
+ return {
+ contentHint: 'motion', degradationPreference: 'maintain-framerate',
+ encoding: Object.assign({ maxBitrate: 2500000, scaleResolutionDownBy: fit(1280) }, high),
+ };
+ }
+ return {
+ contentHint: 'detail', degradationPreference: 'maintain-framerate',
+ encoding: Object.assign({ maxBitrate: 3000000, scaleResolutionDownBy: fit(1920) }, high),
+ };
+ }
+
+ // cameraCap is the camera's encoding while this tab shares a screen,
+ // and its defaults again when it stops: the screen is what everyone is
+ // looking at, and it should have the uplink. `undefined` rather than a
+ // number is how the API spells "the encoder's own choice". Pure, and
+ // lifted by name in the node test.
+ function cameraCap(sharing, height) {
+ if (!sharing) return { maxBitrate: undefined, scaleResolutionDownBy: 1 };
+ return { maxBitrate: 500000, scaleResolutionDownBy: height > 360 ? height / 360 : 1 };
+ }
+
+ function shareMode() {
+ return SHARE_MODES.some(([m]) => m === prefs.shareMode) ? prefs.shareMode : SHARE_MODES[0][0];
+ }
+
+ // tuneSender sets one sender's first encoding (and, where given, its
+ // degradation preference). Best effort for the same reasons as
+ // tuneAudioSender: a sender parameter needs no SDP and no
+ // renegotiation, so §5.1 never learns it happened, and a browser that
+ // refuses carries the call at its own defaults.
+ function tuneSender(sender, encoding, degradationPreference) {
+ if (!sender || typeof sender.getParameters !== 'function') return;
+ let params;
+ try {
+ params = sender.getParameters();
+ } catch {
+ return;
+ }
+ if (!params.encodings || !params.encodings.length) return;
+ for (const [key, value] of Object.entries(encoding)) {
+ if (value === undefined) delete params.encodings[0][key];
+ else params.encodings[0][key] = value;
+ }
+ if (degradationPreference) params.degradationPreference = degradationPreference;
+ Promise.resolve().then(() => sender.setParameters(params)).catch(() => {
+ // Refused, or raced a renegotiation: the next call to this for the
+ // same sender (a mode change, the next connection) tries again.
+ });
+ }
+
+ // tuneShare puts one connection's screen and camera where the mode
+ // wants them: the screen at its plan, the camera capped beside it —
+ // or, with nothing shared, the camera back at its defaults.
+ function tuneShare(entry) {
+ if (!entry) return;
+ const sharing = !!(share && entry.shareTx);
+ if (sharing) {
+ const s = share.track.getSettings ? share.track.getSettings() : {};
+ const plan = sharePlan(shareMode(), s.width, s.height);
+ tuneSender(entry.shareTx.sender, plan.encoding, plan.degradationPreference);
+ }
+ const camera = senderFor(entry, 'video');
+ const track = camera && camera.sender && camera.sender.track;
+ if (!track) return;
+ const settings = track.getSettings ? track.getSettings() : {};
+ tuneSender(camera.sender, cameraCap(sharing, settings.height || 0));
+ }
+
// startLocal walks down a ladder: the remembered devices, then the
// browser's defaults, then audio alone. A camera that is missing or
// in use should not cost the call its microphone as well, and a
@@ -3792,6 +3904,12 @@
return;
}
share = { stream, track };
+ try {
+ track.contentHint = sharePlan(shareMode()).contentHint;
+ } catch {
+ // A browser without contentHint: the sender's degradation
+ // preference below says the same thing.
+ }
// The browser's own "Stop sharing" bar ends the track without
// telling this file anything else. §5.4 gives it the same ending as
// the button.
@@ -3823,6 +3941,7 @@
direction: 'sendonly',
streams: [share.stream],
});
+ tuneShare(entry);
} catch {
// A browser without addTransceiver, or a connection already
// closing: that peer simply never sees this screen, and the call
@@ -3871,6 +3990,7 @@
const tx = entry.shareTx;
if (!tx) return;
entry.shareTx = null;
+ tuneShare(entry);
try {
if (typeof tx.stop === 'function') {
tx.stop();
@@ -4139,7 +4259,7 @@
// live-looking select that silently does nothing is the worse half
// of that, so they are disabled and say so.
if (devicesToggle) devicesToggle.disabled = !on;
- for (const el of [pickCamera, pickMic, pickSpeaker, rawInput]) {
+ for (const el of [pickCamera, pickMic, pickSpeaker, rawInput, pickShareMode]) {
if (el) el.disabled = !on;
}
}
@@ -4587,6 +4707,31 @@
});
}
+ // "Optimise shared screen for". Applied at once to a share in
+ // progress — contentHint on the track, parameters on each sender —
+ // and remembered for the next.
+ if (pickShareMode && shareModeRow && HAS_DISPLAY_MEDIA) {
+ for (const [value, label] of SHARE_MODES) {
+ const option = document.createElement('option');
+ option.value = value;
+ option.textContent = label;
+ pickShareMode.append(option);
+ }
+ pickShareMode.value = shareMode();
+ shareModeRow.hidden = false;
+ pickShareMode.addEventListener('change', () => {
+ prefs.shareMode = pickShareMode.value;
+ savePrefs();
+ if (!share) return;
+ try {
+ share.track.contentHint = sharePlan(shareMode()).contentHint;
+ } catch {
+ // No contentHint here: the sender parameters carry it.
+ }
+ for (const entry of pcs.values()) tuneShare(entry);
+ });
+ }
+
// §5.4's gesture. The handler is not async: a rejection escaping one
// goes where neither a person nor a gate could see it, so the work is
// a named function with a catch of its own — the same shape as the
@@ -4812,6 +4957,26 @@
return reports.length === 1 ? reports[0] : null;
}
+ // shareSendReport is this tab's own screen on the way out, from one
+ // connection's outbound reports: what size and rate it is actually
+ // leaving at, what (if anything) is holding it back, and which encoder
+ // is doing the work — the questions "the screenshare isn't keeping up"
+ // asks. Pure, and lifted by name in the node test.
+ function shareSendReport(reports, mid) {
+ if (!mid) return null;
+ const r = reports.find((x) => x.mid === mid);
+ if (!r) return null;
+ return {
+ width: r.frameWidth || 0,
+ height: r.frameHeight || 0,
+ fps: r.framesPerSecond || 0,
+ limitation: r.qualityLimitationReason || '',
+ targetBitrate: r.targetBitrate || 0,
+ encoder: r.encoderImplementation || '',
+ framesEncoded: r.framesEncoded || 0,
+ };
+ }
+
// judgeCamera watches one connection's own camera encoder and says
// what to do about it: '' to leave it alone, 'repair' to rebuild the
// encoder, 'reset' to rebuild the whole connection.
@@ -5083,6 +5248,7 @@
if (verdict === 'replay') replayTile(key, t, entry, key !== id);
else if (verdict === 'reset') requestReset(id);
}
+ entry.shareSend = shareSendReport(outbound, (entry.shareTx && entry.shareTx.mid) || '');
const tx = localCameraTx(entry);
const verdict = judgeCamera(entry, tx, cameraReport(outbound, (tx && tx.mid) || ''));
if (verdict === 'repair') await repairCamera(tx);
@@ -5273,6 +5439,11 @@
// the message is what draws the tile.
sharing: !!share,
shares: sharingPeers(),
+ // And how this tab's own screen is leaving, per connection, as of
+ // the last stats walk (shareSendReport); the mode it was asked for.
+ shareMode: shareMode(),
+ shareSend: Object.fromEntries(
+ Array.from(pcs, ([id, entry]) => [id, entry.shareSend || null])),
// §15.19: what this tab reports, and what it has been told, by
// peer id. Peers that never reported are absent.
media: localMedia(),
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html
index ff1fb23..50117f0 100644
--- a/internal/calls/templates/room.html
+++ b/internal/calls/templates/room.html
@@ -73,6 +73,10 @@
<div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div>
<div id="raw-row" class="calls-raw-row" hidden>{{template "field-check" dict "Name" "raw-audio" "Label" "Studio audio"}}
<p id="raw-help" class="calls-raw-help">Sends your microphone unprocessed and at a higher bitrate, for an audio interface in a room you have treated. It turns off echo cancellation, noise suppression and automatic gain, so leave it off on a laptop.</p></div>
+ {{/* §5.4: what a shared screen gives up first when the connection
+ cannot carry all of it. room.js fills the options, remembers the
+ choice and reveals the row in a browser that can share. */}}
+ <div id="share-mode-row" hidden>{{template "field-select" dict "ID" "pick-share-mode" "Name" "share-mode" "Label" "Optimise shared screen for" "Help" "Balanced keeps text readable and keeps up with scrolling. Text and detail keeps small print sharp; movement may look jerky. Motion and video keeps movement smooth; text may look softer."}}</div>
{{/* Device messages get a live region of their own, like the relay
warning: renderStatus owns #status and rewrites it on every
render, so a sentence about a camera that would not open has
diff --git a/internal/callstest/native_browser_test.go b/internal/callstest/native_browser_test.go
index 53da5c7..e4d346f 100644
--- a/internal/callstest/native_browser_test.go
+++ b/internal/callstest/native_browser_test.go
@@ -310,3 +310,43 @@ func waitNative(t *testing.T, label string, ok func() bool, apps ...*nativeApp)
}
t.Fatal(label)
}
+
+// TestNativeMacScreenMotion is the app sharing a 1080p page that scrolls
+// at 30 fps, measured at the browser: frames a second and frame size on
+// the received screen, for each "Optimise for" mode, on an open link and
+// under a capped uplink. It holds motion and the balanced default to a
+// frame rate a scroll can be followed at, and detail to full resolution
+// (test/browser/native-motion.mjs has the thresholds).
+func TestNativeMacScreenMotion(t *testing.T) {
+ rig := newBrowserRig(t)
+ room, _ := rig.createMeeting(t)
+ root, err := filepath.Abs("../..")
+ if err != nil {
+ t.Fatal(err)
+ }
+ binary := filepath.Join(root, "native/apple/.build/debug/CallsInterop")
+ if _, err := os.Stat(binary); err != nil {
+ t.Fatal("build native interop executable with make native-mac first")
+ }
+ ctx, cancel := context.WithTimeout(t.Context(), 300*time.Second)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "node", filepath.Join(root, "test/browser/native-motion.mjs"), rig.origin, room, binary)
+ cookies, _ := json.Marshal([]map[string]string{{"name": rig.cookieName, "value": rig.cookieB}})
+ input, _ := json.Marshal(map[string]string{"origin": rig.origin, "room": room, "cookies": string(cookies)})
+ cmd.Env = append(os.Environ(), "CALLS_COOKIE_NAME="+rig.cookieName, "CALLS_COOKIE_A="+rig.cookieA, "CALLS_NATIVE_INPUT="+string(input))
+ out, err := cmd.StdoutPipe()
+ if err != nil {
+ t.Fatal(err)
+ }
+ cmd.Stderr = cmd.Stdout
+ if err := cmd.Start(); err != nil {
+ t.Fatal(err)
+ }
+ scanner := bufio.NewScanner(out)
+ for scanner.Scan() {
+ t.Log(scanner.Text())
+ }
+ if err := cmd.Wait(); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/native/README.md b/native/README.md
index 6e2ed13..8c39352 100644
--- a/native/README.md
+++ b/native/README.md
@@ -335,8 +335,31 @@ transceiver in its own stream, exactly as the browser sends one, announced with
`share {on:true}`; stopping stops the transceiver and renegotiates, so the far
side's track ends rather than freezing, and says `share {on:false}`. The menu
bar's stop control and a shared window closing end it the same way as the
-button. The source is a screencast, so WebRTC gives up frame rate before
-resolution, and frames are capped at 1920 on the long side and 30 a second.
+button. Frames are capped at 1920 on the long side and 30 a second.
+
+**Optimise shared screen for** (in the device panel, remembered, applied live
+to a share in progress) decides what gives way when the uplink or the encoder
+cannot carry everything: **Balanced** (the default) holds the frame rate and
+sends full size while the link allows; **Text and detail** holds resolution
+and lets frames fall; **Motion and video** holds the frame rate at 1280 on the
+long side at most. They are sender parameters (`ScreenSendPolicy`), so nothing
+renegotiates. While a screen goes out it asks for H.264 first (Apple's
+hardware encoder), with every other codec still offered, and has high priority;
+the camera beside it is capped at 500 kbit/s and 360 lines, and given its
+defaults back when the share stops. While sharing, the panel shows one line on
+how the screen is actually leaving, e.g. "Screen: 1920×1080 at 8 fps, limited
+by bandwidth, 1.2 Mbit/s, direct (H264 hardware)". The Mac capture keeps three
+frames in its queue, drops a frame that arrives more than 100 ms late, and
+repeats the last frame once a second while the screen is still, so a keyframe
+can be made for a receiver that asks for one. The browser offers the same three
+choices, as `contentHint`, `degradationPreference` and sender parameters.
+
+The numbers behind the modes come from `TestNativeMacScreenMotion` (in
+`make native-browser`): the app shares a 1080p page scrolling at 30 fps with a
+window dragged across it, and Chromium measures what arrives, with the app's
+uplink estimate open and capped at 1.5 Mbit/s. Before this, a 1.5 Mbit/s uplink
+delivered 2 fps at full size. Balanced and motion must now arrive at 15 fps or
+more, and detail must keep 1920×1080.
On **iPhone** the whole screen is shared through a ReplayKit broadcast
extension, which hands its frames to the app over a socket in an App Group
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift
index ddeadee..28cff12 100644
--- a/native/apple/Sources/CallsInterop/Interop.swift
+++ b/native/apple/Sources/CallsInterop/Interop.swift
@@ -103,6 +103,30 @@ struct Input: Decodable {
print("{\"screenSharing\":\(on)}")
fflush(stdout)
}
+ // "Optimise for", as the device panel sets it.
+ if command.hasPrefix("screen-mode ") {
+ let mode = ScreenShareMode(rawValue: String(command.dropFirst("screen-mode ".count)))
+ if let mode { await MainActor.run { call.screenMode = mode } }
+ print("{\"screenMode\":\"\(mode?.rawValue ?? "")\"}")
+ fflush(stdout)
+ }
+ // The motion gate: a 1080p page scrolling at 30 fps.
+ if command == "screen-motion" {
+ let screen = await SyntheticScreen(motion: true)
+ await MainActor.run { Interop.screen = screen }
+ await call.shareScreen(screen)
+ let on = await call.sharingScreen
+ print("{\"screenSharing\":\(on)}")
+ fflush(stdout)
+ }
+ // A thin uplink, for the same gate: `uplink 1200000`, or `uplink 0`
+ // to lift it.
+ if command.hasPrefix("uplink ") {
+ let bps = Int(command.dropFirst("uplink ".count)) ?? 0
+ await call.limitUplink(bps > 0 ? bps : nil)
+ print("{\"uplink\":\(bps)}")
+ fflush(stdout)
+ }
if command == "screen-off" {
await call.stopSharingScreen()
print("{\"screenSharing\":false}")
@@ -121,9 +145,10 @@ struct Input: Decodable {
}
}
}
- // Long enough for the member scenario, screens and all, with room to
- // spare: the gate always says `leave` long before this runs out.
- for _ in 0..<150 {
+ // Long enough for the member scenario, screens and all, and for the
+ // motion gate's three modes: the gate always says `leave` long before
+ // this runs out.
+ for _ in 0..<400 {
try? await Task.sleep(nanoseconds: 1_000_000_000)
let stats = await call.sampleStats()
if let data = try? JSONSerialization.data(withJSONObject: [
@@ -140,6 +165,7 @@ struct Input: Decodable {
// place a screen says whose it is.
"screenCaptions": call.screens.map { screenCaption(call.name(of: $0)) },
"sharingScreen": call.sharingScreen,
+ "syntheticScreenFrames": Interop.screen?.produced ?? 0,
"cameraOpens": Interop.camera.opens,
"cameraStatus": {
switch call.cameraStatus {
diff --git a/native/apple/Sources/CallsInterop/SyntheticVideo.swift b/native/apple/Sources/CallsInterop/SyntheticVideo.swift
index 186a6b4..1ce3093 100644
--- a/native/apple/Sources/CallsInterop/SyntheticVideo.swift
+++ b/native/apple/Sources/CallsInterop/SyntheticVideo.swift
@@ -93,11 +93,24 @@ import LiveKitWebRTC
private var capturer: RTCVideoCapturer?
private var task: Task<Void, Never>?
private var ended: (@MainActor () -> Void)?
+ /// A 1920×1080 page of text scrolling at 30 frames a second, for the
+ /// motion gate: what someone scrolling a document looks like to the
+ /// encoder, which is where a screencast source falls behind.
+ private let page: ScrollingPage?
+
+ init(motion: Bool = false) { page = motion ? ScrollingPage() : nil }
func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws {
self.source = source
self.ended = ended
- capturer = RTCVideoCapturer(delegate: source)
+ let capturer = RTCVideoCapturer(delegate: source)
+ self.capturer = capturer
+ if let page {
+ // Off the main actor and on a timer of its own, as a real capture
+ // is: the gate measures WebRTC, not this process's main thread.
+ page.start(source: source, capturer: capturer)
+ return
+ }
task = Task { @MainActor [weak self] in
while !Task.isCancelled {
self?.frame()
@@ -105,7 +118,10 @@ import LiveKitWebRTC
}
}
}
+ /// Frames the moving page has produced, for the gate.
+ var produced: Int { page?.produced ?? 0 }
func stop() {
+ page?.stop()
task?.cancel()
task = nil
source = nil
@@ -141,3 +157,125 @@ import LiveKitWebRTC
timeStampNs: Int64(ProcessInfo.processInfo.systemUptime * 1_000_000_000)))
}
}
+
+/// Lines of dark "glyphs" on white, twice a screen tall, shown through a
+/// 1920×1080 window that moves down 12 rows a frame and wraps: a fast
+/// scroll through a dense document, at 30 frames a second, with a window
+/// dragged across it. Built once;
+/// each frame is row copies into a pooled buffer, on a queue of its own.
+final class ScrollingPage: @unchecked Sendable {
+ static let width = 1920
+ static let height = 1080
+ private let rows = ScrollingPage.height * 2
+ private let texture: [UInt8]
+ private var offset = 0
+ private var frame = 0
+ private let queue = DispatchQueue(label: "calls.synthetic-screen", qos: .userInteractive)
+ private var timer: DispatchSourceTimer?
+ private var pool: CVPixelBufferPool?
+ private let lock = NSLock()
+ private var count = 0
+ var produced: Int { lock.withLock { count } }
+
+ init() {
+ let stride = Self.width * 4
+ var texture = [UInt8](repeating: 0xF4, count: stride * rows)
+ var seed: UInt64 = 0x9E37_79B9_7F4A_7C15
+ func random(_ n: Int) -> Int {
+ seed = seed &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407
+ return Int((seed >> 33) % UInt64(n))
+ }
+ let rows = self.rows
+ texture.withUnsafeMutableBytes { raw in
+ guard let base = raw.baseAddress else { return }
+ var line = 8
+ while line + 16 < rows {
+ var x = 40 + random(40)
+ let end = Self.width - 40 - random(600)
+ while x < end {
+ let glyph = 5 + random(9)
+ let tall = 9 + random(6)
+ let shade = Int32(20 + random(60))
+ for y in (line + 14 - tall)..<(line + 14) {
+ memset(base + y * stride + x * 4, shade, min(glyph, end - x) * 4)
+ }
+ x += glyph + 2 + (random(6) == 0 ? 8 : 0)
+ }
+ line += 22
+ }
+ }
+ self.texture = texture
+ CVPixelBufferPoolCreate(
+ kCFAllocatorDefault, nil,
+ [
+ kCVPixelBufferWidthKey: Self.width, kCVPixelBufferHeightKey: Self.height,
+ kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_32BGRA,
+ kCVPixelBufferIOSurfacePropertiesKey: [:],
+ ] as CFDictionary, &pool)
+ }
+
+ func start(source: RTCVideoSource, capturer: RTCVideoCapturer) {
+ // Strict: a lenient timer on a background process is coalesced to a
+ // third of this rate, which measured the timer and not WebRTC.
+ let timer = DispatchSource.makeTimerSource(flags: .strict, queue: queue)
+ timer.schedule(deadline: .now(), repeating: .nanoseconds(33_333_333), leeway: .milliseconds(2))
+ timer.setEventHandler { [weak self] in
+ guard let self, let pixel = self.next() else { return }
+ source.capturer(
+ capturer,
+ didCapture: RTCVideoFrame(
+ buffer: RTCCVPixelBuffer(pixelBuffer: pixel), rotation: ._0,
+ timeStampNs: Int64(DispatchTime.now().uptimeNanoseconds)))
+ self.lock.withLock { self.count += 1 }
+ }
+ self.timer = timer
+ timer.resume()
+ }
+
+ func stop() {
+ timer?.cancel()
+ timer = nil
+ }
+
+ private func bounce(_ travelled: Int, _ span: Int) -> Int {
+ let lap = travelled % (2 * span)
+ return lap < span ? lap : 2 * span - lap
+ }
+
+ private func next() -> CVPixelBuffer? {
+ guard let pool else { return nil }
+ var pixel: CVPixelBuffer?
+ CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &pixel)
+ guard let pixel else { return nil }
+ CVPixelBufferLockBaseAddress(pixel, [])
+ defer { CVPixelBufferUnlockBaseAddress(pixel, []) }
+ guard let base = CVPixelBufferGetBaseAddress(pixel) else { return nil }
+ let destination = CVPixelBufferGetBytesPerRow(pixel)
+ let stride = Self.width * 4
+ let first = offset
+ let rows = self.rows
+ frame += 1
+ // A window being dragged across the page — 800×450, moving 10 rows
+ // and 14 columns a frame and bouncing off the edges — whose content
+ // changes every half second, as a page loading or a slide advancing
+ // would. Together with the scroll beneath it, what someone moving
+ // things around a busy screen sends the encoder.
+ let span = (x: Self.width - 800, y: Self.height - 450)
+ let wx = bounce(frame * 14, span.x)
+ let wy = bounce(frame * 10, span.y)
+ let scene = frame / 15
+ let jump = (scene &* 7919) % (rows - 450)
+ let shift = ((scene &* 104_729) % 900) * 4
+ texture.withUnsafeBytes { raw in
+ guard let source = raw.baseAddress else { return }
+ for y in 0..<Self.height {
+ memcpy(base + y * destination, source + ((first + y) % rows) * stride, stride)
+ if y >= wy, y < wy + 450 {
+ memcpy(base + y * destination + wx * 4, source + (jump + y - wy) * stride + shift, 800 * 4)
+ }
+ }
+ }
+ offset = (offset + 12) % rows
+ return pixel
+ }
+}
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index dbfe124..3f32290 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -43,6 +43,20 @@ private final class Events: NSObject, BridgeListenerProtocol {
@Published public private(set) var sharingScreen = false
@Published public private(set) var screenNote = ""
private var screenPending = false
+ /// How this app's own screen is going out, while it is sharing: the
+ /// slowest connection's, read once a second. See ScreenSendReport.
+ @Published public private(set) var screenReport: ScreenSendReport?
+ /// "Optimise for": what a shared screen gives up first. Remembered, and
+ /// applied at once to a share in progress (see ScreenSendPolicy).
+ @Published public var screenMode: ScreenShareMode = .remembered {
+ didSet {
+ guard screenMode != oldValue else { return }
+ ScreenShareMode.remembered = screenMode
+ media.screenMode = screenMode
+ }
+ }
+ private var screenReports: [String: ScreenSendReport] = [:]
+ private var screenWatch: Task<Void, Never>?
@Published public private(set) var localTrack: RTCVideoTrack?
@Published public private(set) var muted = false
@Published public private(set) var cameraOff = false
@@ -160,6 +174,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
remoteScreens = [:]
sharingScreen = false
screenNote = ""
+ endScreenWatch()
localTrack = nil
ice = nil
state = nil
@@ -408,6 +423,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
screens = []
remoteScreens = [:]
sharingScreen = false
+ endScreenWatch()
localTrack = nil
cameraChanged()
default: status = "Connecting…"
@@ -485,6 +501,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
defer { screenPending = false }
let joined = epoch
screenNote = ""
+ media.screenMode = screenMode
do {
try await media.startScreen(capture) { [weak self] in
// The capture ended on its own: the system's stop control, or
@@ -505,6 +522,37 @@ private final class Events: NSObject, BridgeListenerProtocol {
}
sharingScreen = true
send(type: "share", to: "", gen: 0, payload: json(["on": true]) ?? "")
+ watchScreen()
+ }
+
+ /// Reads the screen's outbound statistics once a second while sharing,
+ /// for the device panel's line and the gate.
+ private func watchScreen() {
+ screenWatch?.cancel()
+ screenWatch = Task { @MainActor [weak self] in
+ while !Task.isCancelled {
+ try? await Task.sleep(nanoseconds: 1_000_000_000)
+ guard let self, !Task.isCancelled, self.sharingScreen else { return }
+ let fresh = await self.media.screenReports()
+ guard !Task.isCancelled, self.sharingScreen else { return }
+ var measured: [String: ScreenSendReport] = [:]
+ for (peer, report) in fresh {
+ measured[peer] = report.measured(since: self.screenReports[peer])
+ }
+ self.screenReports = measured
+ let worst = ScreenSendReport.worst(Array(measured.values))
+ self.screenReport = worst
+ // Motion mode scales from the source's own size, which only the
+ // statistics know (a window can be any shape, and re-picked).
+ if let worst { self.media.screenSource(width: worst.sourceWidth, height: worst.sourceHeight) }
+ }
+ }
+ }
+ private func endScreenWatch() {
+ screenWatch?.cancel()
+ screenWatch = nil
+ screenReports = [:]
+ screenReport = nil
}
/// Stops sharing, from every way out: the button, the system's own stop
@@ -512,6 +560,7 @@ private final class Events: NSObject, BridgeListenerProtocol {
public func stopSharingScreen() {
guard sharingScreen else { return }
sharingScreen = false
+ endScreenWatch()
media.stopScreen()
send(type: "share", to: "", gen: 0, payload: json(["on": false]) ?? "")
}
@@ -533,6 +582,9 @@ private final class Events: NSObject, BridgeListenerProtocol {
remoteScreens.removeValue(forKey: peer)
}
func mediaFailed() { status = "A participant’s media could not connect." }
+ /// Caps the call's uplink estimate at `bps` (nil lifts it): the gate's
+ /// stand-in for a thin connection, which it has no other way to get.
+ public func limitUplink(_ bps: Int?) { media.limitUplink(bps) }
public func sampleStats() async -> [String: UInt64] {
var values = await media.stats()
values["hasICE"] = ice == nil ? 0 : 1
@@ -540,6 +592,27 @@ private final class Events: NSObject, BridgeListenerProtocol {
values["remoteScreens"] = UInt64(remoteScreens.count)
values["screens"] = UInt64(screens.count)
values["invalidEvents"] = invalidEvents
+ // This app's own screen, as it leaves: the slowest connection's.
+ if let screen = screenReport {
+ values["screenSendWidth"] = UInt64(screen.width)
+ values["screenSendHeight"] = UInt64(screen.height)
+ values["screenSendFpsTenths"] = UInt64((screen.framesPerSecond * 10).rounded())
+ values["screenCapturedFpsTenths"] = UInt64((screen.capturedPerSecond * 10).rounded())
+ values["screenFramesSent"] = screen.framesSent
+ values["screenFramesEncoded"] = screen.framesEncoded
+ values["screenFramesCaptured"] = screen.framesCaptured
+ values["screenLimitation"] = screen.limitationCode
+ values["screenLimitedBandwidthMs"] = UInt64(((screen.limitationSeconds["bandwidth"] ?? 0) * 1000).rounded())
+ values["screenLimitedCpuMs"] = UInt64(((screen.limitationSeconds["cpu"] ?? 0) * 1000).rounded())
+ values["screenTargetBitrate"] = screen.targetBitrate
+ values["screenSentBitrate"] = screen.sentBitrate
+ values["screenEncodeMsTenths"] = UInt64((screen.encodeMsPerFrame * 10).rounded())
+ values["screenHardwareEncoder"] = screen.hardwareEncoder ? 1 : 0
+ values["screenCodecH264"] = screen.codec.lowercased().hasSuffix("h264") ? 1 : 0
+ values["screenPath"] = screen.pathCode
+ values["screenRttMs"] = UInt64((screen.roundTripTime * 1000).rounded())
+ values["screenPacketsLost"] = UInt64(max(0, screen.packetsLost))
+ }
values["rosterPeers"] = UInt64(state?.peers.count ?? 0)
values["noiseFramesProcessed"] = media.audio.denoiser.framesProcessed
values["noiseSuppressor"] = media.audio.denoiser.engineName == "RNNoise" ? 1 : 2
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift
index 07a29bc..cde891b 100644
--- a/native/apple/Sources/CallsKit/DevicePanel.swift
+++ b/native/apple/Sources/CallsKit/DevicePanel.swift
@@ -51,6 +51,8 @@ import SwiftUI
noiseToggle
+ screenSection
+
Divider()
audioReadout
}
@@ -229,6 +231,32 @@ import SwiftUI
}
}
+ /// "Optimise for", and while this app is sharing, one line saying how
+ /// the screen is actually leaving — the answer to "it isn't keeping up".
+ private var screenSection: some View {
+ VStack(alignment: .leading, spacing: 4) {
+ Picker("Optimise shared screen for", selection: $call.screenMode) {
+ ForEach(ScreenShareMode.allCases) { mode in
+ Text(mode.title).tag(mode)
+ }
+ }
+ .accessibilityIdentifier("screenMode")
+ #if os(macOS)
+ .pickerStyle(.menu)
+ #endif
+ Text(call.screenMode.explanation)
+ .font(.footnote).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ if call.sharingScreen {
+ Text(call.screenReport?.summary ?? "Screen: starting…")
+ .font(.callout).foregroundStyle(.secondary)
+ .fixedSize(horizontal: false, vertical: true)
+ .textSelection(.enabled)
+ .accessibilityIdentifier("screenReport")
+ }
+ }
+ }
+
private var audioReadout: some View {
VStack(alignment: .leading, spacing: 4) {
Text("Audio health").font(.headline)
diff --git a/native/apple/Sources/CallsKit/MacScreenCapture.swift b/native/apple/Sources/CallsKit/MacScreenCapture.swift
index 738539f..ee6d3c7 100644
--- a/native/apple/Sources/CallsKit/MacScreenCapture.swift
+++ b/native/apple/Sources/CallsKit/MacScreenCapture.swift
@@ -53,7 +53,7 @@
release()
throw error
}
- let frames = ScreenFrames(source: source)
+ let frames = ScreenFrames(source: source, queue: queue)
let stream = SCStream(filter: filter, configuration: Self.configuration(for: filter), delegate: self)
do {
try stream.addStreamOutput(frames, type: .screen, sampleHandlerQueue: queue)
@@ -71,6 +71,7 @@
ended = nil
let stream = self.stream
self.stream = nil
+ frames?.stop()
frames = nil
release()
stream?.stopCapture { _ in }
@@ -123,8 +124,12 @@
/// Full resolution up to 1920 on the long side, at up to 30 frames a
/// second. Larger is sharper than any receiver will be shown it and
- /// costs the encoder for nothing; the source is a screencast, so
- /// under pressure WebRTC gives up frames before it gives up pixels.
+ /// costs the encoder for nothing. What gives way under pressure is the
+ /// "Optimise for" mode's (ScreenSendPolicy).
+ ///
+ /// Three surfaces in the queue, not five: a frame waiting behind two
+ /// others is already a tenth of a second late, and a screen that
+ /// arrives late is exactly the complaint this was tuned against.
static func configuration(for filter: SCContentFilter) -> SCStreamConfiguration {
let configuration = SCStreamConfiguration()
let rect = filter.contentRect
@@ -135,7 +140,7 @@
configuration.minimumFrameInterval = CMTime(value: 1, timescale: 30)
configuration.pixelFormat = kCVPixelFormatType_420YpCbCr8BiPlanarFullRange
configuration.showsCursor = true
- configuration.queueDepth = 5
+ configuration.queueDepth = 3
return configuration
}
}
@@ -198,13 +203,49 @@
/// Hands each complete frame to the source. Called on the capture's own
/// queue, never the main actor: a screen is thirty frames a second and
/// the main actor has an interface to draw.
+ ///
+ /// Two things beyond passing frames on:
+ ///
+ /// - A frame that reaches here more than a tenth of a second after it
+ /// was captured is dropped: the queue is behind, and a newer frame is
+ /// already waiting. Sending it would only add its lateness to the
+ /// picture everyone is watching.
+ /// - An idle screen sends nothing (ScreenCaptureKit reports only
+ /// changes), and the encoder only makes a keyframe when a frame comes
+ /// to encode. So a receiver that lost a packet on a still screen, or
+ /// joined during one, could wait on a broken or empty picture until
+ /// something moved. The last frame is sent again once a second while
+ /// nothing changes, which gives the encoder its chance.
private final class ScreenFrames: NSObject, SCStreamOutput, @unchecked Sendable {
private let source: RTCVideoSource
private let capturer: RTCVideoCapturer
+ private let queue: DispatchQueue
+ private var timer: DispatchSourceTimer?
+ // Only touched on `queue`. Holding the newest frame pins one of the
+ // capture's surfaces, but it is the one the encoder was just given
+ // and is usually still holding anyway.
+ private var last: CVPixelBuffer?
+ private var lastSent: UInt64 = 0
- init(source: RTCVideoSource) {
+ static let stale = 0.1
+ static let repeatAfter: UInt64 = 1_000_000_000
+
+ init(source: RTCVideoSource, queue: DispatchQueue) {
self.source = source
+ self.queue = queue
capturer = RTCVideoCapturer(delegate: source)
+ super.init()
+ let timer = DispatchSource.makeTimerSource(queue: queue)
+ timer.schedule(deadline: .now() + .milliseconds(500), repeating: .milliseconds(500), leeway: .milliseconds(50))
+ timer.setEventHandler { [weak self] in self?.repeatIfIdle() }
+ timer.resume()
+ self.timer = timer
+ }
+
+ func stop() {
+ timer?.cancel()
+ timer = nil
+ queue.async { self.last = nil }
}
func stream(
@@ -212,20 +253,38 @@
) {
guard type == .screen, sample.isValid, let pixels = sample.imageBuffer else { return }
// Only complete frames carry pixels. An idle screen sends status
- // frames and nothing else, and the receiver keeps the last picture,
- // which is what an idle screen looks like.
+ // frames and nothing else; the repeat below covers it.
guard
let attachments = CMSampleBufferGetSampleAttachmentsArray(sample, createIfNecessary: false)
as? [[SCStreamFrameInfo: Any]],
let raw = attachments.first?[.status] as? Int,
SCFrameStatus(rawValue: raw) == .complete
else { return }
- let time = CMTimeGetSeconds(sample.presentationTimeStamp)
+ let captured = CMTimeGetSeconds(sample.presentationTimeStamp)
+ let now = CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock()))
+ last = pixels
+ if now - captured > Self.stale { return }
+ deliver(pixels, at: captured)
+ }
+
+ private func repeatIfIdle() {
+ guard let last else { return }
+ let now = CMTimeGetSeconds(CMClockGetTime(CMClockGetHostTimeClock()))
+ let ns = UInt64(max(0, now) * 1_000_000_000)
+ guard ns >= lastSent, ns - lastSent >= Self.repeatAfter else { return }
+ deliver(last, at: now)
+ }
+
+ private func deliver(_ pixels: CVPixelBuffer, at seconds: Double) {
+ let ns = UInt64(max(0, seconds) * 1_000_000_000)
+ // Never backwards: a repeat stamped just after a real frame would
+ // otherwise be followed by an older one.
+ guard ns > lastSent else { return }
+ lastSent = ns
source.capturer(
capturer,
didCapture: RTCVideoFrame(
- buffer: RTCCVPixelBuffer(pixelBuffer: pixels), rotation: ._0,
- timeStampNs: Int64(time * 1_000_000_000)))
+ buffer: RTCCVPixelBuffer(pixelBuffer: pixels), rotation: ._0, timeStampNs: Int64(ns)))
}
}
#endif
diff --git a/native/apple/Sources/CallsKit/Media.swift b/native/apple/Sources/CallsKit/Media.swift
index bb0360b..a33cd0c 100644
--- a/native/apple/Sources/CallsKit/Media.swift
+++ b/native/apple/Sources/CallsKit/Media.swift
@@ -18,6 +18,12 @@ import LiveKitWebRTC
private var screenAttempt = 0
private var capture: VideoCapture?
private var peers: [String: MediaPeer] = [:]
+ /// What the screen is optimised for (ScreenSendPolicy), and the size
+ /// its source was last seen at, which motion mode scales down from.
+ var screenMode = ScreenShareMode.balanced {
+ didSet { applySendPolicy() }
+ }
+ private var screenSize = (width: 0, height: 0)
/// The audio device module and processing behind the factory. See CallAudio.
let audio: CallAudio
@@ -98,15 +104,14 @@ import LiveKitWebRTC
/// who changes their mind at the system's picker has shared nothing,
/// and nothing is added to any connection.
///
- /// The source is marked as a screencast, which is what tells WebRTC to
- /// keep resolution and give up frame rate under pressure — text that
- /// goes soft is unreadable, where text that updates a little late is
- /// not.
+ /// What gives way under pressure — resolution or frame rate — is the
+ /// "Optimise for" mode's, through sender parameters (ScreenSendPolicy).
func startScreen(_ capture: ScreenCapture, ended: @escaping @MainActor () -> Void) async throws {
guard localScreen == nil else { return }
screenAttempt += 1
let attempt = screenAttempt
- let source = factory.videoSource(forScreenCast: true)
+ screenSize = (0, 0)
+ let source = factory.videoSource(forScreenCast: ScreenSendPolicy.screencast(screenMode))
try await capture.start(source: source, ended: ended)
guard attempt == screenAttempt, localScreen == nil else {
capture.stop()
@@ -129,6 +134,21 @@ import LiveKitWebRTC
capture.stop()
}
+ /// Puts every connection's screen and camera senders where the policy
+ /// wants them; a no-op for any that already are.
+ func applySendPolicy() {
+ for peer in peers.values { peer.applySendPolicy() }
+ }
+ /// The screen's source size, as its statistics report it.
+ func screenSource(width: Int, height: Int) {
+ guard width > 0, height > 0, (width, height) != screenSize else { return }
+ screenSize = (width, height)
+ applySendPolicy()
+ }
+ var screenPlan: EncodingPlan {
+ ScreenSendPolicy.screen(screenMode, width: screenSize.width, height: screenSize.height)
+ }
+
func close() {
stopScreen()
closePeers()
@@ -245,6 +265,33 @@ import LiveKitWebRTC
}
return values
}
+
+ /// Caps every connection's bandwidth estimate, which is what a thin
+ /// uplink does to it. For the gate, which has no thin uplink to test on;
+ /// nil takes the cap off.
+ func limitUplink(_ bps: Int?) {
+ uplinkLimit = bps
+ for peer in peers.values { peer.limitUplink(bps) }
+ }
+ private(set) var uplinkLimit: Int?
+
+ /// This app's screen on the way out, one report per connection sending
+ /// it (see ScreenSendReport).
+ func screenReports() async -> [String: ScreenSendReport] {
+ var reports: [String: ScreenSendReport] = [:]
+ for (id, peer) in peers {
+ guard let pc = peer.pc, let mid = peer.ownScreenMid else { continue }
+ let report: RTCStatisticsReport = await withCheckedContinuation { c in
+ pc.statistics { c.resume(returning: $0) }
+ }
+ let flat = report.statistics.mapValues { (type: $0.type, values: $0.values as [String: Any]) }
+ if var read = ScreenSendReport.read(flat, mid: mid) {
+ read.timestamp = report.timestamp_us / 1_000_000
+ reports[id] = read
+ }
+ }
+ return reports
+ }
}
/// A transceiver direction as SDP spells it, which is how the classifier,
@@ -315,6 +362,7 @@ private func sdpDirection(_ direction: RTCRtpTransceiverDirection) -> String {
if let video = engine.localVideo {
sentVideo = pc?.add(video, streamIds: ["calls"]) != nil
}
+ if let limit = engine.uplinkLimit { limitUplink(limit) }
// A connection built while this app is already sharing gets the
// screen with everything else: someone joining mid-share sees it.
// Last, as in the browser, so the camera and microphone take the
@@ -342,6 +390,24 @@ private func sdpDirection(_ direction: RTCRtpTransceiverDirection) -> String {
setup.direction = .sendOnly
setup.streamIds = ["screen"]
screenTx = pc.addTransceiver(with: track, init: setup)
+ if let tx = screenTx, let engine {
+ try? tx.setCodecPreferences(
+ ScreenSendPolicy.preferringH264(engine.factory.rtpSenderCapabilities(forKind: "video").codecs),
+ error: ())
+ }
+ applySendPolicy()
+ }
+
+ /// The screen at its mode's parameters and the camera capped beside
+ /// it, or the camera back at its defaults when there is no screen.
+ /// Sender parameters only: nothing here renegotiates.
+ func applySendPolicy() {
+ guard let pc, !closed, let engine else { return }
+ if let screen = screenTx?.sender { screen.apply(engine.screenPlan) }
+ let camera = engine.localVideo?.trackId
+ if let sender = pc.senders.first(where: { $0.track != nil && $0.track?.trackId == camera }) {
+ sender.apply(ScreenSendPolicy.camera(sharing: screenTx != nil))
+ }
}
/// Ends this connection's screen m-line. Stopping the transceiver is
@@ -356,6 +422,7 @@ private func sdpDirection(_ direction: RTCRtpTransceiverDirection) -> String {
guard let tx = screenTx else { return }
screenTx = nil
tx.stopInternal()
+ applySendPolicy()
renegotiate()
}
@@ -387,6 +454,7 @@ private func sdpDirection(_ direction: RTCRtpTransceiverDirection) -> String {
private func settled() {
guard let pc, !closed, pc.signalingState == .stable else { return }
addScreen()
+ applySendPolicy()
if owesOffer {
owesOffer = false
renegotiate()
@@ -394,6 +462,10 @@ private func sdpDirection(_ direction: RTCRtpTransceiverDirection) -> String {
}
func isScreen(mid: String) -> Bool { !mid.isEmpty && mid == screenMid }
func isOwnScreen(mid: String) -> Bool { !mid.isEmpty && mid == screenTx?.mid }
+ var ownScreenMid: String? { screenTx?.mid.isEmpty == false ? screenTx?.mid : nil }
+ func limitUplink(_ bps: Int?) {
+ _ = pc?.setBweMinBitrateBps(nil, currentBitrateBps: nil, maxBitrateBps: bps.map { NSNumber(value: $0) })
+ }
/// The `share` message: on draws the captioned tile ahead of the media,
/// off is one of §5.4's two endings.
diff --git a/native/apple/Sources/CallsKit/ScreenSendPolicy.swift b/native/apple/Sources/CallsKit/ScreenSendPolicy.swift
new file mode 100644
index 0000000..b159dd1
--- /dev/null
+++ b/native/apple/Sources/CallsKit/ScreenSendPolicy.swift
@@ -0,0 +1,181 @@
+import Foundation
+import LiveKitWebRTC
+
+/// What a shared screen is for, which decides what WebRTC gives up first
+/// when the uplink or the encoder cannot carry everything.
+///
+/// A screencast source on its own keeps every pixel and drops frames, which
+/// is right for a document and exactly wrong for someone scrolling,
+/// dragging or playing video: the far side sees a slideshow of a screen
+/// that is moving ("the screenshare isn't keeping up", 2026-09-24).
+public enum ScreenShareMode: String, CaseIterable, Identifiable, Sendable {
+ /// Keeps it reasonably sharp while tracking motion. The default.
+ case balanced
+ /// Keeps full resolution and lets the frame rate fall: slides, code, text.
+ case detail
+ /// Keeps the frame rate and lets resolution fall, to 1280 at most.
+ case motion
+
+ public var id: String { rawValue }
+
+ public var title: String {
+ switch self {
+ case .balanced: return "Balanced"
+ case .detail: return "Text and detail"
+ case .motion: return "Motion and video"
+ }
+ }
+
+ public var explanation: String {
+ switch self {
+ case .balanced: return "Keeps up with scrolling and moving windows, at full sharpness when the connection allows."
+ case .detail: return "Keeps small text sharp. Movement may look jerky."
+ case .motion: return "Keeps movement smooth. Text may look softer."
+ }
+ }
+
+ static let key = "screenShareMode"
+
+ /// The remembered choice, balanced until someone picks another.
+ public static var remembered: ScreenShareMode {
+ get { UserDefaults.standard.string(forKey: key).flatMap(Self.init(rawValue:)) ?? .balanced }
+ set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
+ }
+}
+
+/// One sender's encoding, as the policy wants it. Nil means the encoder's
+/// own default, and applying nil puts that default back.
+struct EncodingPlan: Equatable {
+ enum Degradation: Equatable { case maintainResolution, maintainFramerate, balanced }
+
+ var maxBitrate: Int?
+ var maxFramerate: Int?
+ var scaleResolutionDownBy: Double?
+ /// WebRTC's bitrate priority: 1 is everyone's default, 4 is "high".
+ var bitratePriority = 1.0
+ var highPriority = false
+ var degradation: Degradation?
+}
+
+/// The sender parameters for a screen and for the camera beside it.
+///
+/// Measured on 2026-09-24 with the native/browser motion gate (a 1080p
+/// page scrolling at 30 fps with a window dragged across it, H.264 on the
+/// Mac's hardware encoder, received in Chromium), frames a second arriving
+/// with the uplink open / capped at 2.5 / 1.5 / 1.0 Mbit/s:
+///
+/// before (screencast, no parameters) 26 / 22 / 2 / 1 at 1920×1080
+/// detail (maintain resolution, 1920) 27 / 21 / 13 / 2 at 1920×1080
+/// balanced (maintain frame rate, 1920) 30 / 30 / 19 / 25 at 960–320 wide
+/// motion (not screencast, 1280) 30 / 30 / 30 / 23 at 640–320 wide
+///
+/// WebRTC's own "balanced" degradation gave 7 / 2 / 2 / 1.5 on the same
+/// source, which is why the balanced mode does not use it. The same page
+/// on software VP8 managed 2 fps even on the open link (80 ms a frame to
+/// encode), which is why the screen asks for H.264 first.
+///
+/// Sender parameters and nothing else, so none of it touches SDP: a mode
+/// changed mid-share, the camera capped and restored, all without a
+/// renegotiation for perfect negotiation to wobble over (see room.js's
+/// tuneAudioSender for the same reasoning on the browser side).
+enum ScreenSendPolicy {
+ /// A screen longer than this on its long side is scaled down to it in
+ /// motion mode: 720p's worth of pixels is what 30 fps fits in.
+ static let motionLongestSide = 1280.0
+ /// And never sent larger than this in any mode (the Mac's capture is
+ /// already fitted to it; this holds for anything else that shares).
+ static let longestSide = 1920.0
+
+ /// The camera's ceiling while a screen is going out: the screen is what
+ /// everyone is looking at, and the camera is a thumbnail beside it.
+ static let sharingCameraBitrate = 500_000
+ static let sharingCameraHeight = 360.0
+
+ /// Whether the source is marked as a screencast. Motion is not: a
+ /// screencast is encoded as screen content, which is tuned for
+ /// sharpness, and in the measurement above the plain source held 30 fps
+ /// on a thinner link than the screencast did.
+ static func screencast(_ mode: ScreenShareMode) -> Bool { mode != .motion }
+
+ /// The screen's encoding, for a source of the given size (zero while
+ /// it is not known yet, which leaves the scale alone).
+ static func screen(_ mode: ScreenShareMode, width: Int = 0, height: Int = 0) -> EncodingPlan {
+ let longest = Double(max(width, height))
+ func fit(_ side: Double) -> Double? {
+ longest > side ? longest / side : nil
+ }
+ switch mode {
+ case .detail:
+ return EncodingPlan(
+ maxBitrate: 3_500_000, maxFramerate: 30, scaleResolutionDownBy: fit(longestSide),
+ bitratePriority: 4, highPriority: true, degradation: .maintainResolution)
+ case .balanced:
+ // Full size while the link and the encoder can carry it, and a
+ // smaller picture rather than a slideshow when they cannot.
+ return EncodingPlan(
+ maxBitrate: 3_000_000, maxFramerate: 30, scaleResolutionDownBy: fit(longestSide),
+ bitratePriority: 4, highPriority: true, degradation: .maintainFramerate)
+ case .motion:
+ return EncodingPlan(
+ maxBitrate: 2_500_000, maxFramerate: 30, scaleResolutionDownBy: fit(motionLongestSide),
+ bitratePriority: 4, highPriority: true, degradation: .maintainFramerate)
+ }
+ }
+
+ /// The sender's codecs with H.264 first, the rest after it in their own
+ /// order: Apple's hardware encoder where the far side can take H.264,
+ /// and everything else still on offer where it cannot, so a browser
+ /// without H.264 negotiates what it always did. Held on the screen's
+ /// transceiver so a renegotiation the far side offers keeps it.
+ static func preferringH264(_ codecs: [RTCRtpCodecCapability]) -> [RTCRtpCodecCapability] {
+ codecs.filter { $0.name.uppercased() == "H264" } + codecs.filter { $0.name.uppercased() != "H264" }
+ }
+
+ /// The camera's encoding: capped while this app shares a screen, and
+ /// the encoder's own defaults again once it stops. `height` is the
+ /// camera's own frame height where known; the native camera is
+ /// captured nearest 640 wide, so 480 lines.
+ static func camera(sharing: Bool, height: Int = 480) -> EncodingPlan {
+ guard sharing else { return EncodingPlan() }
+ let scale = Double(height) > sharingCameraHeight ? Double(height) / sharingCameraHeight : nil
+ return EncodingPlan(maxBitrate: sharingCameraBitrate, scaleResolutionDownBy: scale)
+ }
+}
+
+extension RTCRtpSender {
+ /// Applies a plan to this sender's first encoding, and reports whether
+ /// anything changed. Reads the parameters fresh each time: WebRTC
+ /// refuses a set that did not come from the latest get.
+ @discardableResult func apply(_ plan: EncodingPlan) -> Bool {
+ let parameters = self.parameters
+ guard let encoding = parameters.encodings.first else { return false }
+ let before = describe(parameters)
+ encoding.maxBitrateBps = plan.maxBitrate.map { NSNumber(value: $0) }
+ encoding.maxFramerate = plan.maxFramerate.map { NSNumber(value: $0) }
+ encoding.scaleResolutionDownBy = NSNumber(value: plan.scaleResolutionDownBy ?? 1)
+ encoding.bitratePriority = plan.bitratePriority
+ encoding.networkPriority = plan.highPriority ? .high : .low
+ switch plan.degradation {
+ case .maintainResolution?:
+ parameters.degradationPreference = NSNumber(value: RTCDegradationPreference.maintainResolution.rawValue)
+ case .maintainFramerate?:
+ parameters.degradationPreference = NSNumber(value: RTCDegradationPreference.maintainFramerate.rawValue)
+ case .balanced?:
+ parameters.degradationPreference = NSNumber(value: RTCDegradationPreference.balanced.rawValue)
+ case nil:
+ parameters.degradationPreference = nil
+ }
+ guard describe(parameters) != before else { return false }
+ self.parameters = parameters
+ return true
+ }
+
+ private func describe(_ parameters: RTCRtpParameters) -> String {
+ guard let e = parameters.encodings.first else { return "" }
+ return [
+ e.maxBitrateBps?.stringValue, e.maxFramerate?.stringValue, e.scaleResolutionDownBy?.stringValue,
+ String(e.bitratePriority), String(e.networkPriority.rawValue),
+ parameters.degradationPreference?.stringValue,
+ ].map { $0 ?? "-" }.joined(separator: ",")
+ }
+}
diff --git a/native/apple/Sources/CallsKit/ScreenSendStats.swift b/native/apple/Sources/CallsKit/ScreenSendStats.swift
new file mode 100644
index 0000000..6bc8847
--- /dev/null
+++ b/native/apple/Sources/CallsKit/ScreenSendStats.swift
@@ -0,0 +1,182 @@
+import Foundation
+
+/// What this app's own screen is doing on the way out, read from one
+/// connection's statistics.
+///
+/// It exists because of a report on 2026-09-24 that "the screenshare
+/// isn't keeping up with what I'm doing on the screen", and because
+/// nothing in the app could say why: a slow screen can be the encoder
+/// giving up frames to keep resolution, a thin or still-ramping uplink,
+/// a relay in the path, or a software encoder that cannot keep up. These
+/// fields tell those apart, and `summary` says which in one line.
+public struct ScreenSendReport: Equatable, Sendable {
+ public var width = 0
+ public var height = 0
+ /// Frames a second leaving the encoder, as WebRTC measures it.
+ public var framesPerSecond = 0.0
+ /// Frames a second arriving from the capture (the media source), and
+ /// its size before any scaling.
+ public var capturedPerSecond = 0.0
+ public var sourceWidth = 0
+ public var sourceHeight = 0
+ public var framesSent: UInt64 = 0
+ public var framesEncoded: UInt64 = 0
+ public var framesCaptured: UInt64 = 0
+ /// "none", "bandwidth", "cpu" or "other", as WebRTC names them.
+ public var limitation = ""
+ /// Seconds spent under each limitation since the stream began.
+ public var limitationSeconds: [String: Double] = [:]
+ public var targetBitrate: UInt64 = 0
+ public var bytesSent: UInt64 = 0
+ /// When the counters were read, in seconds, for turning bytes into a rate.
+ public var timestamp = 0.0
+ /// The encoder behind the stream, e.g. "VideoToolbox" or "libvpx".
+ public var encoder = ""
+ /// The codec's MIME type, e.g. "video/H264".
+ public var codec = ""
+ public var totalEncodeTime = 0.0
+ public var packetsLost: Int64 = 0
+ public var roundTripTime = 0.0
+ /// The selected path: "host", "srflx", "prflx" or "relay". A relay on
+ /// either end is reported as relay, because that is the extra hop.
+ public var path = ""
+ /// Bits a second actually sent, from the byte count between two reads.
+ /// Zero until there have been two.
+ public var sentBitrate: UInt64 = 0
+
+ public init() {}
+
+ /// Milliseconds the encoder took per frame, on average.
+ public var encodeMsPerFrame: Double {
+ framesEncoded == 0 ? 0 : totalEncodeTime * 1000 / Double(framesEncoded)
+ }
+
+ /// The rate from `previous` to this read, when both are the same stream.
+ public func measured(since previous: ScreenSendReport?) -> ScreenSendReport {
+ var next = self
+ guard let previous, timestamp > previous.timestamp, bytesSent >= previous.bytesSent else {
+ return next
+ }
+ next.sentBitrate = UInt64(
+ (Double(bytesSent - previous.bytesSent) * 8 / (timestamp - previous.timestamp)).rounded())
+ return next
+ }
+
+ /// Of several connections, the one a person would notice: the slowest.
+ public static func worst(_ reports: [ScreenSendReport]) -> ScreenSendReport? {
+ reports.min { $0.framesPerSecond < $1.framesPerSecond }
+ }
+
+ /// One line for the device panel, e.g.
+ /// "Screen: 1920×1080 at 8 fps, limited by bandwidth, 1.2 Mbit/s, relay".
+ public var summary: String {
+ if width == 0 || height == 0 { return "Screen: starting…" }
+ var parts = ["Screen: \(width)×\(height) at \(Int(framesPerSecond.rounded())) fps"]
+ switch limitation {
+ case "bandwidth": parts.append("limited by bandwidth")
+ case "cpu": parts.append("limited by the processor")
+ case "other": parts.append("limited")
+ default: break
+ }
+ let rate = sentBitrate > 0 ? sentBitrate : targetBitrate
+ if rate > 0 { parts.append(Self.megabits(rate)) }
+ if !path.isEmpty { parts.append(path == "relay" ? "relay" : "direct") }
+ var line = parts.joined(separator: ", ")
+ if !encoder.isEmpty { line += " (\(Self.encoderName(encoder, codec: codec)))" }
+ return line
+ }
+
+ static func megabits(_ bps: UInt64) -> String {
+ let mbit = Double(bps) / 1_000_000
+ return mbit >= 1 ? String(format: "%.1f Mbit/s", mbit) : "\(Int((Double(bps) / 1000).rounded())) kbit/s"
+ }
+
+ /// "VideoToolbox" means the Mac's hardware encoder, which is the thing
+ /// worth saying; anything else is software.
+ static func encoderName(_ implementation: String, codec: String) -> String {
+ let name = codec.split(separator: "/").last.map(String.init) ?? ""
+ let hardware = implementation.contains("VideoToolbox")
+ return [name, hardware ? "hardware" : "software"].filter { !$0.isEmpty }.joined(separator: " ")
+ }
+
+ /// A limitation as a number, for the gate's integer statistics.
+ public var limitationCode: UInt64 {
+ ["none": 0, "cpu": 1, "bandwidth": 2, "other": 3][limitation] ?? 0
+ }
+ /// The path as a number, for the same: 1 host, 2 srflx, 3 prflx, 4 relay.
+ public var pathCode: UInt64 {
+ ["host": 1, "srflx": 2, "prflx": 3, "relay": 4][path] ?? 0
+ }
+ public var hardwareEncoder: Bool { encoder.contains("VideoToolbox") }
+}
+
+extension ScreenSendReport {
+ /// Reads one connection's statistics for the outbound stream on `mid`.
+ /// `stats` is the report as WebRTC gives it, flattened: id to type and
+ /// values. Nil when that stream is not in it (not negotiated yet).
+ static func read(_ stats: [String: (type: String, values: [String: Any])], mid: String)
+ -> ScreenSendReport?
+ {
+ guard !mid.isEmpty,
+ let (outboundId, outbound) = stats.first(where: {
+ $0.value.type == "outbound-rtp" && ($0.value.values["mid"] as? String) == mid
+ }).map({ ($0.key, $0.value.values) })
+ else { return nil }
+ var r = ScreenSendReport()
+ r.width = int(outbound["frameWidth"])
+ r.height = int(outbound["frameHeight"])
+ r.framesPerSecond = double(outbound["framesPerSecond"])
+ r.framesSent = UInt64(int(outbound["framesSent"]))
+ r.framesEncoded = UInt64(int(outbound["framesEncoded"]))
+ r.limitation = outbound["qualityLimitationReason"] as? String ?? ""
+ if let durations = outbound["qualityLimitationDurations"] as? [String: Any] {
+ r.limitationSeconds = durations.mapValues { double($0) }
+ }
+ r.targetBitrate = UInt64(int(outbound["targetBitrate"]))
+ r.bytesSent = UInt64(int(outbound["bytesSent"]))
+ r.encoder = outbound["encoderImplementation"] as? String ?? ""
+ r.totalEncodeTime = double(outbound["totalEncodeTime"])
+ if let source = outbound["mediaSourceId"] as? String, let media = stats[source]?.values {
+ r.framesCaptured = UInt64(int(media["frames"]))
+ r.capturedPerSecond = double(media["framesPerSecond"])
+ r.sourceWidth = int(media["width"])
+ r.sourceHeight = int(media["height"])
+ }
+ if let codec = outbound["codecId"] as? String {
+ r.codec = stats[codec]?.values["mimeType"] as? String ?? ""
+ }
+ if let remote = stats.first(where: {
+ $0.value.type == "remote-inbound-rtp" && ($0.value.values["localId"] as? String) == outboundId
+ })?.value.values {
+ r.packetsLost = Int64(int(remote["packetsLost"]))
+ r.roundTripTime = double(remote["roundTripTime"])
+ }
+ r.path = path(stats)
+ return r
+ }
+
+ /// The selected candidate pair's kind of path.
+ static func path(_ stats: [String: (type: String, values: [String: Any])]) -> String {
+ var pairId = stats.values.first { $0.type == "transport" }?.values["selectedCandidatePairId"]
+ as? String
+ if pairId == nil {
+ pairId = stats.first {
+ $0.value.type == "candidate-pair" && ($0.value.values["nominated"] as? Bool ?? false)
+ && ($0.value.values["state"] as? String) == "succeeded"
+ }?.key
+ }
+ guard let pairId, let pair = stats[pairId]?.values else { return "" }
+ let kinds = ["localCandidateId", "remoteCandidateId"].compactMap { key in
+ (pair[key] as? String).flatMap { stats[$0]?.values["candidateType"] as? String }
+ }
+ if kinds.contains("relay") { return "relay" }
+ return kinds.first ?? ""
+ }
+
+ private static func int(_ value: Any?) -> Int {
+ (value as? NSNumber)?.intValue ?? (value as? Int) ?? 0
+ }
+ private static func double(_ value: Any?) -> Double {
+ (value as? NSNumber)?.doubleValue ?? (value as? Double) ?? 0
+ }
+}
diff --git a/native/apple/Tests/CallsKitTests/ScreenSendTests.swift b/native/apple/Tests/CallsKitTests/ScreenSendTests.swift
new file mode 100644
index 0000000..4ebdda3
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/ScreenSendTests.swift
@@ -0,0 +1,214 @@
+import LiveKitWebRTC
+import XCTest
+
+@testable import CallsKit
+
+/// The screen's sending half: what each "Optimise for" mode asks of the
+/// sender, the camera capped beside a share and given back after it, and
+/// the statistics line that says how the screen is actually leaving.
+/// room-share.test.mjs holds the browser's sharePlan and cameraCap to the
+/// same numbers; a change here belongs there too.
+final class ScreenSendTests: XCTestCase {
+ // MARK: - Policy
+
+ func testDetailKeepsResolutionAtTheHighestCeiling() {
+ let plan = ScreenSendPolicy.screen(.detail, width: 1920, height: 1080)
+ XCTAssertEqual(plan.degradation, .maintainResolution)
+ XCTAssertEqual(plan.maxBitrate, 3_500_000)
+ XCTAssertEqual(plan.maxFramerate, 30)
+ XCTAssertNil(plan.scaleResolutionDownBy, "a 1080p screen is sent whole")
+ XCTAssertTrue(plan.highPriority)
+ XCTAssertEqual(plan.bitratePriority, 4)
+ }
+
+ func testMotionKeepsTheFrameRateAt1280() {
+ let plan = ScreenSendPolicy.screen(.motion, width: 1920, height: 1080)
+ XCTAssertEqual(plan.degradation, .maintainFramerate)
+ XCTAssertEqual(plan.maxFramerate, 30)
+ XCTAssertEqual(plan.scaleResolutionDownBy ?? 0, 1.5, accuracy: 0.0001, "1920 down to 1280")
+ XCTAssertTrue(plan.highPriority)
+ // A portrait window is scaled by its long side, too.
+ XCTAssertEqual(
+ ScreenSendPolicy.screen(.motion, width: 1000, height: 1600).scaleResolutionDownBy ?? 0, 1.25,
+ accuracy: 0.0001)
+ // Something already small enough is left alone.
+ XCTAssertNil(ScreenSendPolicy.screen(.motion, width: 1280, height: 720).scaleResolutionDownBy)
+ }
+
+ func testBalancedIsTheDefaultAndKeepsTheFrameRateAtFullSize() {
+ XCTAssertEqual(ScreenShareMode.allCases.first, .balanced)
+ let plan = ScreenSendPolicy.screen(.balanced, width: 1920, height: 1080)
+ // Frames are held; resolution gives way only when it must, from full
+ // size rather than motion's 1280.
+ XCTAssertEqual(plan.degradation, .maintainFramerate)
+ XCTAssertNil(plan.scaleResolutionDownBy)
+ XCTAssertEqual(plan.maxBitrate, 3_000_000)
+ XCTAssertEqual(plan.maxFramerate, 30)
+ XCTAssertTrue(plan.highPriority)
+ XCTAssertTrue(ScreenSendPolicy.screencast(.balanced))
+ XCTAssertTrue(ScreenSendPolicy.screencast(.detail))
+ XCTAssertFalse(ScreenSendPolicy.screencast(.motion))
+ }
+
+ func testH264IsPreferredAndNothingIsDropped() {
+ let factory = RTCPeerConnectionFactory(
+ encoderFactory: RTCDefaultVideoEncoderFactory(), decoderFactory: RTCDefaultVideoDecoderFactory())
+ let codecs = factory.rtpSenderCapabilities(forKind: "video").codecs
+ let preferred = ScreenSendPolicy.preferringH264(codecs)
+ XCTAssertEqual(preferred.count, codecs.count, "every codec stays on offer")
+ XCTAssertEqual(preferred.first?.name, "H264")
+ XCTAssertTrue(preferred.contains { $0.name == "VP8" }, "a browser without H.264 still has VP8")
+ }
+
+ func testAScreenLargerThan1920IsNeverSentLarger() {
+ for mode in ScreenShareMode.allCases {
+ let plan = ScreenSendPolicy.screen(mode, width: 3840, height: 2160)
+ XCTAssertGreaterThanOrEqual(plan.scaleResolutionDownBy ?? 1, 2, "\(mode)")
+ }
+ }
+
+ func testAnUnknownSizeLeavesTheScaleAlone() {
+ for mode in ScreenShareMode.allCases {
+ XCTAssertNil(ScreenSendPolicy.screen(mode).scaleResolutionDownBy, "\(mode)")
+ }
+ }
+
+ func testTheCameraIsCappedWhileSharingAndGivenBackAfter() {
+ let capped = ScreenSendPolicy.camera(sharing: true, height: 480)
+ XCTAssertEqual(capped.maxBitrate, 500_000)
+ XCTAssertEqual(capped.scaleResolutionDownBy ?? 0, 480.0 / 360.0, accuracy: 0.0001)
+ XCTAssertFalse(capped.highPriority)
+ XCTAssertNil(ScreenSendPolicy.camera(sharing: true, height: 360).scaleResolutionDownBy)
+ XCTAssertEqual(ScreenSendPolicy.camera(sharing: false), EncodingPlan(), "the encoder's own defaults")
+ }
+
+ func testTheModeIsRemembered() {
+ let defaults = UserDefaults.standard
+ let saved = defaults.object(forKey: ScreenShareMode.key)
+ defer { defaults.set(saved, forKey: ScreenShareMode.key) }
+ defaults.removeObject(forKey: ScreenShareMode.key)
+ XCTAssertEqual(ScreenShareMode.remembered, .balanced)
+ ScreenShareMode.remembered = .motion
+ XCTAssertEqual(ScreenShareMode.remembered, .motion)
+ defaults.set("nonsense", forKey: ScreenShareMode.key)
+ XCTAssertEqual(ScreenShareMode.remembered, .balanced)
+ }
+
+ // MARK: - Applying it to a real sender
+
+ /// The plan reaches a real WebRTC sender, and the camera's defaults
+ /// really come back: set, read back, restored. No call is placed.
+ func testAPlanIsAppliedToASenderAndRestored() throws {
+ let factory = RTCPeerConnectionFactory()
+ let configuration = RTCConfiguration()
+ configuration.sdpSemantics = .unifiedPlan
+ let pc = try XCTUnwrap(
+ factory.peerConnection(
+ with: configuration,
+ constraints: RTCMediaConstraints(mandatoryConstraints: nil, optionalConstraints: nil),
+ delegate: nil))
+ defer { pc.close() }
+ let track = factory.videoTrack(with: factory.videoSource(), trackId: "camera")
+ let sender = try XCTUnwrap(pc.add(track, streamIds: ["calls"]))
+
+ XCTAssertTrue(sender.apply(ScreenSendPolicy.camera(sharing: true, height: 480)))
+ var encoding = try XCTUnwrap(sender.parameters.encodings.first)
+ XCTAssertEqual(encoding.maxBitrateBps?.intValue, 500_000)
+ XCTAssertEqual(encoding.scaleResolutionDownBy?.doubleValue ?? 0, 480.0 / 360.0, accuracy: 0.001)
+ XCTAssertFalse(
+ sender.apply(ScreenSendPolicy.camera(sharing: true, height: 480)), "the same plan twice is a no-op")
+
+ XCTAssertTrue(sender.apply(ScreenSendPolicy.camera(sharing: false)))
+ encoding = try XCTUnwrap(sender.parameters.encodings.first)
+ XCTAssertNil(encoding.maxBitrateBps)
+ XCTAssertEqual(encoding.scaleResolutionDownBy?.doubleValue ?? 1, 1, accuracy: 0.001)
+
+ XCTAssertTrue(sender.apply(ScreenSendPolicy.screen(.motion, width: 1920, height: 1080)))
+ let parameters = sender.parameters
+ encoding = try XCTUnwrap(parameters.encodings.first)
+ XCTAssertEqual(encoding.maxBitrateBps?.intValue, 2_500_000)
+ XCTAssertEqual(encoding.maxFramerate?.intValue, 30)
+ XCTAssertEqual(encoding.networkPriority, .high)
+ XCTAssertEqual(encoding.bitratePriority, 4)
+ XCTAssertEqual(
+ parameters.degradationPreference?.intValue, RTCDegradationPreference.maintainFramerate.rawValue)
+ }
+
+ // MARK: - The readout
+
+ private typealias Stats = [String: (type: String, values: [String: Any])]
+
+ private func stats(mid: String = "2", local: String = "host", remote: String = "host") -> Stats {
+ [
+ "OT1": (
+ "outbound-rtp",
+ [
+ "mid": mid, "frameWidth": 1920, "frameHeight": 1080, "framesPerSecond": 8.0,
+ "framesSent": 400, "framesEncoded": 400, "qualityLimitationReason": "bandwidth",
+ "qualityLimitationDurations": ["none": 2.0, "bandwidth": 30.0, "cpu": 0.0, "other": 0.0],
+ "targetBitrate": 1_200_000, "bytesSent": 5_000_000,
+ "encoderImplementation": "VideoToolbox", "totalEncodeTime": 8.0,
+ "mediaSourceId": "SV1", "codecId": "C1",
+ ]
+ ),
+ "OT0": ("outbound-rtp", ["mid": "1", "frameWidth": 640, "framesPerSecond": 30.0]),
+ "SV1": ("media-source", ["frames": 900, "framesPerSecond": 30.0, "width": 1920, "height": 1080]),
+ "C1": ("codec", ["mimeType": "video/H264"]),
+ "RI1": ("remote-inbound-rtp", ["localId": "OT1", "packetsLost": 12, "roundTripTime": 0.085]),
+ "T1": ("transport", ["selectedCandidatePairId": "P1"]),
+ "P1": ("candidate-pair", ["localCandidateId": "L1", "remoteCandidateId": "R1"]),
+ "L1": ("local-candidate", ["candidateType": local]),
+ "R1": ("remote-candidate", ["candidateType": remote]),
+ ]
+ }
+
+ func testTheScreensOwnStreamIsReadByMid() throws {
+ let r = try XCTUnwrap(ScreenSendReport.read(stats(), mid: "2"))
+ XCTAssertEqual(r.width, 1920)
+ XCTAssertEqual(r.height, 1080)
+ XCTAssertEqual(r.framesPerSecond, 8)
+ XCTAssertEqual(r.capturedPerSecond, 30, "the capture is fine; the sender is not keeping up")
+ XCTAssertEqual(r.framesCaptured, 900)
+ XCTAssertEqual(r.sourceWidth, 1920)
+ XCTAssertEqual(r.limitation, "bandwidth")
+ XCTAssertEqual(r.limitationSeconds["bandwidth"], 30)
+ XCTAssertEqual(r.targetBitrate, 1_200_000)
+ XCTAssertEqual(r.codec, "video/H264")
+ XCTAssertTrue(r.hardwareEncoder)
+ XCTAssertEqual(r.encodeMsPerFrame, 20, accuracy: 0.001)
+ XCTAssertEqual(r.packetsLost, 12)
+ XCTAssertEqual(r.roundTripTime, 0.085)
+ XCTAssertEqual(r.path, "host")
+ XCTAssertNil(ScreenSendReport.read(stats(), mid: "9"), "a stream not negotiated yet")
+ XCTAssertNil(ScreenSendReport.read(stats(), mid: ""))
+ }
+
+ func testARelayAtEitherEndIsTheRelay() {
+ XCTAssertEqual(ScreenSendReport.path(stats(local: "relay")), "relay")
+ XCTAssertEqual(ScreenSendReport.path(stats(remote: "relay")), "relay")
+ XCTAssertEqual(ScreenSendReport.path(stats(local: "srflx", remote: "host")), "srflx")
+ }
+
+ func testTheSummaryIsOneLine() throws {
+ var r = try XCTUnwrap(ScreenSendReport.read(stats(local: "relay"), mid: "2"))
+ XCTAssertEqual(
+ r.summary, "Screen: 1920×1080 at 8 fps, limited by bandwidth, 1.2 Mbit/s, relay (H264 hardware)")
+ var previous = r
+ previous.bytesSent = 4_900_000
+ previous.timestamp = 9
+ r.timestamp = 10
+ r = r.measured(since: previous)
+ XCTAssertEqual(r.sentBitrate, 800_000, "the rate actually sent wins over the target")
+ XCTAssertTrue(r.summary.contains("800 kbit/s"))
+ XCTAssertEqual(ScreenSendReport().summary, "Screen: starting…")
+ }
+
+ func testTheSlowestConnectionIsTheOneShown() {
+ var fast = ScreenSendReport()
+ fast.framesPerSecond = 29
+ var slow = ScreenSendReport()
+ slow.framesPerSecond = 4
+ XCTAssertEqual(ScreenSendReport.worst([fast, slow])?.framesPerSecond, 4)
+ XCTAssertNil(ScreenSendReport.worst([]))
+ }
+}
diff --git a/test/browser/native-motion.mjs b/test/browser/native-motion.mjs
new file mode 100644
index 0000000..f0ab777
--- /dev/null
+++ b/test/browser/native-motion.mjs
@@ -0,0 +1,165 @@
+// native-motion.mjs — the app shares a 1080p page scrolling at 30 fps
+// and the browser measures what actually arrives: frames a second,
+// frame size and freezes on the received screen, beside the app's own
+// account of its sender (encoder, limitation, bitrate, path).
+//
+// Written for a report on 2026-09-24 that a screen shared from the Mac
+// app "isn't keeping up with what I'm doing on the screen". It measures
+// each "Optimise for" mode twice — on an open local link, and with the
+// app's uplink estimate capped the way a thin home connection caps it —
+// and holds motion (and the balanced default) to a frame rate a scroll
+// can be followed at.
+//
+// Run by TestNativeMacScreenMotion (make native-browser). Environment:
+// MOTION_MODES comma-separated modes, default "balanced,detail,motion"
+// MOTION_UPLINKS comma-separated caps in bit/s, 0 for none, default "0,1500000"
+// MOTION_ASSERT "0" to measure without asserting
+import { chromium } from 'playwright';
+import { spawn } from 'node:child_process';
+import { createInterface } from 'node:readline';
+
+const [origin, room, binary] = process.argv.slice(2);
+const modes = (process.env.MOTION_MODES || 'balanced,detail,motion').split(',').filter(Boolean);
+const uplinks = (process.env.MOTION_UPLINKS || '0,1500000').split(',').map(Number);
+const asserting = process.env.MOTION_ASSERT !== '0';
+// A scroll followed at fewer frames than this reads as a slideshow.
+const MOTION_FLOOR_FPS = Number(process.env.MOTION_FLOOR_FPS || 15);
+
+const browser = await chromium.launch({ headless: true, args: [
+ '--use-fake-device-for-media-stream', '--use-fake-ui-for-media-stream',
+ '--autoplay-policy=no-user-gesture-required', '--disable-features=WebRtcHideLocalIpsWithMdns',
+ '--mute-audio',
+] });
+const context = await browser.newContext({ permissions: ['camera', 'microphone'] });
+await context.addCookies([{ name: process.env.CALLS_COOKIE_NAME, value: process.env.CALLS_COOKIE_A, url: origin }]);
+const page = await context.newPage();
+await page.addInitScript(() => {
+ const Native = window.RTCPeerConnection;
+ window.__nativeInteropPCs = new Set();
+ window.RTCPeerConnection = class extends Native {
+ constructor(...args) { super(...args); window.__nativeInteropPCs.add(this); }
+ };
+});
+let native;
+let failed = false;
+try {
+ await page.goto(`${origin}/m/${room}`);
+ await page.waitForFunction(() => !!window.meet);
+ native = spawn(binary, [], { stdio: ['pipe', 'pipe', 'pipe'] });
+ native.stdin.write(process.env.CALLS_NATIVE_INPUT + '\n');
+ let sample = null;
+ let exited = false;
+ native.on('exit', () => { exited = true; });
+ createInterface({ input: native.stdout }).on('line', (line) => {
+ try {
+ const value = JSON.parse(line);
+ if (value.stats) sample = value;
+ } catch {}
+ });
+ native.stderr.resume();
+ const wait = async (predicate, label, seconds = 35) => {
+ const until = Date.now() + seconds * 1000;
+ while (Date.now() < until) {
+ if (exited) throw new Error('native executable exited');
+ if (await predicate()) return;
+ await new Promise((r) => setTimeout(r, 250));
+ }
+ throw new Error(label + ': ' + JSON.stringify(sample));
+ };
+ const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
+ await wait(() => sample?.stats.framesDecoded > 15 && sample?.stats.videoBytesSent > 0, 'native and browser connect');
+
+ // The received screen's counters, found by the share tile's own track.
+ const received = () => page.evaluate(async () => {
+ const v = document.querySelector('#tiles figure.calls-tile-share video');
+ const track = v && v.srcObject ? v.srcObject.getVideoTracks()[0] : null;
+ if (!track) return null;
+ for (const pc of window.__nativeInteropPCs) {
+ if (pc.connectionState === 'closed') continue;
+ const stats = await pc.getStats();
+ for (const r of stats.values()) {
+ if (r.type === 'inbound-rtp' && r.kind === 'video' && r.trackIdentifier === track.id) {
+ return {
+ at: r.timestamp, decoded: r.framesDecoded || 0, width: r.frameWidth || 0, height: r.frameHeight || 0,
+ freezes: r.freezeCount || 0, frozen: r.totalFreezesDuration || 0,
+ jitter: r.jitterBufferDelay || 0, emitted: r.jitterBufferEmittedCount || 0,
+ codec: (stats.get(r.codecId) || {}).mimeType || '', decoder: r.decoderImplementation || '',
+ };
+ }
+ }
+ }
+ return null;
+ });
+
+ const results = [];
+ for (const mode of modes) {
+ native.stdin.write(`screen-mode ${mode}\n`);
+ native.stdin.write('screen-motion\n');
+ await wait(() => sample?.sharingScreen === true, 'native shares the moving screen');
+ await wait(async () => (await received())?.decoded > 30, 'the browser decodes the moving screen');
+ for (const uplink of uplinks) {
+ native.stdin.write(`uplink ${uplink}\n`);
+ // Long enough for the estimate to ramp or fall and the encoder to
+ // settle on it; then ten seconds measured.
+ await sleep(uplink ? 12 : 15);
+ const a = await received();
+ const s0 = sample.stats;
+ const p0 = sample.syntheticScreenFrames || 0;
+ await sleep(10);
+ const b = await received();
+ const s1 = sample.stats;
+ const p1 = sample.syntheticScreenFrames || 0;
+ const seconds = (b.at - a.at) / 1000;
+ const result = {
+ mode, uplink,
+ receivedFps: +((b.decoded - a.decoded) / seconds).toFixed(1),
+ receivedSize: `${b.width}x${b.height}`,
+ freezes: b.freezes - a.freezes,
+ frozenSeconds: +(b.frozen - a.frozen).toFixed(2),
+ jitterBufferMs: b.emitted > a.emitted ? Math.round(((b.jitter - a.jitter) / (b.emitted - a.emitted)) * 1000) : 0,
+ codec: b.codec, decoder: b.decoder,
+ sent: `${s1.screenSendWidth}x${s1.screenSendHeight}`,
+ sentFps: (s1.screenSendFpsTenths || 0) / 10,
+ producedFps: +((p1 - p0) / seconds).toFixed(1),
+ capturedFps: (s1.screenCapturedFpsTenths || 0) / 10,
+ encodedFps: +(((s1.screenFramesEncoded || 0) - (s0.screenFramesEncoded || 0)) / seconds).toFixed(1),
+ limitation: ['none', 'cpu', 'bandwidth', 'other'][s1.screenLimitation || 0],
+ sentKbps: Math.round((s1.screenSentBitrate || 0) / 1000),
+ targetKbps: Math.round((s1.screenTargetBitrate || 0) / 1000),
+ cameraKbps: Math.round(((s1.videoBytesSent || 0) - (s0.videoBytesSent || 0)) * 8 / seconds / 1000),
+ encodeMs: (s1.screenEncodeMsTenths || 0) / 10,
+ hardware: s1.screenHardwareEncoder === 1,
+ path: ['', 'host', 'srflx', 'prflx', 'relay'][s1.screenPath || 0],
+ };
+ results.push(result);
+ console.log('MEASURE ' + JSON.stringify(result));
+ }
+ native.stdin.write('uplink 0\n');
+ native.stdin.write('screen-off\n');
+ await wait(() => sample?.sharingScreen === false, 'native stops sharing');
+ await wait(async () => (await received()) === null, 'the browser drops the screen');
+ }
+
+ if (asserting) {
+ for (const r of results) {
+ if (r.mode === 'motion' || r.mode === 'balanced') {
+ if (r.receivedFps < MOTION_FLOOR_FPS) {
+ failed = true;
+ console.log(`FAIL ${r.mode} at uplink ${r.uplink || 'open'}: ${r.receivedFps} fps received, below ${MOTION_FLOOR_FPS}`);
+ } else {
+ console.log(`PASS ${r.mode} at uplink ${r.uplink || 'open'}: ${r.receivedFps} fps received at ${r.receivedSize}`);
+ }
+ }
+ if (r.mode === 'detail' && r.receivedSize !== '1920x1080') {
+ failed = true;
+ console.log(`FAIL detail at uplink ${r.uplink || 'open'} gave up resolution: ${r.receivedSize}`);
+ }
+ }
+ }
+ native.stdin.write('leave\n');
+ await sleep(1);
+} finally {
+ native?.kill('SIGTERM');
+ await browser.close();
+}
+if (failed) process.exit(1);
diff --git a/test/browser/room-lane.test.mjs b/test/browser/room-lane.test.mjs
index 345b452..d1c4d4f 100644
--- a/test/browser/room-lane.test.mjs
+++ b/test/browser/room-lane.test.mjs
@@ -898,7 +898,7 @@ test('a connection replaced under the negotiation is not offered to', async () =
// ---------------------------------------------------------------
const sharerBox = new Function(
- 'share', 'pcs', 'renderTiles', 'renderShare', 'broadcast', 'stopStream',
+ 'share', 'pcs', 'renderTiles', 'renderShare', 'broadcast', 'stopStream', 'tuneShare',
[lift(src, 'addShareTo'), lift(src, 'removeShareFrom'), lift(src, 'dropShare')].join('\n') +
'\nreturn { addShareTo, removeShareFrom, dropShare };',
);
@@ -930,9 +930,14 @@ function shareOver(signalingState) {
const entry = connection(signalingState);
const pcs = new Map([['peer1', entry]]);
const rendered = [];
+ // Sender tuning (the screen's parameters, the camera capped and given
+ // back) runs after each add and each removal; it is recorded with the
+ // transceiver the entry holds at that moment.
+ const tuned = [];
const box = sharerBox({ stream, track: display }, pcs,
- () => rendered.push('tiles'), () => rendered.push('share'), () => {}, () => {});
- return Object.assign(box, { entry, display, stream, rendered });
+ () => rendered.push('tiles'), () => rendered.push('share'), () => {}, () => {},
+ (e) => tuned.push(e.shareTx));
+ return Object.assign(box, { entry, display, stream, rendered, tuned });
}
test('a screen is added to a stable connection', () => {
@@ -942,6 +947,7 @@ test('a screen is added to a stable connection', () => {
assert.equal(s.entry.pc.added[0].track, s.display);
assert.deepEqual(s.entry.pc.added[0].init, { direction: 'sendonly', streams: [s.stream] });
assert.equal(s.entry.shareTx, s.entry.pc.added[0]);
+ assert.deepEqual(s.tuned, [s.entry.shareTx], 'the new screen is tuned once it exists');
});
test('a screen is not added to a connection that is mid-negotiation', () => {
@@ -983,7 +989,9 @@ test('stopping a screen stops its own transceiver and nothing else', () => {
// fake camera sender throws from replaceTrack, so reaching it at all
// fails loudly rather than quietly.
assert.deepEqual(s.entry.pc.removed, [], 'a sender was removed from the connection');
- assert.equal(s.entry.pc.camera.track.id, 'camera');
+ assert.equal(s.entry.pc.camera.track.id, 'camera'); // Retuned with no screen on it, which is what gives the camera its
+ // own defaults back (cameraCap(false) in room-share.test.mjs).
+ assert.deepEqual(s.tuned, [tx, null], 'the camera was not given back its defaults');
});
test('the fallback is only for a transceiver that cannot be stopped', () => {
diff --git a/test/browser/room-share.test.mjs b/test/browser/room-share.test.mjs
index aa20f7a..7be3622 100644
--- a/test/browser/room-share.test.mjs
+++ b/test/browser/room-share.test.mjs
@@ -878,3 +878,135 @@ test('a browser without requestVideoFrameCallback counts nothing here', () => {
assert.equal(video.pending, null);
assert.equal(frames.size, 0);
});
+
+// ---------------------------------------------------------------
+// Sending a screen: sharePlan, cameraCap, tuneSender, shareSendReport
+//
+// The native app's ScreenSendPolicy is sharePlan's twin (its tests are
+// native/apple/Tests/CallsKitTests/ScreenSendTests.swift), and the two
+// are meant to agree number for number.
+// ---------------------------------------------------------------
+
+const sharePlan = build('sharePlan');
+const cameraCap = build('cameraCap');
+const tuneSender = build('tuneSender');
+const shareSendReport = build('shareSendReport');
+
+test('the default is balanced, and every mode is offered by name', () => {
+ const m = /const SHARE_MODES = \[([\s\S]*?)\];/.exec(src);
+ assert.notEqual(m, null, 'SHARE_MODES is not in room.js any more');
+ const modes = [...m[1].matchAll(/\['(\w+)', '([^']+)'\]/g)].map((x) => [x[1], x[2]]);
+ assert.deepEqual(modes, [
+ ['balanced', 'Balanced'], ['detail', 'Text and detail'], ['motion', 'Motion and video'],
+ ]);
+});
+
+test('detail keeps every pixel, at the highest ceiling', () => {
+ const p = sharePlan('detail', 1920, 1080);
+ assert.equal(p.contentHint, 'detail');
+ assert.equal(p.degradationPreference, 'maintain-resolution');
+ assert.equal(p.encoding.maxBitrate, 3500000);
+ assert.equal(p.encoding.scaleResolutionDownBy, 1);
+ assert.equal(p.encoding.maxFramerate, 30);
+ assert.equal(p.encoding.priority, 'high');
+ assert.equal(p.encoding.networkPriority, 'high');
+});
+
+test('motion keeps the frame rate at 1280 on the long side', () => {
+ const p = sharePlan('motion', 1920, 1080);
+ assert.equal(p.contentHint, 'motion');
+ assert.equal(p.degradationPreference, 'maintain-framerate');
+ assert.equal(p.encoding.scaleResolutionDownBy, 1.5);
+ assert.equal(p.encoding.maxFramerate, 30);
+ assert.equal(p.encoding.priority, 'high');
+ assert.equal(sharePlan('motion', 1000, 1600).encoding.scaleResolutionDownBy, 1.25, 'portrait, by its long side');
+ assert.equal(sharePlan('motion', 1280, 720).encoding.scaleResolutionDownBy, 1);
+});
+
+test('balanced holds the frame rate from full size, and anything unknown is balanced', () => {
+ const b = sharePlan('balanced', 1920, 1080);
+ assert.equal(b.contentHint, 'detail', 'still encoded as screen content');
+ assert.equal(b.degradationPreference, 'maintain-framerate');
+ assert.equal(b.encoding.maxBitrate, 3000000);
+ assert.equal(b.encoding.scaleResolutionDownBy, 1, 'full size until the link says otherwise');
+ assert.equal(b.encoding.priority, 'high');
+ assert.deepEqual(sharePlan('nonsense', 1920, 1080), b);
+ assert.deepEqual(sharePlan(undefined, 1920, 1080), b);
+});
+
+test('a screen larger than 1920 is never sent larger, in any mode', () => {
+ for (const mode of ['balanced', 'detail', 'motion']) {
+ assert.ok(sharePlan(mode, 3840, 2160).encoding.scaleResolutionDownBy >= 2, mode);
+ }
+});
+
+test('an unknown size leaves the scale alone', () => {
+ for (const mode of ['balanced', 'detail', 'motion']) {
+ assert.equal(sharePlan(mode).encoding.scaleResolutionDownBy, 1, mode);
+ }
+});
+
+test('the camera is capped while sharing and given back after', () => {
+ assert.deepEqual(cameraCap(true, 720), { maxBitrate: 500000, scaleResolutionDownBy: 2 });
+ assert.deepEqual(cameraCap(true, 360), { maxBitrate: 500000, scaleResolutionDownBy: 1 });
+ assert.deepEqual(cameraCap(false, 720), { maxBitrate: undefined, scaleResolutionDownBy: 1 });
+});
+
+// A sender that keeps what it was given, as Chromium's does.
+function fakeSender(encodings = [{ active: true }]) {
+ const sender = {
+ params: { encodings, degradationPreference: undefined },
+ sets: 0,
+ getParameters() { return JSON.parse(JSON.stringify(this.params)); },
+ async setParameters(p) { this.sets += 1; this.params = p; },
+ };
+ return sender;
+}
+
+test('a plan reaches the sender, and giving the camera back removes the cap', async () => {
+ const camera = fakeSender();
+ tuneSender(camera, cameraCap(true, 720));
+ await new Promise((r) => setTimeout(r, 0));
+ assert.equal(camera.params.encodings[0].maxBitrate, 500000);
+ assert.equal(camera.params.encodings[0].scaleResolutionDownBy, 2);
+ tuneSender(camera, cameraCap(false, 720));
+ await new Promise((r) => setTimeout(r, 0));
+ assert.equal('maxBitrate' in camera.params.encodings[0], false, 'undefined deletes, never zero');
+ assert.equal(camera.params.encodings[0].scaleResolutionDownBy, 1);
+ assert.equal(camera.params.encodings[0].active, true, 'the rest of the encoding is left alone');
+
+ const screen = fakeSender();
+ const plan = sharePlan('motion', 1920, 1080);
+ tuneSender(screen, plan.encoding, plan.degradationPreference);
+ await new Promise((r) => setTimeout(r, 0));
+ assert.equal(screen.params.degradationPreference, 'maintain-framerate');
+ assert.equal(screen.params.encodings[0].maxBitrate, 2500000);
+ assert.equal(screen.params.encodings[0].priority, 'high');
+});
+
+test('a sender with no encodings yet, or one that refuses, costs nothing', async () => {
+ const empty = fakeSender([]);
+ tuneSender(empty, cameraCap(true, 720));
+ await new Promise((r) => setTimeout(r, 0));
+ assert.equal(empty.sets, 0);
+ const refusing = fakeSender();
+ refusing.setParameters = () => Promise.reject(new Error('InvalidModificationError'));
+ tuneSender(refusing, cameraCap(true, 720));
+ await new Promise((r) => setTimeout(r, 0));
+ tuneSender(null, cameraCap(true, 720));
+ tuneSender({}, cameraCap(true, 720));
+});
+
+test('the screen is read off its own outbound report, by mid', () => {
+ const reports = [
+ { mid: '1', frameWidth: 640, frameHeight: 480, framesPerSecond: 30 },
+ { mid: '2', frameWidth: 1920, frameHeight: 1080, framesPerSecond: 8, qualityLimitationReason: 'bandwidth',
+ targetBitrate: 1200000, encoderImplementation: 'OpenH264', framesEncoded: 400 },
+ ];
+ assert.deepEqual(shareSendReport(reports, '2'), {
+ width: 1920, height: 1080, fps: 8, limitation: 'bandwidth', targetBitrate: 1200000,
+ encoder: 'OpenH264', framesEncoded: 400,
+ });
+ assert.equal(shareSendReport(reports, ''), null, 'not sharing on this connection');
+ assert.equal(shareSendReport(reports, '9'), null, 'not negotiated yet');
+});