| 1 | import Foundation |
| 2 | |
| 3 | /// What this app's own screen is doing on the way out, read from one |
| 4 | /// connection's statistics. |
| 5 | /// |
| 6 | /// It exists because of a report on 2026-09-24 that "the screenshare |
| 7 | /// isn't keeping up with what I'm doing on the screen", and because |
| 8 | /// nothing in the app could say why: a slow screen can be the encoder |
| 9 | /// giving up frames to keep resolution, a thin or still-ramping uplink, |
| 10 | /// a relay in the path, or a software encoder that cannot keep up. These |
| 11 | /// fields tell those apart, and `summary` says which in one line. |
| 12 | public struct ScreenSendReport: Equatable, Sendable { |
| 13 | public var width = 0 |
| 14 | public var height = 0 |
| 15 | /// Frames a second leaving the encoder, as WebRTC measures it. |
| 16 | public var framesPerSecond = 0.0 |
| 17 | /// Frames a second arriving from the capture (the media source), and |
| 18 | /// its size before any scaling. |
| 19 | public var capturedPerSecond = 0.0 |
| 20 | public var sourceWidth = 0 |
| 21 | public var sourceHeight = 0 |
| 22 | public var framesSent: UInt64 = 0 |
| 23 | public var framesEncoded: UInt64 = 0 |
| 24 | public var framesCaptured: UInt64 = 0 |
| 25 | /// Keyframes the encoder has made, and the keyframe requests (PLI and |
| 26 | /// FIR) that have come back from the receiver for this stream. |
| 27 | public var keyFramesEncoded: UInt64 = 0 |
| 28 | public var keyframeRequests: UInt64 = 0 |
| 29 | /// "none", "bandwidth", "cpu" or "other", as WebRTC names them. |
| 30 | public var limitation = "" |
| 31 | /// Seconds spent under each limitation since the stream began. |
| 32 | public var limitationSeconds: [String: Double] = [:] |
| 33 | public var targetBitrate: UInt64 = 0 |
| 34 | public var bytesSent: UInt64 = 0 |
| 35 | /// When the counters were read, in seconds, for turning bytes into a rate. |
| 36 | public var timestamp = 0.0 |
| 37 | /// The encoder behind the stream, e.g. "VideoToolbox" or "libvpx". |
| 38 | public var encoder = "" |
| 39 | /// The codec's MIME type, e.g. "video/H264". |
| 40 | public var codec = "" |
| 41 | public var totalEncodeTime = 0.0 |
| 42 | public var packetsLost: Int64 = 0 |
| 43 | public var roundTripTime = 0.0 |
| 44 | /// The selected path: "host", "srflx", "prflx" or "relay". A relay on |
| 45 | /// either end is reported as relay, because that is the extra hop. |
| 46 | public var path = "" |
| 47 | /// Bits a second actually sent, from the byte count between two reads. |
| 48 | /// Zero until there have been two. |
| 49 | public var sentBitrate: UInt64 = 0 |
| 50 | |
| 51 | public init() {} |
| 52 | |
| 53 | /// Milliseconds the encoder took per frame, on average. |
| 54 | public var encodeMsPerFrame: Double { |
| 55 | framesEncoded == 0 ? 0 : totalEncodeTime * 1000 / Double(framesEncoded) |
| 56 | } |
| 57 | |
| 58 | /// The rate from `previous` to this read, when both are the same stream. |
| 59 | public func measured(since previous: ScreenSendReport?) -> ScreenSendReport { |
| 60 | var next = self |
| 61 | guard let previous, timestamp > previous.timestamp, bytesSent >= previous.bytesSent else { |
| 62 | return next |
| 63 | } |
| 64 | next.sentBitrate = UInt64( |
| 65 | (Double(bytesSent - previous.bytesSent) * 8 / (timestamp - previous.timestamp)).rounded()) |
| 66 | return next |
| 67 | } |
| 68 | |
| 69 | /// Of several connections, the one a person would notice: the slowest. |
| 70 | public static func worst(_ reports: [ScreenSendReport]) -> ScreenSendReport? { |
| 71 | reports.min { $0.framesPerSecond < $1.framesPerSecond } |
| 72 | } |
| 73 | |
| 74 | /// One line for the device panel, e.g. |
| 75 | /// "Screen: 1920×1080 at 8 fps, limited by bandwidth, 1.2 Mbit/s, relay". |
| 76 | public var summary: String { |
| 77 | if width == 0 || height == 0 { return "Screen: starting…" } |
| 78 | var parts = ["Screen: \(width)×\(height) at \(Int(framesPerSecond.rounded())) fps"] |
| 79 | switch limitation { |
| 80 | case "bandwidth": parts.append("limited by bandwidth") |
| 81 | case "cpu": parts.append("limited by the processor") |
| 82 | case "other": parts.append("limited") |
| 83 | default: break |
| 84 | } |
| 85 | let rate = sentBitrate > 0 ? sentBitrate : targetBitrate |
| 86 | if rate > 0 { parts.append(Self.megabits(rate)) } |
| 87 | if !path.isEmpty { parts.append(path == "relay" ? "relay" : "direct") } |
| 88 | var line = parts.joined(separator: ", ") |
| 89 | if !encoder.isEmpty { line += " (\(Self.encoderName(encoder, codec: codec)))" } |
| 90 | return line |
| 91 | } |
| 92 | |
| 93 | /// The second line, under `summary`, while sharing: what the encoder |
| 94 | /// and the watchdog are doing, e.g. "Encoded 29 fps, sent 29 fps, |
| 95 | /// keyframes 4 (3 asked for); watchdog: watching". |
| 96 | public func detail(since previous: ScreenSendReport?, watchdog: String) -> String { |
| 97 | var encodedRate = framesPerSecond |
| 98 | var sentRate = framesPerSecond |
| 99 | if let previous, timestamp > previous.timestamp, framesEncoded >= previous.framesEncoded, |
| 100 | framesSent >= previous.framesSent |
| 101 | { |
| 102 | let seconds = timestamp - previous.timestamp |
| 103 | encodedRate = Double(framesEncoded - previous.framesEncoded) / seconds |
| 104 | sentRate = Double(framesSent - previous.framesSent) / seconds |
| 105 | } |
| 106 | var line = |
| 107 | "Encoded \(Int(encodedRate.rounded())) fps, sent \(Int(sentRate.rounded())) fps, keyframes \(keyFramesEncoded)" |
| 108 | + " (\(keyframeRequests) asked for)" |
| 109 | if !watchdog.isEmpty { line += "; watchdog: \(watchdog)" } |
| 110 | return line |
| 111 | } |
| 112 | |
| 113 | static func megabits(_ bps: UInt64) -> String { |
| 114 | let mbit = Double(bps) / 1_000_000 |
| 115 | return mbit >= 1 ? String(format: "%.1f Mbit/s", mbit) : "\(Int((Double(bps) / 1000).rounded())) kbit/s" |
| 116 | } |
| 117 | |
| 118 | /// "VideoToolbox" means the Mac's hardware encoder, which is the thing |
| 119 | /// worth saying; anything else is software. |
| 120 | static func encoderName(_ implementation: String, codec: String) -> String { |
| 121 | let name = codec.split(separator: "/").last.map(String.init) ?? "" |
| 122 | let hardware = implementation.contains("VideoToolbox") |
| 123 | return [name, hardware ? "hardware" : "software"].filter { !$0.isEmpty }.joined(separator: " ") |
| 124 | } |
| 125 | |
| 126 | /// A limitation as a number, for the gate's integer statistics. |
| 127 | public var limitationCode: UInt64 { |
| 128 | ["none": 0, "cpu": 1, "bandwidth": 2, "other": 3][limitation] ?? 0 |
| 129 | } |
| 130 | /// The path as a number, for the same: 1 host, 2 srflx, 3 prflx, 4 relay. |
| 131 | public var pathCode: UInt64 { |
| 132 | ["host": 1, "srflx": 2, "prflx": 3, "relay": 4][path] ?? 0 |
| 133 | } |
| 134 | public var hardwareEncoder: Bool { encoder.contains("VideoToolbox") } |
| 135 | } |
| 136 | |
| 137 | extension ScreenSendReport { |
| 138 | /// Reads one connection's statistics for the outbound stream on `mid`. |
| 139 | /// `stats` is the report as WebRTC gives it, flattened: id to type and |
| 140 | /// values. Nil when that stream is not in it (not negotiated yet). |
| 141 | static func read(_ stats: [String: (type: String, values: [String: Any])], mid: String) |
| 142 | -> ScreenSendReport? |
| 143 | { |
| 144 | guard !mid.isEmpty, |
| 145 | let (outboundId, outbound) = stats.first(where: { |
| 146 | $0.value.type == "outbound-rtp" && ($0.value.values["mid"] as? String) == mid |
| 147 | }).map({ ($0.key, $0.value.values) }) |
| 148 | else { return nil } |
| 149 | var r = ScreenSendReport() |
| 150 | r.width = int(outbound["frameWidth"]) |
| 151 | r.height = int(outbound["frameHeight"]) |
| 152 | r.framesPerSecond = double(outbound["framesPerSecond"]) |
| 153 | r.framesSent = UInt64(int(outbound["framesSent"])) |
| 154 | r.framesEncoded = UInt64(int(outbound["framesEncoded"])) |
| 155 | r.keyFramesEncoded = UInt64(int(outbound["keyFramesEncoded"])) |
| 156 | r.keyframeRequests = UInt64(int(outbound["pliCount"]) + int(outbound["firCount"])) |
| 157 | r.limitation = outbound["qualityLimitationReason"] as? String ?? "" |
| 158 | if let durations = outbound["qualityLimitationDurations"] as? [String: Any] { |
| 159 | r.limitationSeconds = durations.mapValues { double($0) } |
| 160 | } |
| 161 | r.targetBitrate = UInt64(int(outbound["targetBitrate"])) |
| 162 | r.bytesSent = UInt64(int(outbound["bytesSent"])) |
| 163 | r.encoder = outbound["encoderImplementation"] as? String ?? "" |
| 164 | r.totalEncodeTime = double(outbound["totalEncodeTime"]) |
| 165 | if let source = outbound["mediaSourceId"] as? String, let media = stats[source]?.values { |
| 166 | r.framesCaptured = UInt64(int(media["frames"])) |
| 167 | r.capturedPerSecond = double(media["framesPerSecond"]) |
| 168 | r.sourceWidth = int(media["width"]) |
| 169 | r.sourceHeight = int(media["height"]) |
| 170 | } |
| 171 | if let codec = outbound["codecId"] as? String { |
| 172 | r.codec = stats[codec]?.values["mimeType"] as? String ?? "" |
| 173 | } |
| 174 | if let remote = stats.first(where: { |
| 175 | $0.value.type == "remote-inbound-rtp" && ($0.value.values["localId"] as? String) == outboundId |
| 176 | })?.value.values { |
| 177 | r.packetsLost = Int64(int(remote["packetsLost"])) |
| 178 | r.roundTripTime = double(remote["roundTripTime"]) |
| 179 | } |
| 180 | r.path = path(stats) |
| 181 | return r |
| 182 | } |
| 183 | |
| 184 | /// The selected candidate pair's kind of path. |
| 185 | static func path(_ stats: [String: (type: String, values: [String: Any])]) -> String { |
| 186 | var pairId = stats.values.first { $0.type == "transport" }?.values["selectedCandidatePairId"] |
| 187 | as? String |
| 188 | if pairId == nil { |
| 189 | pairId = stats.first { |
| 190 | $0.value.type == "candidate-pair" && ($0.value.values["nominated"] as? Bool ?? false) |
| 191 | && ($0.value.values["state"] as? String) == "succeeded" |
| 192 | }?.key |
| 193 | } |
| 194 | guard let pairId, let pair = stats[pairId]?.values else { return "" } |
| 195 | let kinds = ["localCandidateId", "remoteCandidateId"].compactMap { key in |
| 196 | (pair[key] as? String).flatMap { stats[$0]?.values["candidateType"] as? String } |
| 197 | } |
| 198 | if kinds.contains("relay") { return "relay" } |
| 199 | return kinds.first ?? "" |
| 200 | } |
| 201 | |
| 202 | private static func int(_ value: Any?) -> Int { |
| 203 | (value as? NSNumber)?.intValue ?? (value as? Int) ?? 0 |
| 204 | } |
| 205 | private static func double(_ value: Any?) -> Double { |
| 206 | (value as? NSNumber)?.doubleValue ?? (value as? Double) ?? 0 |
| 207 | } |
| 208 | } |
| 209 | |