oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// 0.4.0: Speaker view and a viewer's own order of the people in a call.
4// The rules, apart from any view, on the caller's clock, so tests hold
5// them with made-up times: which layout, whose tile comes where, who is
6// on the stage, and when the stage moves to somebody else. The browser
7// carries the same rules with the same constants: room.js's speakerStep,
8// and call-layout.js's orderMerge, orderMove and stageFor.
9
10// MARK: - Layout
11
12/// The call's two layouts. **Speaker** is one person (or a screen) on the
13/// stage with everyone else in a filmstrip underneath; **Grid** is
14/// everyone the same size. Speaker replaced Focus: Focus was "one chosen
15/// person on the stage", which is Speaker with that person pinned.
16public enum VideoLayout: String, CaseIterable, Identifiable, Sendable {
17 case speaker
18 case grid
19
20 /// Where the choice is kept (the browser keeps its own, in localStorage
21 /// as `calls.videoLayout`).
22 public static let key = "videoLayout"
23 public static let defaultLayout = VideoLayout.speaker
24 public var id: String { rawValue }
25 public var title: String {
26 switch self {
27 case .speaker: "Speaker"
28 case .grid: "Grid"
29 }
30 }
31 public var symbol: String {
32 switch self {
33 case .speaker: "rectangle.bottomthird.inset.filled"
34 case .grid: "square.grid.2x2"
35 }
36 }
37
38 /// A kept value, read back: "focus" (the layout before 0.4.0) is
39 /// Speaker, and anything unknown is the default. Nothing is lost by it:
40 /// what Focus did, Speaker does with a pin.
41 public static func stored(_ raw: String?) -> VideoLayout {
42 switch raw {
43 case "grid": .grid
44 case "speaker", "focus": .speaker
45 default: defaultLayout
46 }
47 }
48
49 /// Reads the kept layout, rewriting a pre-0.4.0 "focus" as "speaker"
50 /// so the migration happens once and the stored value says what it is.
51 public static func load(_ defaults: UserDefaults = .standard) -> VideoLayout {
52 let raw = defaults.string(forKey: key)
53 let layout = stored(raw)
54 if raw != nil, raw != layout.rawValue { defaults.set(layout.rawValue, forKey: key) }
55 return layout
56 }
57
58 public func save(_ defaults: UserDefaults = .standard) {
59 defaults.set(rawValue, forKey: Self.key)
60 }
61}
62
63// MARK: - The viewer's order
64
65/// The order this viewer has put the people in a call in: one list,
66/// shared by Grid and Speaker's filmstrip, local to this viewer and never
67/// sent to anyone.
68///
69/// Keys are stable across a rejoin (see `ParticipantKeys`), so the order
70/// can be kept for the meeting and put back when the person comes back to
71/// it. It may hold keys of people who are not here: remembered ones not
72/// yet seen since the order was put back. Someone seen and then gone has
73/// left, and is removed; a new arrival goes on the end.
74public struct ParticipantOrder: Equatable, Codable, Sendable {
75 /// Every key in order, remembered ones included.
76 public private(set) var keys: [String]
77 /// Who was here at the last merge: a key in this and not in the next
78 /// one has left.
79 private var present: [String]
80
81 public init(keys: [String] = []) {
82 var seen = Set<String>()
83 self.keys = keys.filter { seen.insert($0).inserted }
84 present = []
85 }
86
87 /// The people here now, in this viewer's order.
88 public var shown: [String] { keys.filter(present.contains) }
89
90 /// Takes the roster as it is now. Leavers — here at the last merge and
91 /// not now — are removed; new arrivals are added at the end, in the
92 /// order they are given; a remembered key that arrives takes its old
93 /// place.
94 public mutating func merge(present now: [String]) {
95 let here = Set(now)
96 let left = Set(present).subtracting(here)
97 keys.removeAll(where: left.contains)
98 for key in now where !keys.contains(key) { keys.append(key) }
99 present = now.filter { key in keys.contains(key) }
100 var seen = Set<String>()
101 present = present.filter { seen.insert($0).inserted }
102 }
103
104 /// Moves `key` to `index` among the people shown: 0 is first; an index
105 /// at or past the end is last. Nothing happens for a key not shown.
106 /// Returns whether the order changed.
107 @discardableResult
108 public mutating func move(_ key: String, to index: Int) -> Bool {
109 var list = shown
110 guard let from = list.firstIndex(of: key) else { return false }
111 list.remove(at: from)
112 let to = min(max(index, 0), list.count)
113 list.insert(key, at: to)
114 guard list != shown else { return false }
115 // The shown keys take the slots the shown keys had; remembered keys
116 // that are not here keep theirs.
117 var slots = list.makeIterator()
118 keys = keys.map { present.contains($0) ? slots.next()! : $0 }
119 return true
120 }
121
122 /// "Move earlier": one place towards the start.
123 @discardableResult
124 public mutating func moveEarlier(_ key: String) -> Bool {
125 guard let at = shown.firstIndex(of: key), at > 0 else { return false }
126 return move(key, to: at - 1)
127 }
128
129 /// "Move later": one place towards the end.
130 @discardableResult
131 public mutating func moveLater(_ key: String) -> Bool {
132 let list = shown
133 guard let at = list.firstIndex(of: key), at < list.count - 1 else { return false }
134 return move(key, to: at + 1)
135 }
136
137 /// "Move to first".
138 @discardableResult
139 public mutating func moveToFirst(_ key: String) -> Bool { move(key, to: 0) }
140
141 /// Which of the three moves make sense for `key` where it is now.
142 public func canMove(_ key: String) -> (earlier: Bool, later: Bool) {
143 let list = shown
144 guard let at = list.firstIndex(of: key) else { return (false, false) }
145 return (at > 0, at < list.count - 1)
146 }
147
148 /// Where an order is kept, per meeting: `meeting` is the server and the
149 /// meeting's id. The same shape as room.js's sessionStorage key.
150 public static func storageKey(meeting: String) -> String { "calls.order." + meeting }
151
152 public static func == (a: Self, b: Self) -> Bool { a.keys == b.keys && a.present == b.present }
153
154 enum CodingKeys: String, CodingKey { case keys }
155 public init(from decoder: Decoder) throws {
156 let container = try decoder.container(keyedBy: CodingKeys.self)
157 self.init(keys: try container.decode([String].self, forKey: .keys))
158 }
159 public func encode(to encoder: Encoder) throws {
160 var container = encoder.container(keyedBy: CodingKeys.self)
161 try container.encode(keys, forKey: .keys)
162 }
163}
164
165/// The orders kept for this run of the app, by meeting: the app's
166/// equivalent of the browser's sessionStorage. A rejoin puts the order
167/// back; quitting forgets it.
168@MainActor public enum ParticipantOrders {
169 private static var kept: [String: ParticipantOrder] = [:]
170 public static func load(meeting: String) -> ParticipantOrder {
171 kept[ParticipantOrder.storageKey(meeting: meeting)].map { ParticipantOrder(keys: $0.keys) }
172 ?? ParticipantOrder()
173 }
174 public static func save(_ order: ParticipantOrder, meeting: String) {
175 guard !meeting.isEmpty else { return }
176 kept[ParticipantOrder.storageKey(meeting: meeting)] = order
177 }
178 static func forgetAll() { kept = [:] }
179}
180
181/// A person's key in an order: stable across a rejoin, which a peer id is
182/// not (every join is a new peer). You are "self"; anyone else is their
183/// name, with "#2", "#3" for a second and third of the same name, counted
184/// in roster order.
185public enum ParticipantKeys {
186 public static let me = "self"
187
188 /// Keys for `roster` (peer ids with their names, in roster order),
189 /// with `me` the peer id that is you.
190 public static func keys(roster: [(peer: String, name: String)], me: String) -> [(peer: String, key: String)] {
191 var count: [String: Int] = [:]
192 return roster.map { entry in
193 if entry.peer == me { return (entry.peer, Self.me) }
194 let n = (count[entry.name] ?? 0) + 1
195 count[entry.name] = n
196 return (entry.peer, "name:" + entry.name + (n > 1 ? "#\(n)" : ""))
197 }
198 }
199}
200
201// MARK: - The active speaker
202
203/// Who is speaking, for the stage: the person who has been the loudest
204/// speaker for long enough, held there through pauses and interjections.
205///
206/// Fed a few times a second with who is speaking and how loudly (the
207/// speaking gate and level the glow already use), and the time. The
208/// stage moves to someone only once they have been the dominant speaker
209/// for `dominance`, and not until whoever is on it has had `minimumHold`
210/// there — so a laugh, a "yes" or a cough never takes it, and two people
211/// talking over each other do not make it flap. You are never the active
212/// speaker: you know what you look like.
213public struct ActiveSpeaker: Equatable, Sendable {
214 /// How long someone must be the loudest speaker before the stage goes
215 /// to them. Most interjections ("mm", "right", a laugh) are under a
216 /// second; a turn at speaking is longer.
217 public static let dominance: Double = 1.3
218 /// The least time someone stays on the stage once there, whoever speaks
219 /// meanwhile: a stage that changes more often than this reads as a
220 /// flicker, not a conversation.
221 public static let minimumHold: Double = 3
222
223 /// One reading of one person.
224 public struct Voice: Equatable, Sendable {
225 public var peer: String
226 /// Speaking, per the speaking gate (on at -30 dBFS, off after 0.6 s
227 /// under -42).
228 public var speaking: Bool
229 /// The level, 0...1 of full scale.
230 public var level: Float
231 public init(peer: String, speaking: Bool, level: Float) {
232 self.peer = peer
233 self.speaking = speaking
234 self.level = level
235 }
236 }
237
238 /// Who is on the stage, or nil before anyone has spoken for long
239 /// enough (the stage then shows the first person in the viewer's order).
240 public private(set) var current: String?
241 /// When `current` took the stage.
242 public private(set) var since: Double = -.infinity
243 /// Who is loudest now, and since when, while they are not `current`.
244 public private(set) var candidate: String?
245 public private(set) var candidateSince: Double = 0
246
247 public init() {}
248
249 /// The loudest person speaking, you left out.
250 public static func dominant(_ voices: [Voice], me: String?) -> String? {
251 voices.filter { $0.speaking && $0.peer != me }.max { $0.level < $1.level }?.peer
252 }
253
254 /// Takes one reading. `present` is everyone else still in the call;
255 /// someone who has left stops being the speaker at once. Returns
256 /// whether `current` changed.
257 @discardableResult
258 public mutating func update(_ voices: [Voice], present: Set<String>, me: String?, now: Double) -> Bool {
259 let before = current
260 if let who = current, !present.contains(who) {
261 current = nil
262 since = -.infinity
263 }
264 let loudest = Self.dominant(voices.filter { present.contains($0.peer) }, me: me)
265 if loudest == nil || loudest == current {
266 // A pause, or the speaker speaking: nobody is taking over.
267 candidate = nil
268 } else if loudest != candidate {
269 candidate = loudest
270 candidateSince = now
271 }
272 if let next = candidate, now - candidateSince >= Self.dominance, now - since >= Self.minimumHold {
273 current = next
274 since = now
275 candidate = nil
276 }
277 return current != before
278 }
279
280 /// Forgets everything: a new call.
281 public mutating func reset() { self = ActiveSpeaker() }
282}
283
284// MARK: - The stage in Speaker view
285
286extension Stage {
287 /// What Speaker view puts on the stage. A pin stands while what is
288 /// pinned is still there. Otherwise a shared screen, the newest first,
289 /// as before 0.4.0: somebody who shares is asking to be looked at. Then
290 /// the active speaker, and before anyone has spoken, the first person
291 /// in the viewer's order who is not you. Alone, nothing (the stage says
292 /// who it is waiting for). You are on it only if you pinned yourself.
293 public static func speaker(
294 pinned: StageItem?, active: String?, others: [String], screens: [String], me: String? = nil
295 ) -> StageItem? {
296 if let pinned {
297 switch pinned {
298 case .camera(let peer) where others.contains(peer) || (peer == me && me != nil): return pinned
299 case .screen(let peer) where screens.contains(peer): return pinned
300 default: break
301 }
302 }
303 if let newest = screens.last { return .screen(newest) }
304 if let active, others.contains(active) { return .camera(active) }
305 return others.first.map(StageItem.camera)
306 }
307
308 /// The filmstrip under the stage: every screen not on the stage (newest
309 /// first), then every camera, yours included, in the viewer's `order`,
310 /// less whatever is on the stage.
311 public static func filmstrip(stage: StageItem?, order: [String], screens: [String]) -> [StageItem] {
312 let shared = screens.reversed().map(StageItem.screen).filter { $0 != stage }
313 return shared + order.map(StageItem.camera).filter { $0 != stage }
314 }
315
316 /// Whether you float over the stage as the self view rather than sit in
317 /// the filmstrip: only when the filmstrip would hold nobody but you —
318 /// a call of two, or a call of one — which keeps a two-person call
319 /// exactly as it has looked since 0.3.4 (the other person filling the
320 /// window, you in the corner). With anyone else to show, you are one of
321 /// the filmstrip, in your place in the order.
322 public static func selfFloats(filmstrip: [StageItem], me: String?) -> Bool {
323 guard let me else { return true }
324 return filmstrip.allSatisfy { $0 == .camera(me) }
325 }
326}
327
328/// What is said when the stage changes by the person's hand. Automatic
329/// switches are not announced: in a lively call they would talk over the
330/// talking. The stage's own label always says who is on it.
331public enum StageWords {
332 public static func onStage(_ name: String) -> String { "On stage: " + name }
333 public static func pinned(_ name: String) -> String { "Pinned " + name + " to the stage" }
334 public static let unpinned = "Unpinned. The stage follows whoever is speaking."
335 public static func pin(_ name: String) -> String { "Pin " + name }
336 public static func unpin(_ name: String) -> String { "Unpin " + name }
337 public static let moveEarlier = "Move earlier"
338 public static let moveLater = "Move later"
339 public static let moveToFirst = "Move to first"
340 public static func moved(_ name: String, to index: Int, of count: Int) -> String {
341 name + " moved to position \(index + 1) of \(count)"
342 }
343}
344
345/// A tile's accessibility label, said as the stage when it is on it.
346public func stageLabel(_ label: String, onStage: Bool, pinned: Bool) -> String {
347 guard onStage else { return label }
348 return StageWords.onStage(label) + (pinned ? ", pinned" : "")
349}
350
351/// Where the stage's name sits in Speaker view, now that the stage fills
352/// the window under the filmstrip: at the bottom left, lifted by the
353/// filmstrip's band and, while the controls show, by as much as the
354/// filmstrip rises for the bar, so it rides just above the filmstrip and
355/// is never stranded mid-height. Without a filmstrip it clears the bar
356/// alone, as it always has. The same rule is calls.css's stage caption and
357/// the iPhone's stage label.
358public enum StageLabel {
359 /// `bar` is how far the filmstrip (or a name) rises for the bar now: 0
360 /// while the controls are hidden. `band` is the filmstrip's height with
361 /// its padding, 0 when there is none.
362 public static func lift(bar: CGFloat, band: CGFloat) -> CGFloat { bar + band }
363}
364