oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1import CallsBroadcastKit
2import CoreVideo
3import Foundation
4import os
5import 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.
61func 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.
77final 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