| 1 | @_spi(Fixtures) import CallsKit |
| 2 | import SwiftUI |
| 3 | import LiveKitWebRTC |
| 4 | |
| 5 | /// The delegate exists for one reason: Apple hands a device token to |
| 6 | /// the application delegate and nowhere else, so a pure SwiftUI app has |
| 7 | /// no way to receive one. |
| 8 | final class CallsPhoneDelegate: NSObject, UIApplicationDelegate { |
| 9 | @MainActor static var model: CallsModel? |
| 10 | |
| 11 | func application( |
| 12 | _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data |
| 13 | ) { |
| 14 | Task { @MainActor in CallsPhoneDelegate.model?.registerDevice(token: token) } |
| 15 | } |
| 16 | |
| 17 | func application( |
| 18 | _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error |
| 19 | ) { |
| 20 | // Expected in the simulator and on any build without the push |
| 21 | // entitlement. Silent, for the same reason as on the Mac. |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | @main struct CallsPhoneApp: App { |
| 26 | @UIApplicationDelegateAdaptor(CallsPhoneDelegate.self) private var appDelegate |
| 27 | @StateObject private var model = CallsModel() |
| 28 | @Environment(\.scenePhase) private var scenePhase |
| 29 | var body: some Scene { |
| 30 | WindowGroup { |
| 31 | #if DEBUG |
| 32 | if let level = PhoneCallFixture.rimGallery { |
| 33 | WarmRimGallery(level: level).frame(maxWidth: .infinity, maxHeight: .infinity) |
| 34 | .background(Color.black).preferredColorScheme(.dark) |
| 35 | } else if let fixture = PhoneCallFixture.requested { |
| 36 | PhoneCallFixture(fixture: fixture) |
| 37 | } else { |
| 38 | home |
| 39 | } |
| 40 | #else |
| 41 | home |
| 42 | #endif |
| 43 | } |
| 44 | } |
| 45 | |
| 46 | private var home: some View { |
| 47 | PhoneHome(model: model) |
| 48 | .onOpenURL { model.openMeetingURL($0) } |
| 49 | .onAppear { |
| 50 | CallsPhoneDelegate.model = model |
| 51 | // UI tests launch with this set so a sign-in kept by an earlier |
| 52 | // run, against a harness server long gone, cannot decide what |
| 53 | // the next run sees first. The simulator's Keychain outlives the |
| 54 | // app being reinstalled. |
| 55 | if UserDefaults.standard.bool(forKey: "CallsForgetSignIn") { |
| 56 | model.signOut() |
| 57 | } else { |
| 58 | model.restore() |
| 59 | } |
| 60 | } |
| 61 | .onChange(of: scenePhase) { _, phase in |
| 62 | // This milestone is foreground-only. Browser sign-in is kept alive; |
| 63 | // an active call leaves cleanly instead of silently sending audio. |
| 64 | // The one exception is a call sharing the whole screen, whose |
| 65 | // point is the other apps the person is about to go to. |
| 66 | if phase == .background && model.joining && !model.call.sharingScreen { model.leave() } |
| 67 | UIApplication.shared.isIdleTimerDisabled = phase == .active && model.joining |
| 68 | } |
| 69 | .onChange(of: model.joining) { _, joining in |
| 70 | UIApplication.shared.isIdleTimerDisabled = joining |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | struct PhoneHome: View { |
| 76 | @ObservedObject var model: CallsModel |
| 77 | @State private var showingSettings = false |
| 78 | var body: some View { |
| 79 | NavigationStack { |
| 80 | Group { |
| 81 | if model.joining { |
| 82 | PhoneCall(call: model.call, leave: model.leave) |
| 83 | } else { |
| 84 | CallsLobby(model: model, openSettings: { showingSettings = true }) |
| 85 | } |
| 86 | }.tint(CallsAppearance.accent).navigationTitle("Calls").navigationBarTitleDisplayMode(.inline) |
| 87 | .toolbar { |
| 88 | // There is no Settings app entry: the gear is where the server |
| 89 | // setting lives, reachable from home and never during a call. |
| 90 | if !model.joining { |
| 91 | ToolbarItem(placement: .topBarTrailing) { |
| 92 | Button("Settings", systemImage: "gearshape") { showingSettings = true } |
| 93 | .accessibilityIdentifier("settings") |
| 94 | } |
| 95 | } |
| 96 | } |
| 97 | .sheet(isPresented: $showingSettings) { |
| 98 | ServerSettingsSheet(model: model) { showingSettings = false } |
| 99 | .presentationDetents([.medium, .large]) |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | struct PhoneCall: View { |
| 106 | @ObservedObject var call: Call |
| 107 | let leave: () -> Void |
| 108 | @State var showingChat = false |
| 109 | @State var showingDevices = false |
| 110 | @State var showingMore = false |
| 111 | /// What the controls sheet was closed to do, done once it has gone: |
| 112 | /// one sheet at a time. |
| 113 | @State var afterMore: MoreAction? |
| 114 | @State var readChatCount = 0 |
| 115 | @ObservedObject var arrangement: CallArrangement |
| 116 | @ObservedObject var speakerNow: SpeakerNow |
| 117 | /// A tile being dragged to a new place, and where every tile is. |
| 118 | @State var drag: TileDrag? |
| 119 | @State var tileFrames: [String: CGRect] = [:] |
| 120 | @State var dismissedNotices: Set<String> = [] |
| 121 | @State var pipDrag: CGSize = .zero |
| 122 | @StateObject var draft = ChatDraft() |
| 123 | @StateObject var screenShare = PhoneScreenShare() |
| 124 | @StateObject var chrome: ControlsVisibilityModel |
| 125 | @AppStorage(CallLayoutStyle.key) private var layoutChoice = CallLayoutStyle.defaultStyle.rawValue |
| 126 | @AppStorage(PiPCorner.key) var pipCorner = PiPCorner.bottomTrailing.rawValue |
| 127 | /// A microphone button beside the controls menu, off by default: the |
| 128 | /// chosen layout is the menu and End call alone. Set from the command |
| 129 | /// line (`-CallsMicBesideMenu YES`) to try the other. |
| 130 | @AppStorage("CallsMicBesideMenu") var micBesideMenu = false |
| 131 | @Environment(\.accessibilityReduceMotion) var reduceMotion |
| 132 | @Environment(\.accessibilityVoiceOverEnabled) var voiceOver |
| 133 | @Environment(\.fixtureBackdrop) var fixtureBackdrop |
| 134 | private let style: CallLayoutStyle? |
| 135 | |
| 136 | init( |
| 137 | call: Call, leave: @escaping () -> Void, style: CallLayoutStyle? = nil, |
| 138 | controlsShown: Bool? = nil, showingMore: Bool = false, grid: Bool? = nil |
| 139 | ) { |
| 140 | self.call = call |
| 141 | self.leave = leave |
| 142 | self.style = style |
| 143 | _showingMore = State(initialValue: showingMore) |
| 144 | if let grid { call.arrangement.show(grid ? .grid : .speaker) } |
| 145 | arrangement = call.arrangement |
| 146 | speakerNow = call.levels.speaker |
| 147 | _chrome = StateObject(wrappedValue: ControlsVisibilityModel(input: .touch, pinned: controlsShown)) |
| 148 | } |
| 149 | |
| 150 | var layout: CallLayoutStyle { style ?? CallLayoutStyle(rawValue: layoutChoice) ?? .defaultStyle } |
| 151 | |
| 152 | var grid: Bool { arrangement.layout == .grid } |
| 153 | /// Everyone else, in this viewer's order. |
| 154 | var peers: [RosterPeer] { |
| 155 | let roster = call.state?.peers ?? [] |
| 156 | return arrangement.others.compactMap { id in roster.first { $0.peer == id } } |
| 157 | } |
| 158 | /// What Speaker view's stage shows, and the filmstrip under it. |
| 159 | var focused: StageItem? { arrangement.stage(active: speakerNow.peer, screens: call.screens) } |
| 160 | var filmstrip: [StageItem] { arrangement.filmstrip(stage: focused, screens: call.screens) } |
| 161 | /// You float over the stage only when you would be all the filmstrip |
| 162 | /// holds (see Stage.selfFloats). |
| 163 | var selfFloats: Bool { Stage.selfFloats(filmstrip: filmstrip, me: arrangement.me) } |
| 164 | var gridItems: [StageItem] { |
| 165 | call.screens.reversed().map(StageItem.screen) + arrangement.people.map(StageItem.camera) |
| 166 | } |
| 167 | |
| 168 | /// A tile for anything the call can show, as it is seen now. |
| 169 | func phoneTile( |
| 170 | _ item: StageItem, cornerRadius: CGFloat = 18, compact: Bool = false, |
| 171 | labelInsets: EdgeInsets = EdgeInsets(), backdrop: Color = .black, onStage: Bool = false, |
| 172 | iconOnly: Bool = false |
| 173 | ) -> PhoneVideoTile { |
| 174 | let pinned = onStage && arrangement.pinned == item |
| 175 | switch item { |
| 176 | case .screen(let peer): |
| 177 | return PhoneVideoTile( |
| 178 | track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), screen: true, |
| 179 | cornerRadius: cornerRadius, compact: compact, labelInsets: labelInsets, backdrop: backdrop, |
| 180 | onStage: onStage, pinned: pinned, iconOnly: iconOnly) |
| 181 | case .camera(let peer) where peer == arrangement.me: |
| 182 | return PhoneVideoTile( |
| 183 | track: call.localTrack, label: "You", muted: call.muted, mirrored: true, |
| 184 | cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: cornerRadius, |
| 185 | compact: compact, labelInsets: labelInsets, backdrop: backdrop, audio: call.selfAudio, |
| 186 | onStage: onStage, pinned: pinned, iconOnly: iconOnly) |
| 187 | case .camera(let peer): |
| 188 | return PhoneVideoTile( |
| 189 | track: call.remoteTracks[peer], label: call.name(of: peer), muted: call.isMuted(peer), |
| 190 | cameraOff: call.isCameraOff(peer), cornerRadius: cornerRadius, compact: compact, |
| 191 | labelInsets: labelInsets, backdrop: backdrop, audio: call.tileAudio(peer), |
| 192 | onStage: onStage, pinned: pinned, iconOnly: iconOnly) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | func itemTitle(_ item: StageItem) -> String { |
| 197 | switch item { |
| 198 | case .screen(let peer): screenCaption(call.name(of: peer)) |
| 199 | case .camera(let peer): peer == arrangement.me ? "You" : call.name(of: peer) |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | /// A tile of Grid or the filmstrip: a long press picks it up to move |
| 204 | /// it, a tap pins it (the filmstrip), and VoiceOver has both as actions. |
| 205 | func arrangeable(_ item: StageItem, tapToPin: Bool) -> ArrangeableTile { |
| 206 | ArrangeableTile( |
| 207 | item: item, title: itemTitle(item), arrangement: arrangement, drag: $drag, |
| 208 | frames: tileFrames, tapToPin: tapToPin, touch: true, reduceMotion: reduceMotion, |
| 209 | fixtureLift: item == .camera(arrangement.fixtureLift ?? "") ? CGSize(width: 24, height: -30) : nil) |
| 210 | } |
| 211 | |
| 212 | var body: some View { |
| 213 | Group { |
| 214 | if layout == .classic { |
| 215 | classic |
| 216 | } else { |
| 217 | fullScreen |
| 218 | } |
| 219 | } |
| 220 | .overlay(alignment: .bottom) { |
| 221 | CallToast(call: call).padding(.bottom, 110) |
| 222 | } |
| 223 | .background { |
| 224 | // ⌘⇧I on an iPad's keyboard, as on the Mac: the invite link to the |
| 225 | // clipboard. A key command with no button to show for it. |
| 226 | Button(InviteLinkCopy.title) { Task { await call.copyInviteLink() } } |
| 227 | .keyboardShortcut(InviteLinkCopy.shortcut) |
| 228 | .opacity(0).frame(width: 0, height: 0).accessibilityHidden(true) |
| 229 | } |
| 230 | .task { await call.watchAudio() } |
| 231 | .onAppear { screenShare.listen(for: call, leave: leave) } |
| 232 | .onDisappear { |
| 233 | screenShare.stop() |
| 234 | call.stopSharingScreen() |
| 235 | } |
| 236 | .sheet(isPresented: $showingDevices) { |
| 237 | NavigationStack { |
| 238 | ScrollView { DevicePanel(call: call).frame(maxWidth: .infinity, alignment: .leading) } |
| 239 | .navigationTitle("Devices").navigationBarTitleDisplayMode(.inline) |
| 240 | .toolbar { |
| 241 | ToolbarItem(placement: .confirmationAction) { |
| 242 | Button("Done") { showingDevices = false } |
| 243 | } |
| 244 | } |
| 245 | }.presentationDetents([.medium, .large]) |
| 246 | } |
| 247 | .sheet(isPresented: $showingChat, onDismiss: { readChatCount = call.receivedChatCount }) { |
| 248 | NavigationStack { |
| 249 | ChatView(call: call, draft: draft).padding() |
| 250 | .navigationTitle("Chat").navigationBarTitleDisplayMode(.inline) |
| 251 | .toolbar { |
| 252 | ToolbarItem(placement: .confirmationAction) { Button("Done") { showingChat = false } } |
| 253 | } |
| 254 | }.presentationDetents([.medium, .large]) |
| 255 | } |
| 256 | .onChange(of: call.receivedChatCount) { _, count in |
| 257 | if showingChat { readChatCount = count } |
| 258 | } |
| 259 | .reportProblemSheet(call) |
| 260 | } |
| 261 | |
| 262 | /// The call as it was before the full-screen layout: title and stage |
| 263 | /// in the page, controls always showing along the bottom. |
| 264 | private var classic: some View { |
| 265 | VStack(spacing: 12) { |
| 266 | HStack { |
| 267 | VStack(alignment: .leading, spacing: 4) { |
| 268 | Text(call.title).font(.headline).lineLimit(2) |
| 269 | Text(call.status).font(.caption).foregroundStyle(.secondary) |
| 270 | if call.sharingScreen { |
| 271 | Label("You are sharing your screen", systemImage: "rectangle.on.rectangle") |
| 272 | .font(.caption.bold()).foregroundStyle(.orange) |
| 273 | } |
| 274 | } |
| 275 | Spacer() |
| 276 | Menu { |
| 277 | LayoutChoices(call: call, arrangement: arrangement, title: itemTitle) |
| 278 | } label: { |
| 279 | Label( |
| 280 | "Video layout", systemImage: arrangement.layout.symbol |
| 281 | ) |
| 282 | .labelStyle(.iconOnly).frame(width: 44, height: 44) |
| 283 | }.accessibilityIdentifier("videoLayout") |
| 284 | } |
| 285 | if call.floor { |
| 286 | Text("Direct connections only. Some networks may be unable to connect.") |
| 287 | .font(.footnote).foregroundStyle(.secondary) |
| 288 | } |
| 289 | if grid { |
| 290 | ScrollView { |
| 291 | LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) { |
| 292 | ForEach(gridItems, id: \.self) { item in |
| 293 | phoneTile(item) |
| 294 | .aspectRatio(4 / 3, contentMode: .fit) |
| 295 | .modifier(arrangeable(item, tapToPin: false)) |
| 296 | } |
| 297 | } |
| 298 | } |
| 299 | .coordinateSpace(name: arrangeSpace) |
| 300 | .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 } |
| 301 | } else { |
| 302 | GeometryReader { geometry in |
| 303 | ZStack(alignment: .bottomTrailing) { |
| 304 | if let focused { |
| 305 | phoneTile(focused, onStage: true) |
| 306 | } else { |
| 307 | ContentUnavailableView( |
| 308 | "Waiting for others", systemImage: "person.2", |
| 309 | description: Text("Others will appear here when they join.") |
| 310 | ) |
| 311 | .frame(maxWidth: .infinity, maxHeight: .infinity) |
| 312 | .background(CallsAppearance.background, in: RoundedRectangle(cornerRadius: 18)) |
| 313 | } |
| 314 | phoneTile(.camera(arrangement.me ?? "")) |
| 315 | .frame( |
| 316 | width: min(160, geometry.size.width * 0.32), |
| 317 | height: min(120, geometry.size.height * 0.28) |
| 318 | ) |
| 319 | .shadow(color: .black.opacity(0.25), radius: 6, y: 2).padding(12) |
| 320 | } |
| 321 | } |
| 322 | } |
| 323 | if !(call.state?.waiting.isEmpty ?? true) { |
| 324 | ScrollView { |
| 325 | ForEach(call.state?.waiting ?? []) { person in |
| 326 | HStack { |
| 327 | Text("\(person.name) is waiting") |
| 328 | Spacer() |
| 329 | Button("Let in") { call.admit(person.peer) } |
| 330 | } |
| 331 | } |
| 332 | }.frame(maxHeight: 90) |
| 333 | } |
| 334 | if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } } |
| 335 | if call.state?.phase == "ended" { ReportProblemButton { call.reportProblem() }.buttonStyle(.bordered) } |
| 336 | }.padding(.horizontal, 16).padding(.top, 8) |
| 337 | .safeAreaInset(edge: .bottom) { |
| 338 | HStack(spacing: 14) { |
| 339 | Button( |
| 340 | call.muted ? "Unmute microphone" : "Mute microphone", |
| 341 | glyph: call.muted ? "mic.slash.fill" : "mic.fill" |
| 342 | ) { call.mute() } |
| 343 | // Space on a hardware keyboard (iPad, mostly). It is a |
| 344 | // UIKeyCommand without wantsPriorityOverSystemBehavior, so a |
| 345 | // text field being typed in and a control with keyboard focus |
| 346 | // keep their Space; only otherwise does it mute. |
| 347 | .keyboardShortcut(.space, modifiers: []) |
| 348 | .buttonStyle( |
| 349 | CallActionStyle( |
| 350 | color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent, |
| 351 | emphasized: call.muted)) |
| 352 | Button( |
| 353 | call.cameraOff ? "Turn camera on" : "Turn camera off", |
| 354 | glyph: call.cameraOff ? "video.slash.fill" : "video.fill" |
| 355 | ) { call.toggleCamera() } |
| 356 | .buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff)) |
| 357 | if call.sharingScreen { |
| 358 | Button("Stop sharing your screen", glyph: "rectangle.on.rectangle.slash") { |
| 359 | call.stopSharingScreen() |
| 360 | } |
| 361 | .buttonStyle(CallActionStyle(color: .orange, emphasized: true)) |
| 362 | .accessibilityIdentifier("shareScreen") |
| 363 | } else { |
| 364 | BroadcastButton().frame(width: 44, height: 44) |
| 365 | .disabled(call.state?.phase != "admitted") |
| 366 | .accessibilityIdentifier("shareScreen") |
| 367 | } |
| 368 | Button("Devices", glyph: "slider.horizontal.3") { showingDevices = true } |
| 369 | .buttonStyle(CallActionStyle(color: .teal)) |
| 370 | .accessibilityIdentifier("devices") |
| 371 | MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo)) |
| 372 | Spacer(minLength: 0) |
| 373 | Button(chatButtonTitle, glyph: "bubble.left.and.bubble.right.fill") { |
| 374 | readChatCount = call.receivedChatCount |
| 375 | showingChat = true |
| 376 | }.buttonStyle(CallActionStyle(color: .purple)).accessibilityIdentifier("openChat") |
| 377 | .overlay(alignment: .topTrailing) { |
| 378 | if unread > 0 { |
| 379 | Text("\(unread)").font(.caption.bold()).foregroundStyle(.white) |
| 380 | .padding(4).background(.purple, in: Capsule()).accessibilityHidden(true) |
| 381 | } |
| 382 | } |
| 383 | Button("Leave meeting", glyph: "phone.down.fill", role: .destructive, action: leave) |
| 384 | .buttonStyle(CallActionStyle(color: .red, emphasized: true)) |
| 385 | .accessibilityIdentifier("leave") |
| 386 | }.padding(16).background(.bar) |
| 387 | } |
| 388 | } |
| 389 | var unread: Int { max(0, call.receivedChatCount - readChatCount) } |
| 390 | var chatButtonTitle: String { unread == 0 ? "Chat" : "Chat (\(unread) new)" } |
| 391 | } |
| 392 | |
| 393 | struct PhoneVideoTile: View { |
| 394 | let track: RTCVideoTrack? |
| 395 | let label: String |
| 396 | /// §15.19: the peer has said it is muted. False for unknown. |
| 397 | var muted = false |
| 398 | var mirrored = false |
| 399 | var cameraOff = false |
| 400 | /// A shared screen (§5.4), shown whole rather than cropped: on a phone |
| 401 | /// more than anywhere, cropping a laptop's screen to a portrait tile |
| 402 | /// would leave the middle third of it. |
| 403 | var screen = false |
| 404 | /// Your own tile: what the camera is doing, in place of a picture that |
| 405 | /// is not arriving (see `Call.selfPlaceholder`). Nil shows the video. |
| 406 | var placeholder: String? = nil |
| 407 | /// The full-screen call's look: as rounded as `cornerRadius` says |
| 408 | /// (square to the screen's edges for the stage), a smaller name with the |
| 409 | /// muted mark inside it, kept `labelInsets` clear of the safe area and |
| 410 | /// the controls. |
| 411 | var cornerRadius: CGFloat = 18 |
| 412 | var compact = false |
| 413 | var labelInsets = EdgeInsets() |
| 414 | /// Behind a picture that is not there: a shade lighter than the stage |
| 415 | /// for a tile among others, so it reads as a tile. |
| 416 | var backdrop = Color.black |
| 417 | /// The sound on this tile (spec §15.20): your microphone's glow, or a |
| 418 | /// remote person's with "No sound from" and their notices. Nil for a |
| 419 | /// screen. |
| 420 | var audio: TileAudio? = nil |
| 421 | /// Speaker view's stage: VoiceOver hears "On stage: Ana", and whether |
| 422 | /// it was pinned there. |
| 423 | var onStage = false |
| 424 | var pinned = false |
| 425 | /// A filmstrip thumbnail: the symbol alone for a missing picture. |
| 426 | var iconOnly = false |
| 427 | /// The height of the name label and what is over it, which the |
| 428 | /// placeholder keeps clear of. |
| 429 | @State private var labelBlock: CGFloat = 0 |
| 430 | @Environment(\.fixtureStagePicture) private var fixturePicture |
| 431 | var body: some View { |
| 432 | TileAudioReader(audio) { info in tile(info) } |
| 433 | } |
| 434 | private func tile(_ info: TileAudioInfo?) -> some View { |
| 435 | ZStack { |
| 436 | backdrop |
| 437 | if let fixturePicture, onStage, track == nil, !cameraOff, placeholder == nil { |
| 438 | FixtureVideo(picture: fixturePicture, screen: screen) |
| 439 | } else if let track, !cameraOff, placeholder == nil { |
| 440 | // A screen on the stage is fitted whole; around it, the same |
| 441 | // picture soft and dimmed rather than black bars. |
| 442 | if screen, onStage { ScreenBackdrop(track: track) } |
| 443 | PhoneVideo(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| 444 | } else { |
| 445 | TilePlaceholder( |
| 446 | symbol: cameraOff |
| 447 | ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle", |
| 448 | words: cameraOff || iconOnly ? nil : placeholder, wordsFont: .caption, spacing: 6, |
| 449 | reserve: labelBlock) |
| 450 | } |
| 451 | } |
| 452 | .overlay(alignment: .bottomLeading) { |
| 453 | VStack(alignment: .leading, spacing: 4) { |
| 454 | TileAudioNotices(info) |
| 455 | HStack(spacing: 5) { |
| 456 | TileSpeakingName(label: label, muted: muted, compact: compact, glow: audio?.glow) |
| 457 | TileAudioStatus(info) |
| 458 | } |
| 459 | } |
| 460 | .padding(compact ? 6 : 8).padding(compact ? labelInsets : EdgeInsets()) |
| 461 | // Room past the label's end for the rim's outer breath, when a long |
| 462 | // name has it truncating. |
| 463 | .padding(.trailing, 6) |
| 464 | .tileLabelBlock($labelBlock) |
| 465 | } |
| 466 | .overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(8) } } |
| 467 | .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) |
| 468 | .accessibilityElement(children: .ignore) |
| 469 | .accessibilityLabel(stageLabel(tileAccessibilityLabel(label, muted: muted, audio: info), onStage: onStage, pinned: pinned)) |
| 470 | .accessibilityHint(info?.detail ?? "") |
| 471 | .accessibilityValue( |
| 472 | cameraOff |
| 473 | ? "Camera off" |
| 474 | : placeholder ?? (track == nil ? (screen ? "Waiting for the screen" : "Waiting for video") : "Video on")) |
| 475 | } |
| 476 | } |
| 477 | struct PhoneVideo: UIViewRepresentable { |
| 478 | let track: RTCVideoTrack |
| 479 | var fit = false |
| 480 | func makeCoordinator() -> Coordinator { Coordinator() } |
| 481 | func makeUIView(context: Context) -> PlacedVideoView { |
| 482 | let view = PlacedVideoView() |
| 483 | view.fit = fit |
| 484 | context.coordinator.attach(track, to: view.renderer) |
| 485 | return view |
| 486 | } |
| 487 | func updateUIView(_ view: PlacedVideoView, context: Context) { |
| 488 | view.fit = fit |
| 489 | context.coordinator.attach(track, to: view.renderer) |
| 490 | } |
| 491 | static func dismantleUIView(_ view: PlacedVideoView, coordinator: Coordinator) { |
| 492 | coordinator.track?.remove(view.renderer) |
| 493 | } |
| 494 | final class Coordinator { |
| 495 | var track: RTCVideoTrack? |
| 496 | func attach(_ next: RTCVideoTrack, to view: RTCMTLVideoView) { |
| 497 | if track !== next { |
| 498 | track?.remove(view) |
| 499 | track = next |
| 500 | next.add(view) |
| 501 | } |
| 502 | } |
| 503 | } |
| 504 | } |
| 505 | |
| 506 | /// The renderer at the size and place `VideoFrame` gives it: a camera |
| 507 | /// filling the tile with more of a crop taken from the bottom than the |
| 508 | /// top, so heads are not cut; a screen fitted whole. The container clips. |
| 509 | final class PlacedVideoView: UIView, RTCVideoViewDelegate { |
| 510 | let renderer = RTCMTLVideoView(frame: .zero) |
| 511 | private var videoSize = CGSize(width: 16, height: 9) |
| 512 | var fit = false { |
| 513 | didSet { if fit != oldValue { setNeedsLayout() } } |
| 514 | } |
| 515 | override init(frame: CGRect) { |
| 516 | super.init(frame: frame) |
| 517 | clipsToBounds = true |
| 518 | isUserInteractionEnabled = false |
| 519 | renderer.videoContentMode = .scaleToFill |
| 520 | renderer.delegate = self |
| 521 | addSubview(renderer) |
| 522 | } |
| 523 | required init?(coder: NSCoder) { nil } |
| 524 | nonisolated func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) { |
| 525 | DispatchQueue.main.async { [weak self] in |
| 526 | guard let self, size.width > 0, size.height > 0 else { return } |
| 527 | self.videoSize = size |
| 528 | self.setNeedsLayout() |
| 529 | } |
| 530 | } |
| 531 | override func layoutSubviews() { |
| 532 | super.layoutSubviews() |
| 533 | renderer.frame = VideoFrame.place(video: videoSize, in: bounds.size, fit: fit) |
| 534 | } |
| 535 | } |
| 536 | |
| 537 | /// Speaker or Grid, and what is pinned to Speaker view's stage: the |
| 538 | /// iPhone's layout menu, in the classic call and the controls sheet. |
| 539 | struct LayoutChoices: View { |
| 540 | @ObservedObject var call: Call |
| 541 | @ObservedObject var arrangement: CallArrangement |
| 542 | let title: (StageItem) -> String |
| 543 | |
| 544 | var body: some View { |
| 545 | Picker( |
| 546 | "Video layout", selection: Binding(get: { arrangement.layout }, set: { arrangement.choose($0) }) |
| 547 | ) { |
| 548 | ForEach(VideoLayout.allCases) { layout in |
| 549 | Label(layout.title, systemImage: layout.symbol).tag(layout) |
| 550 | } |
| 551 | } |
| 552 | if arrangement.people.count > 2 || !call.screens.isEmpty { |
| 553 | Section("On the stage") { |
| 554 | Button { |
| 555 | arrangement.pin(nil) |
| 556 | } label: { |
| 557 | Label("Whoever is speaking", systemImage: arrangement.pinned == nil ? "checkmark" : "waveform") |
| 558 | } |
| 559 | ForEach(call.screens.reversed(), id: \.self) { peer in choice(.screen(peer)) } |
| 560 | ForEach(arrangement.others, id: \.self) { peer in choice(.camera(peer)) } |
| 561 | } |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | private func choice(_ item: StageItem) -> some View { |
| 566 | Button { |
| 567 | arrangement.pin(item) |
| 568 | } label: { |
| 569 | Label(title(item), systemImage: arrangement.pinned == item ? "checkmark" : "pin") |
| 570 | } |
| 571 | } |
| 572 | } |
| 573 | |