import Foundation import SwiftUI /// How this viewer has the call arranged: the layout, what is pinned, and /// the order of the people. One per `Call`, so the call window, the mini /// window and a rejoin all see the same arrangement; the order is kept for /// the meeting for as long as the app runs (`ParticipantOrders`). /// /// Who is speaking is not here: it is measured with the levels /// (`AudioLevels.speaker`), and `stage(active:)` takes it as an input. @MainActor public final class CallArrangement: ObservableObject { @Published public private(set) var layout: VideoLayout /// What the person pinned to the stage in Speaker view. Nil follows the /// speaker. @Published public private(set) var pinned: StageItem? @Published public private(set) var order = ParticipantOrder() /// You, as the roster names you. @Published public private(set) var me: String? private var keyOf: [String: String] = [:] private var peerOf: [String: String] = [:] private var names: [String: String] = [:] private(set) var meeting = "" private let defaults: UserDefaults public init(defaults: UserDefaults = .standard) { self.defaults = defaults layout = VideoLayout.load(defaults) } // MARK: Layout and pin /// The person chose a layout: kept for the next call too. public func choose(_ next: VideoLayout) { guard next != layout else { return } layout = next next.save(defaults) } /// A layout for this call only (fixtures, tests). @_spi(Fixtures) public func show(_ next: VideoLayout) { layout = next } /// Pins `item` to the stage and says so; nil unpins. Pinning is a /// Speaker view thing, so it switches to Speaker. public func pin(_ item: StageItem?) { guard item != pinned else { return } pinned = item if item != nil { choose(.speaker) } AudioAnnouncer.say(item.map { StageWords.pinned(title(of: $0)) } ?? StageWords.unpinned) } /// Pins what is not pinned, and unpins what is. public func togglePin(_ item: StageItem) { pin(pinned == item ? nil : item) } /// Pinned without a word, for fixtures. @_spi(Fixtures) public func showPinned(_ item: StageItem?) { pinned = item } /// A person's tile held lifted, as if half way through a drag, for the /// snapshots. @Published public private(set) var fixtureLift: String? @_spi(Fixtures) public func showLift(_ peer: String?) { fixtureLift = peer } /// An order for the snapshots: `peers` first, in that order. @_spi(Fixtures) public func showOrder(_ peers: [String]) { for (index, peer) in peers.enumerated() { _ = change(peer, announce: false) { $0.move($1, to: index) } } } // MARK: The roster /// The call started, for `meeting` (server and meeting id): puts back /// the order kept for it, if any. func begin(meeting: String) { self.meeting = meeting order = ParticipantOrders.load(meeting: meeting) pinned = nil } /// Takes the roster as it is. Only while admitted: a roster while /// reconnecting or waiting is not a statement about who has left. func roster(_ state: RoomState?) { guard let state else { me = nil pinned = nil return } if me != state.peer { me = state.peer } guard state.phase == "admitted" else { return } let keyed = ParticipantKeys.keys( roster: state.peers.map { ($0.peer, $0.name) }, me: state.peer) keyOf = Dictionary(keyed.map { ($0.peer, $0.key) }, uniquingKeysWith: { a, _ in a }) peerOf = Dictionary(keyed.map { ($0.key, $0.peer) }, uniquingKeysWith: { a, _ in a }) names = Dictionary(state.peers.map { ($0.peer, $0.name) }, uniquingKeysWith: { a, _ in a }) var next = order next.merge(present: keyed.map(\.key)) if next != order { order = next ParticipantOrders.save(order, meeting: meeting) } } /// Everyone here, you included, in this viewer's order, as peer ids. public var people: [String] { order.shown.compactMap { peerOf[$0] } } /// Everyone but you, in order. public var others: [String] { people.filter { $0 != me } } // MARK: Moving people /// Moves `peer` to `index` in `people`. Returns whether it moved. @discardableResult public func move(_ peer: String, to index: Int, announce: Bool = false) -> Bool { change(peer, announce: announce) { $0.move($1, to: index) } } @discardableResult public func moveEarlier(_ peer: String) -> Bool { change(peer, announce: true) { $0.moveEarlier($1) } } @discardableResult public func moveLater(_ peer: String) -> Bool { change(peer, announce: true) { $0.moveLater($1) } } @discardableResult public func moveToFirst(_ peer: String) -> Bool { change(peer, announce: true) { $0.moveToFirst($1) } } public func canMove(_ peer: String) -> (earlier: Bool, later: Bool) { guard let key = keyOf[peer] else { return (false, false) } return order.canMove(key) } private func change( _ peer: String, announce: Bool, _ step: (inout ParticipantOrder, String) -> Bool ) -> Bool { guard let key = keyOf[peer] else { return false } var next = order guard step(&next, key) else { return false } order = next ParticipantOrders.save(order, meeting: meeting) if announce, let at = people.firstIndex(of: peer) { AudioAnnouncer.say(StageWords.moved(name(of: peer), to: at, of: people.count)) } return true } // MARK: The stage /// What Speaker view's stage shows, with `active` the speaker. public func stage(active: String?, screens: [String]) -> StageItem? { Stage.speaker(pinned: pinned, active: active, others: others, screens: screens, me: me) } /// The filmstrip under that stage. public func filmstrip(stage: StageItem?, screens: [String]) -> [StageItem] { Stage.filmstrip(stage: stage, order: people, screens: screens) } func name(of peer: String) -> String { peer == me ? "You" : names[peer] ?? "Participant" } func title(of item: StageItem) -> String { switch item { case .camera(let peer): peer == me ? "yourself" : name(of: peer) case .screen(let peer): screenCaption(names[peer] ?? "Participant") } } } /// Who is speaking, observed alone: publishes only when the stage should /// move to someone else, a few times a minute in a lively call. @MainActor public final class SpeakerNow: ObservableObject { @Published public private(set) var peer: String? private var rule = ActiveSpeaker() public init() {} func update(_ voices: [ActiveSpeaker.Voice], present: Set, now: Double) { rule.update(voices, present: present, me: nil, now: now) if peer != rule.current { peer = rule.current } } func reset() { rule.reset() if peer != nil { peer = nil } } /// Someone on the stage as the speaker, for the snapshots. @_spi(Fixtures) public func showFixture(_ who: String?) { peer = who } }