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). public 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 most 16×16 blocks a frame from a source that is *not* marked as /// a screencast may have: H.264 level 3.1's 3,600, which is 1280×720. /// /// Chromium answers every H.264 offer at level 3.1, and the Mac's /// hardware encoder, as this build of WebRTC sets it up, holds a camera- /// like stream to the level it negotiated: a larger frame is refused by /// VideoToolbox (kVTParameterErr, -12902) and nothing is encoded at all. /// A screencast source is set up another way and is not held to it — /// measured 2026-09-28 at 1920×1246, steady. So this is the motion /// mode's cap, and the cap for any mode on a share that *started* in /// motion mode (its source cannot become a screencast later). /// /// It is Paul's freeze of 2026-09-28 ("after a while, my screen froze on /// her end"): a 16:10 display in motion mode is 1280×830 at its 1280 cap, /// 4,160 blocks. It went out at 960×620 while the link was still /// ramping, and the moment WebRTC let it up to full size, the encoder /// stopped — for eight or nine seconds at a time, until WebRTC's quality /// scaler stepped it down again, over and over; and for good if the mode /// was then switched to Text and detail, which does not let the size /// fall. A 16:9 page at 1280×720 is exactly 3,600, which is why the /// motion gate's synthetic 1080p page never met it. static let realtimeMacroblocks = 3600 /// Off only for the gate, which measures 0.4.1's sizing against it. @_spi(Fixtures) public static var levelCapped = true /// The screen's encoding, for a source of the given size (zero while /// it is not known yet, which leaves the scale alone). `screencast` is /// whether the source was made as one (see `realtimeMacroblocks`). static func screen( _ mode: ScreenShareMode, width: Int = 0, height: Int = 0, screencast: Bool? = nil ) -> EncodingPlan { let longest = Double(max(width, height)) let capped = levelCapped && !(screencast ?? Self.screencast(mode)) && width > 0 && height > 0 func fit(_ side: Double) -> Double? { var scale = longest > side ? longest / side : 1 if capped { // Down until the frame's blocks fit the level, as WebRTC will // size it: each side divided by the scale, rounded down. func blocks(_ s: Double) -> Int { ((Int(Double(width) / s) + 15) / 16) * ((Int(Double(height) / s) + 15) / 16) } while blocks(scale) > realtimeMacroblocks { scale *= 1.01 } } return scale > 1 ? scale : 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: ",") } }