import Foundation // 0.4.0: Speaker view and a viewer's own order of the people in a call. // The rules, apart from any view, on the caller's clock, so tests hold // them with made-up times: which layout, whose tile comes where, who is // on the stage, and when the stage moves to somebody else. The browser // carries the same rules with the same constants: room.js's speakerStep, // and call-layout.js's orderMerge, orderMove and stageFor. // MARK: - Layout /// The call's two layouts. **Speaker** is one person (or a screen) on the /// stage with everyone else in a filmstrip underneath; **Grid** is /// everyone the same size. Speaker replaced Focus: Focus was "one chosen /// person on the stage", which is Speaker with that person pinned. public enum VideoLayout: String, CaseIterable, Identifiable, Sendable { case speaker case grid /// Where the choice is kept (the browser keeps its own, in localStorage /// as `calls.videoLayout`). public static let key = "videoLayout" public static let defaultLayout = VideoLayout.speaker public var id: String { rawValue } public var title: String { switch self { case .speaker: "Speaker" case .grid: "Grid" } } public var symbol: String { switch self { case .speaker: "rectangle.bottomthird.inset.filled" case .grid: "square.grid.2x2" } } /// A kept value, read back: "focus" (the layout before 0.4.0) is /// Speaker, and anything unknown is the default. Nothing is lost by it: /// what Focus did, Speaker does with a pin. public static func stored(_ raw: String?) -> VideoLayout { switch raw { case "grid": .grid case "speaker", "focus": .speaker default: defaultLayout } } /// Reads the kept layout, rewriting a pre-0.4.0 "focus" as "speaker" /// so the migration happens once and the stored value says what it is. public static func load(_ defaults: UserDefaults = .standard) -> VideoLayout { let raw = defaults.string(forKey: key) let layout = stored(raw) if raw != nil, raw != layout.rawValue { defaults.set(layout.rawValue, forKey: key) } return layout } public func save(_ defaults: UserDefaults = .standard) { defaults.set(rawValue, forKey: Self.key) } } // MARK: - The viewer's order /// The order this viewer has put the people in a call in: one list, /// shared by Grid and Speaker's filmstrip, local to this viewer and never /// sent to anyone. /// /// Keys are stable across a rejoin (see `ParticipantKeys`), so the order /// can be kept for the meeting and put back when the person comes back to /// it. It may hold keys of people who are not here: remembered ones not /// yet seen since the order was put back. Someone seen and then gone has /// left, and is removed; a new arrival goes on the end. public struct ParticipantOrder: Equatable, Codable, Sendable { /// Every key in order, remembered ones included. public private(set) var keys: [String] /// Who was here at the last merge: a key in this and not in the next /// one has left. private var present: [String] public init(keys: [String] = []) { var seen = Set() self.keys = keys.filter { seen.insert($0).inserted } present = [] } /// The people here now, in this viewer's order. public var shown: [String] { keys.filter(present.contains) } /// Takes the roster as it is now. Leavers — here at the last merge and /// not now — are removed; new arrivals are added at the end, in the /// order they are given; a remembered key that arrives takes its old /// place. public mutating func merge(present now: [String]) { let here = Set(now) let left = Set(present).subtracting(here) keys.removeAll(where: left.contains) for key in now where !keys.contains(key) { keys.append(key) } present = now.filter { key in keys.contains(key) } var seen = Set() present = present.filter { seen.insert($0).inserted } } /// Moves `key` to `index` among the people shown: 0 is first; an index /// at or past the end is last. Nothing happens for a key not shown. /// Returns whether the order changed. @discardableResult public mutating func move(_ key: String, to index: Int) -> Bool { var list = shown guard let from = list.firstIndex(of: key) else { return false } list.remove(at: from) let to = min(max(index, 0), list.count) list.insert(key, at: to) guard list != shown else { return false } // The shown keys take the slots the shown keys had; remembered keys // that are not here keep theirs. var slots = list.makeIterator() keys = keys.map { present.contains($0) ? slots.next()! : $0 } return true } /// "Move earlier": one place towards the start. @discardableResult public mutating func moveEarlier(_ key: String) -> Bool { guard let at = shown.firstIndex(of: key), at > 0 else { return false } return move(key, to: at - 1) } /// "Move later": one place towards the end. @discardableResult public mutating func moveLater(_ key: String) -> Bool { let list = shown guard let at = list.firstIndex(of: key), at < list.count - 1 else { return false } return move(key, to: at + 1) } /// "Move to first". @discardableResult public mutating func moveToFirst(_ key: String) -> Bool { move(key, to: 0) } /// Which of the three moves make sense for `key` where it is now. public func canMove(_ key: String) -> (earlier: Bool, later: Bool) { let list = shown guard let at = list.firstIndex(of: key) else { return (false, false) } return (at > 0, at < list.count - 1) } /// Where an order is kept, per meeting: `meeting` is the server and the /// meeting's id. The same shape as room.js's sessionStorage key. public static func storageKey(meeting: String) -> String { "calls.order." + meeting } public static func == (a: Self, b: Self) -> Bool { a.keys == b.keys && a.present == b.present } enum CodingKeys: String, CodingKey { case keys } public init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) self.init(keys: try container.decode([String].self, forKey: .keys)) } public func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CodingKeys.self) try container.encode(keys, forKey: .keys) } } /// The orders kept for this run of the app, by meeting: the app's /// equivalent of the browser's sessionStorage. A rejoin puts the order /// back; quitting forgets it. @MainActor public enum ParticipantOrders { private static var kept: [String: ParticipantOrder] = [:] public static func load(meeting: String) -> ParticipantOrder { kept[ParticipantOrder.storageKey(meeting: meeting)].map { ParticipantOrder(keys: $0.keys) } ?? ParticipantOrder() } public static func save(_ order: ParticipantOrder, meeting: String) { guard !meeting.isEmpty else { return } kept[ParticipantOrder.storageKey(meeting: meeting)] = order } static func forgetAll() { kept = [:] } } /// A person's key in an order: stable across a rejoin, which a peer id is /// not (every join is a new peer). You are "self"; anyone else is their /// name, with "#2", "#3" for a second and third of the same name, counted /// in roster order. public enum ParticipantKeys { public static let me = "self" /// Keys for `roster` (peer ids with their names, in roster order), /// with `me` the peer id that is you. public static func keys(roster: [(peer: String, name: String)], me: String) -> [(peer: String, key: String)] { var count: [String: Int] = [:] return roster.map { entry in if entry.peer == me { return (entry.peer, Self.me) } let n = (count[entry.name] ?? 0) + 1 count[entry.name] = n return (entry.peer, "name:" + entry.name + (n > 1 ? "#\(n)" : "")) } } } // MARK: - The active speaker /// Who is speaking, for the stage: the person who has been the loudest /// speaker for long enough, held there through pauses and interjections. /// /// Fed a few times a second with who is speaking and how loudly (the /// speaking gate and level the glow already use), and the time. The /// stage moves to someone only once they have been the dominant speaker /// for `dominance`, and not until whoever is on it has had `minimumHold` /// there — so a laugh, a "yes" or a cough never takes it, and two people /// talking over each other do not make it flap. You are never the active /// speaker: you know what you look like. public struct ActiveSpeaker: Equatable, Sendable { /// How long someone must be the loudest speaker before the stage goes /// to them. Most interjections ("mm", "right", a laugh) are under a /// second; a turn at speaking is longer. public static let dominance: Double = 1.3 /// The least time someone stays on the stage once there, whoever speaks /// meanwhile: a stage that changes more often than this reads as a /// flicker, not a conversation. public static let minimumHold: Double = 3 /// One reading of one person. public struct Voice: Equatable, Sendable { public var peer: String /// Speaking, per the speaking gate (on at -30 dBFS, off after 0.6 s /// under -42). public var speaking: Bool /// The level, 0...1 of full scale. public var level: Float public init(peer: String, speaking: Bool, level: Float) { self.peer = peer self.speaking = speaking self.level = level } } /// Who is on the stage, or nil before anyone has spoken for long /// enough (the stage then shows the first person in the viewer's order). public private(set) var current: String? /// When `current` took the stage. public private(set) var since: Double = -.infinity /// Who is loudest now, and since when, while they are not `current`. public private(set) var candidate: String? public private(set) var candidateSince: Double = 0 public init() {} /// The loudest person speaking, you left out. public static func dominant(_ voices: [Voice], me: String?) -> String? { voices.filter { $0.speaking && $0.peer != me }.max { $0.level < $1.level }?.peer } /// Takes one reading. `present` is everyone else still in the call; /// someone who has left stops being the speaker at once. Returns /// whether `current` changed. @discardableResult public mutating func update(_ voices: [Voice], present: Set, me: String?, now: Double) -> Bool { let before = current if let who = current, !present.contains(who) { current = nil since = -.infinity } let loudest = Self.dominant(voices.filter { present.contains($0.peer) }, me: me) if loudest == nil || loudest == current { // A pause, or the speaker speaking: nobody is taking over. candidate = nil } else if loudest != candidate { candidate = loudest candidateSince = now } if let next = candidate, now - candidateSince >= Self.dominance, now - since >= Self.minimumHold { current = next since = now candidate = nil } return current != before } /// Forgets everything: a new call. public mutating func reset() { self = ActiveSpeaker() } } // MARK: - The stage in Speaker view extension Stage { /// What Speaker view puts on the stage. A pin stands while what is /// pinned is still there. Otherwise a shared screen, the newest first, /// as before 0.4.0: somebody who shares is asking to be looked at. Then /// the active speaker, and before anyone has spoken, the first person /// in the viewer's order who is not you. Alone, nothing (the stage says /// who it is waiting for). You are on it only if you pinned yourself. public static func speaker( pinned: StageItem?, active: String?, others: [String], screens: [String], me: String? = nil ) -> StageItem? { if let pinned { switch pinned { case .camera(let peer) where others.contains(peer) || (peer == me && me != nil): return pinned case .screen(let peer) where screens.contains(peer): return pinned default: break } } if let newest = screens.last { return .screen(newest) } if let active, others.contains(active) { return .camera(active) } return others.first.map(StageItem.camera) } /// The filmstrip under the stage: every screen not on the stage (newest /// first), then every camera, yours included, in the viewer's `order`, /// less whatever is on the stage. public static func filmstrip(stage: StageItem?, order: [String], screens: [String]) -> [StageItem] { let shared = screens.reversed().map(StageItem.screen).filter { $0 != stage } return shared + order.map(StageItem.camera).filter { $0 != stage } } /// Whether you float over the stage as the self view rather than sit in /// the filmstrip: only when the filmstrip would hold nobody but you — /// a call of two, or a call of one — which keeps a two-person call /// exactly as it has looked since 0.3.4 (the other person filling the /// window, you in the corner). With anyone else to show, you are one of /// the filmstrip, in your place in the order. public static func selfFloats(filmstrip: [StageItem], me: String?) -> Bool { guard let me else { return true } return filmstrip.allSatisfy { $0 == .camera(me) } } } /// What is said when the stage changes by the person's hand. Automatic /// switches are not announced: in a lively call they would talk over the /// talking. The stage's own label always says who is on it. public enum StageWords { public static func onStage(_ name: String) -> String { "On stage: " + name } public static func pinned(_ name: String) -> String { "Pinned " + name + " to the stage" } public static let unpinned = "Unpinned. The stage follows whoever is speaking." public static func pin(_ name: String) -> String { "Pin " + name } public static func unpin(_ name: String) -> String { "Unpin " + name } public static let moveEarlier = "Move earlier" public static let moveLater = "Move later" public static let moveToFirst = "Move to first" public static func moved(_ name: String, to index: Int, of count: Int) -> String { name + " moved to position \(index + 1) of \(count)" } } /// A tile's accessibility label, said as the stage when it is on it. public func stageLabel(_ label: String, onStage: Bool, pinned: Bool) -> String { guard onStage else { return label } return StageWords.onStage(label) + (pinned ? ", pinned" : "") } /// Where the stage's name sits in Speaker view, now that the stage fills /// the window under the filmstrip: at the bottom left, lifted by the /// filmstrip's band and, while the controls show, by as much as the /// filmstrip rises for the bar, so it rides just above the filmstrip and /// is never stranded mid-height. Without a filmstrip it clears the bar /// alone, as it always has. The same rule is calls.css's stage caption and /// the iPhone's stage label. public enum StageLabel { /// `bar` is how far the filmstrip (or a name) rises for the bar now: 0 /// while the controls are hidden. `band` is the filmstrip's height with /// its padding, 0 when there is none. public static func lift(bar: CGFloat, band: CGFloat) -> CGFloat { bar + band } }