oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import LiveKitWebRTC
3
4/// What a shared screen is for, which decides what WebRTC gives up first
5/// when the uplink or the encoder cannot carry everything.
6///
7/// A screencast source on its own keeps every pixel and drops frames, which
8/// is right for a document and exactly wrong for someone scrolling,
9/// dragging or playing video: the far side sees a slideshow of a screen
10/// that is moving ("the screenshare isn't keeping up", 2026-09-24).
11public enum ScreenShareMode: String, CaseIterable, Identifiable, Sendable {
12 /// Keeps it reasonably sharp while tracking motion. The default.
13 case balanced
14 /// Keeps full resolution and lets the frame rate fall: slides, code, text.
15 case detail
16 /// Keeps the frame rate and lets resolution fall, to 1280 at most.
17 case motion
18
19 public var id: String { rawValue }
20
21 public var title: String {
22 switch self {
23 case .balanced: return "Balanced"
24 case .detail: return "Text and detail"
25 case .motion: return "Motion and video"
26 }
27 }
28
29 public var explanation: String {
30 switch self {
31 case .balanced: return "Keeps up with scrolling and moving windows, at full sharpness when the connection allows."
32 case .detail: return "Keeps small text sharp. Movement may look jerky."
33 case .motion: return "Keeps movement smooth. Text may look softer."
34 }
35 }
36
37 static let key = "screenShareMode"
38
39 /// The remembered choice, balanced until someone picks another.
40 public static var remembered: ScreenShareMode {
41 get { UserDefaults.standard.string(forKey: key).flatMap(Self.init(rawValue:)) ?? .balanced }
42 set { UserDefaults.standard.set(newValue.rawValue, forKey: key) }
43 }
44}
45
46/// One sender's encoding, as the policy wants it. Nil means the encoder's
47/// own default, and applying nil puts that default back.
48struct EncodingPlan: Equatable {
49 enum Degradation: Equatable { case maintainResolution, maintainFramerate, balanced }
50
51 var maxBitrate: Int?
52 var maxFramerate: Int?
53 var scaleResolutionDownBy: Double?
54 /// WebRTC's bitrate priority: 1 is everyone's default, 4 is "high".
55 var bitratePriority = 1.0
56 var highPriority = false
57 var degradation: Degradation?
58}
59
60/// The sender parameters for a screen and for the camera beside it.
61///
62/// Measured on 2026-09-24 with the native/browser motion gate (a 1080p
63/// page scrolling at 30 fps with a window dragged across it, H.264 on the
64/// Mac's hardware encoder, received in Chromium), frames a second arriving
65/// with the uplink open / capped at 2.5 / 1.5 / 1.0 Mbit/s:
66///
67/// before (screencast, no parameters) 26 / 22 / 2 / 1 at 1920×1080
68/// detail (maintain resolution, 1920) 27 / 21 / 13 / 2 at 1920×1080
69/// balanced (maintain frame rate, 1920) 30 / 30 / 19 / 25 at 960–320 wide
70/// motion (not screencast, 1280) 30 / 30 / 30 / 23 at 640–320 wide
71///
72/// WebRTC's own "balanced" degradation gave 7 / 2 / 2 / 1.5 on the same
73/// source, which is why the balanced mode does not use it. The same page
74/// on software VP8 managed 2 fps even on the open link (80 ms a frame to
75/// encode), which is why the screen asks for H.264 first.
76///
77/// Sender parameters and nothing else, so none of it touches SDP: a mode
78/// changed mid-share, the camera capped and restored, all without a
79/// renegotiation for perfect negotiation to wobble over (see room.js's
80/// tuneAudioSender for the same reasoning on the browser side).
81public enum ScreenSendPolicy {
82 /// A screen longer than this on its long side is scaled down to it in
83 /// motion mode: 720p's worth of pixels is what 30 fps fits in.
84 static let motionLongestSide = 1280.0
85 /// And never sent larger than this in any mode (the Mac's capture is
86 /// already fitted to it; this holds for anything else that shares).
87 static let longestSide = 1920.0
88
89 /// The camera's ceiling while a screen is going out: the screen is what
90 /// everyone is looking at, and the camera is a thumbnail beside it.
91 static let sharingCameraBitrate = 500_000
92 static let sharingCameraHeight = 360.0
93
94 /// Whether the source is marked as a screencast. Motion is not: a
95 /// screencast is encoded as screen content, which is tuned for
96 /// sharpness, and in the measurement above the plain source held 30 fps
97 /// on a thinner link than the screencast did.
98 static func screencast(_ mode: ScreenShareMode) -> Bool { mode != .motion }
99
100 /// The most 16×16 blocks a frame from a source that is *not* marked as
101 /// a screencast may have: H.264 level 3.1's 3,600, which is 1280×720.
102 ///
103 /// Chromium answers every H.264 offer at level 3.1, and the Mac's
104 /// hardware encoder, as this build of WebRTC sets it up, holds a camera-
105 /// like stream to the level it negotiated: a larger frame is refused by
106 /// VideoToolbox (kVTParameterErr, -12902) and nothing is encoded at all.
107 /// A screencast source is set up another way and is not held to it —
108 /// measured 2026-09-28 at 1920×1246, steady. So this is the motion
109 /// mode's cap, and the cap for any mode on a share that *started* in
110 /// motion mode (its source cannot become a screencast later).
111 ///
112 /// It is Paul's freeze of 2026-09-28 ("after a while, my screen froze on
113 /// her end"): a 16:10 display in motion mode is 1280×830 at its 1280 cap,
114 /// 4,160 blocks. It went out at 960×620 while the link was still
115 /// ramping, and the moment WebRTC let it up to full size, the encoder
116 /// stopped — for eight or nine seconds at a time, until WebRTC's quality
117 /// scaler stepped it down again, over and over; and for good if the mode
118 /// was then switched to Text and detail, which does not let the size
119 /// fall. A 16:9 page at 1280×720 is exactly 3,600, which is why the
120 /// motion gate's synthetic 1080p page never met it.
121 static let realtimeMacroblocks = 3600
122 /// Off only for the gate, which measures 0.4.1's sizing against it.
123 @_spi(Fixtures) public static var levelCapped = true
124
125 /// The screen's encoding, for a source of the given size (zero while
126 /// it is not known yet, which leaves the scale alone). `screencast` is
127 /// whether the source was made as one (see `realtimeMacroblocks`).
128 static func screen(
129 _ mode: ScreenShareMode, width: Int = 0, height: Int = 0, screencast: Bool? = nil
130 ) -> EncodingPlan {
131 let longest = Double(max(width, height))
132 let capped = levelCapped && !(screencast ?? Self.screencast(mode)) && width > 0 && height > 0
133 func fit(_ side: Double) -> Double? {
134 var scale = longest > side ? longest / side : 1
135 if capped {
136 // Down until the frame's blocks fit the level, as WebRTC will
137 // size it: each side divided by the scale, rounded down.
138 func blocks(_ s: Double) -> Int {
139 ((Int(Double(width) / s) + 15) / 16) * ((Int(Double(height) / s) + 15) / 16)
140 }
141 while blocks(scale) > realtimeMacroblocks { scale *= 1.01 }
142 }
143 return scale > 1 ? scale : nil
144 }
145 switch mode {
146 case .detail:
147 return EncodingPlan(
148 maxBitrate: 3_500_000, maxFramerate: 30, scaleResolutionDownBy: fit(longestSide),
149 bitratePriority: 4, highPriority: true, degradation: .maintainResolution)
150 case .balanced:
151 // Full size while the link and the encoder can carry it, and a
152 // smaller picture rather than a slideshow when they cannot.
153 return EncodingPlan(
154 maxBitrate: 3_000_000, maxFramerate: 30, scaleResolutionDownBy: fit(longestSide),
155 bitratePriority: 4, highPriority: true, degradation: .maintainFramerate)
156 case .motion:
157 return EncodingPlan(
158 maxBitrate: 2_500_000, maxFramerate: 30, scaleResolutionDownBy: fit(motionLongestSide),
159 bitratePriority: 4, highPriority: true, degradation: .maintainFramerate)
160 }
161 }
162
163 /// The sender's codecs with H.264 first, the rest after it in their own
164 /// order: Apple's hardware encoder where the far side can take H.264,
165 /// and everything else still on offer where it cannot, so a browser
166 /// without H.264 negotiates what it always did. Held on the screen's
167 /// transceiver so a renegotiation the far side offers keeps it.
168 static func preferringH264(_ codecs: [RTCRtpCodecCapability]) -> [RTCRtpCodecCapability] {
169 codecs.filter { $0.name.uppercased() == "H264" } + codecs.filter { $0.name.uppercased() != "H264" }
170 }
171
172 /// The camera's encoding: capped while this app shares a screen, and
173 /// the encoder's own defaults again once it stops. `height` is the
174 /// camera's own frame height where known; the native camera is
175 /// captured nearest 640 wide, so 480 lines.
176 static func camera(sharing: Bool, height: Int = 480) -> EncodingPlan {
177 guard sharing else { return EncodingPlan() }
178 let scale = Double(height) > sharingCameraHeight ? Double(height) / sharingCameraHeight : nil
179 return EncodingPlan(maxBitrate: sharingCameraBitrate, scaleResolutionDownBy: scale)
180 }
181}
182
183extension RTCRtpSender {
184 /// Applies a plan to this sender's first encoding, and reports whether
185 /// anything changed. Reads the parameters fresh each time: WebRTC
186 /// refuses a set that did not come from the latest get.
187 @discardableResult func apply(_ plan: EncodingPlan) -> Bool {
188 let parameters = self.parameters
189 guard let encoding = parameters.encodings.first else { return false }
190 let before = describe(parameters)
191 encoding.maxBitrateBps = plan.maxBitrate.map { NSNumber(value: $0) }
192 encoding.maxFramerate = plan.maxFramerate.map { NSNumber(value: $0) }
193 encoding.scaleResolutionDownBy = NSNumber(value: plan.scaleResolutionDownBy ?? 1)
194 encoding.bitratePriority = plan.bitratePriority
195 encoding.networkPriority = plan.highPriority ? .high : .low
196 switch plan.degradation {
197 case .maintainResolution?:
198 parameters.degradationPreference = NSNumber(value: RTCDegradationPreference.maintainResolution.rawValue)
199 case .maintainFramerate?:
200 parameters.degradationPreference = NSNumber(value: RTCDegradationPreference.maintainFramerate.rawValue)
201 case .balanced?:
202 parameters.degradationPreference = NSNumber(value: RTCDegradationPreference.balanced.rawValue)
203 case nil:
204 parameters.degradationPreference = nil
205 }
206 guard describe(parameters) != before else { return false }
207 self.parameters = parameters
208 return true
209 }
210
211 private func describe(_ parameters: RTCRtpParameters) -> String {
212 guard let e = parameters.encodings.first else { return "" }
213 return [
214 e.maxBitrateBps?.stringValue, e.maxFramerate?.stringValue, e.scaleResolutionDownBy?.stringValue,
215 String(e.bitratePriority), String(e.networkPriority.rawValue),
216 parameters.degradationPreference?.stringValue,
217 ].map { $0 ?? "-" }.joined(separator: ",")
218 }
219}
220