| 1 | import 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. |
| 16 | public 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. |
| 74 | public 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. |
| 185 | public 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. |
| 213 | public 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 | |
| 286 | extension 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. |
| 331 | public 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. |
| 346 | public 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. |
| 358 | public 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 | |