| 1 | import Foundation |
| 2 | import SwiftUI |
| 3 | |
| 4 | /// How this viewer has the call arranged: the layout, what is pinned, and |
| 5 | /// the order of the people. One per `Call`, so the call window, the mini |
| 6 | /// window and a rejoin all see the same arrangement; the order is kept for |
| 7 | /// the meeting for as long as the app runs (`ParticipantOrders`). |
| 8 | /// |
| 9 | /// Who is speaking is not here: it is measured with the levels |
| 10 | /// (`AudioLevels.speaker`), and `stage(active:)` takes it as an input. |
| 11 | @MainActor public final class CallArrangement: ObservableObject { |
| 12 | @Published public private(set) var layout: VideoLayout |
| 13 | /// What the person pinned to the stage in Speaker view. Nil follows the |
| 14 | /// speaker. |
| 15 | @Published public private(set) var pinned: StageItem? |
| 16 | @Published public private(set) var order = ParticipantOrder() |
| 17 | /// You, as the roster names you. |
| 18 | @Published public private(set) var me: String? |
| 19 | private var keyOf: [String: String] = [:] |
| 20 | private var peerOf: [String: String] = [:] |
| 21 | private var names: [String: String] = [:] |
| 22 | private(set) var meeting = "" |
| 23 | private let defaults: UserDefaults |
| 24 | |
| 25 | public init(defaults: UserDefaults = .standard) { |
| 26 | self.defaults = defaults |
| 27 | layout = VideoLayout.load(defaults) |
| 28 | } |
| 29 | |
| 30 | // MARK: Layout and pin |
| 31 | |
| 32 | /// The person chose a layout: kept for the next call too. |
| 33 | public func choose(_ next: VideoLayout) { |
| 34 | guard next != layout else { return } |
| 35 | layout = next |
| 36 | next.save(defaults) |
| 37 | } |
| 38 | |
| 39 | /// A layout for this call only (fixtures, tests). |
| 40 | @_spi(Fixtures) public func show(_ next: VideoLayout) { layout = next } |
| 41 | |
| 42 | /// Pins `item` to the stage and says so; nil unpins. Pinning is a |
| 43 | /// Speaker view thing, so it switches to Speaker. |
| 44 | public func pin(_ item: StageItem?) { |
| 45 | guard item != pinned else { return } |
| 46 | pinned = item |
| 47 | if item != nil { choose(.speaker) } |
| 48 | AudioAnnouncer.say(item.map { StageWords.pinned(title(of: $0)) } ?? StageWords.unpinned) |
| 49 | } |
| 50 | |
| 51 | /// Pins what is not pinned, and unpins what is. |
| 52 | public func togglePin(_ item: StageItem) { pin(pinned == item ? nil : item) } |
| 53 | |
| 54 | /// Pinned without a word, for fixtures. |
| 55 | @_spi(Fixtures) public func showPinned(_ item: StageItem?) { pinned = item } |
| 56 | |
| 57 | /// A person's tile held lifted, as if half way through a drag, for the |
| 58 | /// snapshots. |
| 59 | @Published public private(set) var fixtureLift: String? |
| 60 | @_spi(Fixtures) public func showLift(_ peer: String?) { fixtureLift = peer } |
| 61 | /// An order for the snapshots: `peers` first, in that order. |
| 62 | @_spi(Fixtures) public func showOrder(_ peers: [String]) { |
| 63 | for (index, peer) in peers.enumerated() { _ = change(peer, announce: false) { $0.move($1, to: index) } } |
| 64 | } |
| 65 | |
| 66 | // MARK: The roster |
| 67 | |
| 68 | /// The call started, for `meeting` (server and meeting id): puts back |
| 69 | /// the order kept for it, if any. |
| 70 | func begin(meeting: String) { |
| 71 | self.meeting = meeting |
| 72 | order = ParticipantOrders.load(meeting: meeting) |
| 73 | pinned = nil |
| 74 | } |
| 75 | |
| 76 | /// Takes the roster as it is. Only while admitted: a roster while |
| 77 | /// reconnecting or waiting is not a statement about who has left. |
| 78 | func roster(_ state: RoomState?) { |
| 79 | guard let state else { |
| 80 | me = nil |
| 81 | pinned = nil |
| 82 | return |
| 83 | } |
| 84 | if me != state.peer { me = state.peer } |
| 85 | guard state.phase == "admitted" else { return } |
| 86 | let keyed = ParticipantKeys.keys( |
| 87 | roster: state.peers.map { ($0.peer, $0.name) }, me: state.peer) |
| 88 | keyOf = Dictionary(keyed.map { ($0.peer, $0.key) }, uniquingKeysWith: { a, _ in a }) |
| 89 | peerOf = Dictionary(keyed.map { ($0.key, $0.peer) }, uniquingKeysWith: { a, _ in a }) |
| 90 | names = Dictionary(state.peers.map { ($0.peer, $0.name) }, uniquingKeysWith: { a, _ in a }) |
| 91 | var next = order |
| 92 | next.merge(present: keyed.map(\.key)) |
| 93 | if next != order { |
| 94 | order = next |
| 95 | ParticipantOrders.save(order, meeting: meeting) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | /// Everyone here, you included, in this viewer's order, as peer ids. |
| 100 | public var people: [String] { order.shown.compactMap { peerOf[$0] } } |
| 101 | /// Everyone but you, in order. |
| 102 | public var others: [String] { people.filter { $0 != me } } |
| 103 | |
| 104 | // MARK: Moving people |
| 105 | |
| 106 | /// Moves `peer` to `index` in `people`. Returns whether it moved. |
| 107 | @discardableResult |
| 108 | public func move(_ peer: String, to index: Int, announce: Bool = false) -> Bool { |
| 109 | change(peer, announce: announce) { $0.move($1, to: index) } |
| 110 | } |
| 111 | @discardableResult |
| 112 | public func moveEarlier(_ peer: String) -> Bool { |
| 113 | change(peer, announce: true) { $0.moveEarlier($1) } |
| 114 | } |
| 115 | @discardableResult |
| 116 | public func moveLater(_ peer: String) -> Bool { |
| 117 | change(peer, announce: true) { $0.moveLater($1) } |
| 118 | } |
| 119 | @discardableResult |
| 120 | public func moveToFirst(_ peer: String) -> Bool { |
| 121 | change(peer, announce: true) { $0.moveToFirst($1) } |
| 122 | } |
| 123 | public func canMove(_ peer: String) -> (earlier: Bool, later: Bool) { |
| 124 | guard let key = keyOf[peer] else { return (false, false) } |
| 125 | return order.canMove(key) |
| 126 | } |
| 127 | |
| 128 | private func change( |
| 129 | _ peer: String, announce: Bool, _ step: (inout ParticipantOrder, String) -> Bool |
| 130 | ) -> Bool { |
| 131 | guard let key = keyOf[peer] else { return false } |
| 132 | var next = order |
| 133 | guard step(&next, key) else { return false } |
| 134 | order = next |
| 135 | ParticipantOrders.save(order, meeting: meeting) |
| 136 | if announce, let at = people.firstIndex(of: peer) { |
| 137 | AudioAnnouncer.say(StageWords.moved(name(of: peer), to: at, of: people.count)) |
| 138 | } |
| 139 | return true |
| 140 | } |
| 141 | |
| 142 | // MARK: The stage |
| 143 | |
| 144 | /// What Speaker view's stage shows, with `active` the speaker. |
| 145 | public func stage(active: String?, screens: [String]) -> StageItem? { |
| 146 | Stage.speaker(pinned: pinned, active: active, others: others, screens: screens, me: me) |
| 147 | } |
| 148 | |
| 149 | /// The filmstrip under that stage. |
| 150 | public func filmstrip(stage: StageItem?, screens: [String]) -> [StageItem] { |
| 151 | Stage.filmstrip(stage: stage, order: people, screens: screens) |
| 152 | } |
| 153 | |
| 154 | func name(of peer: String) -> String { peer == me ? "You" : names[peer] ?? "Participant" } |
| 155 | func title(of item: StageItem) -> String { |
| 156 | switch item { |
| 157 | case .camera(let peer): peer == me ? "yourself" : name(of: peer) |
| 158 | case .screen(let peer): screenCaption(names[peer] ?? "Participant") |
| 159 | } |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | /// Who is speaking, observed alone: publishes only when the stage should |
| 164 | /// move to someone else, a few times a minute in a lively call. |
| 165 | @MainActor public final class SpeakerNow: ObservableObject { |
| 166 | @Published public private(set) var peer: String? |
| 167 | private var rule = ActiveSpeaker() |
| 168 | |
| 169 | public init() {} |
| 170 | |
| 171 | func update(_ voices: [ActiveSpeaker.Voice], present: Set<String>, now: Double) { |
| 172 | rule.update(voices, present: present, me: nil, now: now) |
| 173 | if peer != rule.current { peer = rule.current } |
| 174 | } |
| 175 | |
| 176 | func reset() { |
| 177 | rule.reset() |
| 178 | if peer != nil { peer = nil } |
| 179 | } |
| 180 | |
| 181 | /// Someone on the stage as the speaker, for the snapshots. |
| 182 | @_spi(Fixtures) public func showFixture(_ who: String?) { peer = who } |
| 183 | } |
| 184 | |