| 1 | import CallsBroadcastKit |
| 2 | import CoreVideo |
| 3 | import Foundation |
| 4 | import os |
| 5 | import LiveKitWebRTC |
| 6 | |
| 7 | /// The iPhone's whole screen, arriving from the broadcast extension over |
| 8 | /// its socket (see CallsBroadcastKit), delivered into the call's screen |
| 9 | /// source. |
| 10 | /// |
| 11 | /// There is no picker to wait on by the time one of these exists: the |
| 12 | /// person chose to broadcast at the system's own sheet, and the extension |
| 13 | /// connecting is the proof. So `start` returns at once, and `ended` is the |
| 14 | /// extension going away — the broadcast stopped from Control Center or the |
| 15 | /// red status bar. `stop` closes the connection, which is how stopping in |
| 16 | /// the app reaches the system: the extension's next write fails and it |
| 17 | /// ends the broadcast. |
| 18 | @MainActor public final class BroadcastScreenCapture: ScreenCapture { |
| 19 | private let connection: BroadcastConnection |
| 20 | private var stopped = false |
| 21 | private var feed: StillScreenFeed? |
| 22 | /// Called after the share has ended on its own, once the call has heard. |
| 23 | /// The app uses it to leave a call it is only still in for the screen. |
| 24 | public var afterEnded: (@MainActor () -> Void)? |
| 25 | |
| 26 | public init(_ connection: BroadcastConnection) { self.connection = connection } |
| 27 | |
| 28 | public func start(source: RTCVideoSource, ended: @escaping @MainActor () -> Void) async throws { |
| 29 | let feed = StillScreenFeed(source: source) |
| 30 | self.feed = feed |
| 31 | connection.onFrame = { pixels, orientation in feed.frame(pixels, rotation: broadcastRotation(orientation)) } |
| 32 | connection.onClose = { [weak self] in |
| 33 | Task { @MainActor [weak self] in |
| 34 | guard let self, !self.stopped else { return } |
| 35 | self.stopped = true |
| 36 | ended() |
| 37 | self.afterEnded?() |
| 38 | } |
| 39 | } |
| 40 | connection.resume() |
| 41 | } |
| 42 | |
| 43 | public var counts: ScreenCaptureCounts { feed?.counts ?? ScreenCaptureCounts() } |
| 44 | |
| 45 | public func stop() { |
| 46 | stopped = true |
| 47 | feed?.stop() |
| 48 | feed = nil |
| 49 | connection.onClose = nil |
| 50 | connection.onFrame = nil |
| 51 | connection.close() |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | /// ReplayKit's orientation for a frame, as the rotation WebRTC should |
| 56 | /// apply to show it upright. ReplayKit says which way the *image* is |
| 57 | /// turned, as a CGImagePropertyOrientation: `left` (8) is a phone held in |
| 58 | /// landscape with its top to the left, whose frames need turning a quarter |
| 59 | /// clockwise to stand up; `right` (6) the other way. Anything else — and a |
| 60 | /// phone upright — is sent as it came. |
| 61 | func broadcastRotation(_ orientation: UInt16) -> RTCVideoRotation { |
| 62 | switch orientation { |
| 63 | case 3, 4: return ._180 |
| 64 | case 5, 8: return ._90 |
| 65 | case 6, 7: return ._270 |
| 66 | default: return ._0 |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | /// The broadcast's frames into the call's source, and the last of them |
| 71 | /// again once a second while the screen is still (2026-09-30). ReplayKit |
| 72 | /// sends nothing at all for a screen that is not changing, and a receiver |
| 73 | /// that sees no frame for four seconds reports the screen stalled (spec |
| 74 | /// §15.21) — which, for a still iPhone screen, was a false alarm the |
| 75 | /// sharer then tried to mend. The Mac's capture has repeated its last |
| 76 | /// frame since 2026-09-28 (MacScreenCapture); this is the same rule. |
| 77 | final class StillScreenFeed: @unchecked Sendable { |
| 78 | static let repeatAfter: UInt64 = 1_000_000_000 |
| 79 | |
| 80 | private let source: RTCVideoSource |
| 81 | private let capturer: RTCVideoCapturer |
| 82 | private let queue = DispatchQueue(label: "calls.broadcast.feed", qos: .userInteractive) |
| 83 | private var timer: DispatchSourceTimer? |
| 84 | // Only touched on `queue`. |
| 85 | private var last: (CVPixelBuffer, RTCVideoRotation)? |
| 86 | private var lastSent: UInt64 = 0 |
| 87 | private let tally = OSAllocatedUnfairLock(initialState: ScreenCaptureCounts(heartbeats: nil, delivered: 0)) |
| 88 | var counts: ScreenCaptureCounts { tally.withLock { $0 } } |
| 89 | |
| 90 | init(source: RTCVideoSource) { |
| 91 | self.source = source |
| 92 | capturer = RTCVideoCapturer(delegate: source) |
| 93 | let timer = DispatchSource.makeTimerSource(queue: queue) |
| 94 | timer.schedule(deadline: .now() + .milliseconds(500), repeating: .milliseconds(500), leeway: .milliseconds(50)) |
| 95 | timer.setEventHandler { [weak self] in self?.repeatIfIdle() } |
| 96 | timer.resume() |
| 97 | self.timer = timer |
| 98 | } |
| 99 | |
| 100 | /// One frame from the extension. Stamped on arrival, on this device's |
| 101 | /// clock, rather than with the extension's: the two are separate |
| 102 | /// processes, and the encoder only needs the frames in order and spaced |
| 103 | /// as they came. |
| 104 | func frame(_ pixels: CVPixelBuffer, rotation: RTCVideoRotation) { |
| 105 | queue.async { |
| 106 | self.last = (pixels, rotation) |
| 107 | self.deliver(pixels, rotation) |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | func stop() { |
| 112 | timer?.cancel() |
| 113 | timer = nil |
| 114 | queue.async { self.last = nil } |
| 115 | } |
| 116 | |
| 117 | private func repeatIfIdle() { |
| 118 | guard let (pixels, rotation) = last else { return } |
| 119 | let now = DispatchTime.now().uptimeNanoseconds |
| 120 | guard now >= lastSent, now - lastSent >= Self.repeatAfter else { return } |
| 121 | deliver(pixels, rotation) |
| 122 | } |
| 123 | |
| 124 | private func deliver(_ pixels: CVPixelBuffer, _ rotation: RTCVideoRotation) { |
| 125 | let ns = max(DispatchTime.now().uptimeNanoseconds, lastSent + 1) |
| 126 | lastSent = ns |
| 127 | tally.withLock { $0.delivered += 1 } |
| 128 | source.capturer( |
| 129 | capturer, |
| 130 | didCapture: RTCVideoFrame(buffer: RTCCVPixelBuffer(pixelBuffer: pixels), rotation: rotation, timeStampNs: Int64(ns))) |
| 131 | } |
| 132 | } |
| 133 | |