| 1 | @_spi(Fixtures) import CallsKit |
| 2 | import SwiftUI |
| 3 | |
| 4 | /// What the controls sheet was closed in order to open. |
| 5 | enum MoreAction { case devices, chat, report } |
| 6 | |
| 7 | /// The full-screen call: the picture under the status bar and the home |
| 8 | /// indicator, both of which go with the controls. A tap shows the |
| 9 | /// controls and another hides them; they go on their own 3 s after the |
| 10 | /// last touch (see ControlsVisibility for why that long, and what keeps |
| 11 | /// them up). The bar is a controls menu on the left and End call on the |
| 12 | /// right; everything else is in the menu. |
| 13 | extension PhoneCall { |
| 14 | /// The round buttons' size, and the bar's height with its margin. |
| 15 | static let button: CGFloat = 56 |
| 16 | static let barHeight: CGFloat = 56 + 16 |
| 17 | static let tileBackdrop = Color(white: 0.13) |
| 18 | |
| 19 | var fullScreen: some View { |
| 20 | GeometryReader { geometry in |
| 21 | let safe = geometry.safeAreaInsets |
| 22 | let size = CGSize( |
| 23 | width: geometry.size.width + safe.leading + safe.trailing, |
| 24 | height: geometry.size.height + safe.top + safe.bottom) |
| 25 | ZStack { |
| 26 | Color.black |
| 27 | stage(in: size, safe: safe) |
| 28 | .contentShape(Rectangle()) |
| 29 | .onTapGesture { chrome.tapped() } |
| 30 | .accessibilityAddTraits(.allowsDirectInteraction) |
| 31 | if !grid, selfFloats { selfView(in: size, safe: safe) } |
| 32 | topOverlay(safe: safe) |
| 33 | // Gone rather than transparent while hidden, so nothing invisible |
| 34 | // can be tapped or found; the fade is the transition. |
| 35 | if chrome.visible { bar(safe: safe).transition(.opacity) } |
| 36 | // Space on a hardware keyboard, as the classic bar's Mute has it; |
| 37 | // the full-screen Mute is in the menu, so this stands in for it. |
| 38 | Button("Mute or unmute microphone") { call.mute() } |
| 39 | .keyboardShortcut(.space, modifiers: []) |
| 40 | .opacity(0).frame(width: 0, height: 0).accessibilityHidden(true) |
| 41 | } |
| 42 | .frame(width: size.width, height: size.height) |
| 43 | .ignoresSafeArea() |
| 44 | } |
| 45 | .toolbar(.hidden, for: .navigationBar) |
| 46 | .statusBarHidden(!chrome.visible) |
| 47 | .persistentSystemOverlays(chrome.visible ? .automatic : .hidden) |
| 48 | .animation(fade, value: chrome.visible) |
| 49 | .onAppear { updateHolds() } |
| 50 | .onChange(of: showingMore) { updateHolds() } |
| 51 | .onChange(of: showingDevices) { updateHolds() } |
| 52 | .onChange(of: showingChat) { updateHolds() } |
| 53 | .onChange(of: voiceOver) { updateHolds() } |
| 54 | .sheet( |
| 55 | isPresented: $showingMore, |
| 56 | onDismiss: { |
| 57 | switch afterMore { |
| 58 | case .devices?: showingDevices = true |
| 59 | case .chat?: |
| 60 | readChatCount = call.receivedChatCount |
| 61 | showingChat = true |
| 62 | case .report?: call.reportProblem() |
| 63 | case nil: break |
| 64 | } |
| 65 | afterMore = nil |
| 66 | } |
| 67 | ) { |
| 68 | MoreControls( |
| 69 | call: call, arrangement: arrangement, title: itemTitle, unread: unread, |
| 70 | open: { action in |
| 71 | afterMore = action |
| 72 | showingMore = false |
| 73 | }, |
| 74 | close: { showingMore = false } |
| 75 | ) |
| 76 | .presentationDetents([.medium, .large]) |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | private var fade: Animation { |
| 81 | ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion) |
| 82 | } |
| 83 | |
| 84 | private func updateHolds() { |
| 85 | chrome.set(.menuOpen, showingMore || showingDevices || showingChat) |
| 86 | chrome.set(.voiceOver, voiceOver) |
| 87 | } |
| 88 | |
| 89 | // MARK: Stage |
| 90 | |
| 91 | @ViewBuilder private func stage(in size: CGSize, safe: EdgeInsets) -> some View { |
| 92 | Group { |
| 93 | if let fixtureBackdrop { |
| 94 | fixtureBackdrop.view.accessibilityHidden(true).accessibilityIdentifier("callStage") |
| 95 | } else if grid { |
| 96 | grid(in: size, safe: safe).accessibilityIdentifier("callStage") |
| 97 | } else { |
| 98 | speakerStage(in: size, safe: safe) |
| 99 | } |
| 100 | } |
| 101 | .coordinateSpace(name: arrangeSpace) |
| 102 | .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 } |
| 103 | } |
| 104 | |
| 105 | /// Speaker view: the stage fills the whole screen, edge to edge, and |
| 106 | /// everyone else is a filmstrip floating over its bottom that scrolls |
| 107 | /// sideways, in this viewer's order (2026-10-05: until then the stage |
| 108 | /// stopped above the filmstrip, a black band behind it). The filmstrip |
| 109 | /// sits just above the home indicator and rises clear of the bar while |
| 110 | /// the controls show, as the self view does; the stage's name rides just |
| 111 | /// above it (StageLabel). In a call of two there is no filmstrip: the |
| 112 | /// other person fills the screen and you float. |
| 113 | private func speakerStage(in size: CGSize, safe: EdgeInsets) -> some View { |
| 114 | let strip = selfFloats ? [] : filmstrip |
| 115 | let thumb = Self.filmstripTile(in: size) |
| 116 | let band = strip.isEmpty ? 0 : thumb.height + 2 * Self.filmstripPadding |
| 117 | let raised = chrome.visible ? Self.barHeight : 0 |
| 118 | let lift = EdgeInsets( |
| 119 | top: 0, leading: safe.leading, |
| 120 | bottom: safe.bottom + StageLabel.lift(bar: raised, band: band), |
| 121 | trailing: 0) |
| 122 | return ZStack(alignment: .bottom) { |
| 123 | Group { |
| 124 | if let focused { |
| 125 | phoneTile(focused, cornerRadius: 0, compact: true, labelInsets: lift, onStage: true) |
| 126 | } else { |
| 127 | ContentUnavailableView( |
| 128 | "Waiting for others", systemImage: "person.2", |
| 129 | description: Text("Others will appear here when they join.") |
| 130 | ) |
| 131 | .foregroundStyle(.white.opacity(0.85)) |
| 132 | .environment(\.colorScheme, .dark) |
| 133 | .frame(maxWidth: .infinity, maxHeight: .infinity) |
| 134 | } |
| 135 | } |
| 136 | // The stage, and not the filmstrip, is what the UI tests tap to |
| 137 | // bring the controls back. |
| 138 | .accessibilityIdentifier("callStage") |
| 139 | .frame(maxWidth: .infinity, maxHeight: .infinity) |
| 140 | // The pin, over the stage's top corner below the title; outside |
| 141 | // the stage's own accessibility identifier, so it keeps its own. |
| 142 | if let focused, arrangement.pinned == focused { |
| 143 | PinBadge(title: itemTitle(focused)) { arrangement.pin(nil) } |
| 144 | .padding(.top, max(safe.top, 12) + 60).padding(.trailing, safe.trailing + 12) |
| 145 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) |
| 146 | } |
| 147 | if !strip.isEmpty { |
| 148 | ScrollView(.horizontal) { |
| 149 | HStack(spacing: 6) { |
| 150 | ForEach(strip, id: \.self) { item in |
| 151 | phoneTile(item, cornerRadius: 10, compact: true, backdrop: Self.tileBackdrop, iconOnly: true) |
| 152 | .frame(width: thumb.width, height: thumb.height) |
| 153 | .modifier(arrangeable(item, tapToPin: true)) |
| 154 | } |
| 155 | } |
| 156 | .padding(.horizontal, 10).padding(.vertical, Self.filmstripPadding) |
| 157 | .frame(minWidth: size.width - safe.leading - safe.trailing) |
| 158 | } |
| 159 | .scrollIndicators(.never) |
| 160 | .accessibilityElement(children: .contain) |
| 161 | .accessibilityLabel("Filmstrip") |
| 162 | .accessibilityIdentifier("filmstrip") |
| 163 | .padding(.leading, safe.leading).padding(.trailing, safe.trailing) |
| 164 | .padding(.bottom, safe.bottom + raised) |
| 165 | .animation(ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion), value: chrome.visible) |
| 166 | } |
| 167 | } |
| 168 | } |
| 169 | |
| 170 | static let filmstripPadding: CGFloat = 6 |
| 171 | |
| 172 | /// A filmstrip tile: upright 3:4 in portrait, as a phone's camera sees |
| 173 | /// a face, 4:3 in landscape; about an eighth of the screen's height. |
| 174 | static func filmstripTile(in size: CGSize) -> CGSize { |
| 175 | if size.height >= size.width { |
| 176 | let height = min(132, max(96, (size.height / 7).rounded())) |
| 177 | return CGSize(width: (height * 3 / 4).rounded(), height: height) |
| 178 | } |
| 179 | let height = min(96, max(64, (size.height / 5).rounded())) |
| 180 | return CGSize(width: (height * 4 / 3).rounded(), height: height) |
| 181 | } |
| 182 | |
| 183 | /// Every tile as large as fits inside the safe area, scrolling only |
| 184 | /// once they would be too small to see. |
| 185 | private func grid(in size: CGSize, safe: EdgeInsets) -> some View { |
| 186 | let items = gridItems |
| 187 | let count = items.count |
| 188 | let area = CGSize( |
| 189 | width: size.width - safe.leading - safe.trailing - 8, |
| 190 | height: size.height - safe.top - safe.bottom - 8) |
| 191 | let aspect: CGFloat = area.width > area.height ? 4 / 3 : 3 / 4 |
| 192 | let columns = GridFit.columns(count: count, in: area, aspect: aspect) |
| 193 | let fitted = GridFit.tile(count: count, columns: columns, in: area, aspect: aspect) |
| 194 | let tile = fitted.width >= 110 ? fitted : CGSize(width: 110, height: 110 / aspect) |
| 195 | let across = fitted.width >= 110 ? columns : max(1, Int((area.width + 4) / (tile.width + 4))) |
| 196 | return ScrollView { |
| 197 | LazyVGrid( |
| 198 | columns: Array(repeating: GridItem(.fixed(tile.width), spacing: 4), count: across), |
| 199 | spacing: 4 |
| 200 | ) { |
| 201 | ForEach(items, id: \.self) { item in |
| 202 | phoneTile(item, cornerRadius: 8, compact: true, backdrop: Self.tileBackdrop) |
| 203 | .frame(width: tile.width, height: tile.height) |
| 204 | .modifier(arrangeable(item, tapToPin: false)) |
| 205 | } |
| 206 | } |
| 207 | .padding(4) |
| 208 | .frame(minHeight: area.height + 8) |
| 209 | } |
| 210 | .scrollIndicators(.never) |
| 211 | .padding(safe) |
| 212 | } |
| 213 | |
| 214 | // MARK: Self view |
| 215 | |
| 216 | /// You, small and floating: dragged anywhere, settling in the nearest |
| 217 | /// corner, above the bar while it shows. Muted is said on it always, |
| 218 | /// since the Mute button is a tap away inside the menu. |
| 219 | private func selfView(in size: CGSize, safe: EdgeInsets) -> some View { |
| 220 | let corner = PiPCorner(rawValue: pipCorner) ?? .bottomTrailing |
| 221 | let pip = PiPLayout.size(in: size, touch: true) |
| 222 | let insets = EdgeInsets( |
| 223 | top: safe.top, leading: safe.leading, |
| 224 | bottom: safe.bottom + (chrome.visible ? Self.barHeight : 0), trailing: safe.trailing) |
| 225 | let frame = PiPLayout.frame(corner: corner, size: pip, in: size, insets: insets) |
| 226 | return PhoneVideoTile( |
| 227 | track: call.localTrack, label: "You", muted: call.muted, mirrored: true, |
| 228 | cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: 14, |
| 229 | compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio |
| 230 | ) |
| 231 | .frame(width: pip.width, height: pip.height) |
| 232 | .shadow(color: .black.opacity(0.35), radius: 8, y: 3) |
| 233 | .position(x: frame.midX + pipDrag.width, y: frame.midY + pipDrag.height) |
| 234 | .onTapGesture { chrome.tapped() } |
| 235 | .gesture( |
| 236 | DragGesture() |
| 237 | .onChanged { pipDrag = $0.translation } |
| 238 | .onEnded { drag in |
| 239 | let dropped = CGPoint( |
| 240 | x: frame.midX + drag.translation.width, y: frame.midY + drag.translation.height) |
| 241 | let headed = CGPoint( |
| 242 | x: frame.midX + drag.predictedEndTranslation.width, |
| 243 | y: frame.midY + drag.predictedEndTranslation.height) |
| 244 | withAnimation(reduceMotion ? nil : .spring(duration: 0.3)) { |
| 245 | pipCorner = PiPLayout.corner(droppedAt: dropped, predicted: headed, in: size).rawValue |
| 246 | pipDrag = .zero |
| 247 | } |
| 248 | } |
| 249 | ) |
| 250 | .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: frame) |
| 251 | .accessibilityIdentifier("selfView") |
| 252 | .accessibilityActions { |
| 253 | ForEach(PiPCorner.allCases, id: \.self) { place in |
| 254 | Button(moveTitle(place)) { pipCorner = place.rawValue } |
| 255 | } |
| 256 | } |
| 257 | } |
| 258 | |
| 259 | private func moveTitle(_ corner: PiPCorner) -> String { |
| 260 | switch corner { |
| 261 | case .topLeading: "Move self view to top left" |
| 262 | case .topTrailing: "Move self view to top right" |
| 263 | case .bottomLeading: "Move self view to bottom left" |
| 264 | case .bottomTrailing: "Move self view to bottom right" |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | // MARK: Title and notices |
| 269 | |
| 270 | private var notices: [CallNotice] { |
| 271 | CallNotices.notices( |
| 272 | phase: call.noticePhase, status: call.status, sharingScreen: call.sharingScreen, |
| 273 | screenNote: call.screenNote, floor: call.floor, screenRecovery: call.screenRecovery) |
| 274 | } |
| 275 | |
| 276 | private func topOverlay(safe: EdgeInsets) -> some View { |
| 277 | VStack(spacing: 8) { |
| 278 | // No pill (2026-10-02, Paul approved): how the call is going is the |
| 279 | // notices' to say. A title someone chose is a small centred line at |
| 280 | // the top, inside the safe area, fading with the controls; a title |
| 281 | // Calls wrote is not shown (CallTitle). |
| 282 | if let shown = CallTitle.shown(title: call.title, named: call.named) { |
| 283 | Text(shown) |
| 284 | .font(.footnote.weight(.semibold)).foregroundStyle(.white) |
| 285 | .shadow(color: .black.opacity(0.5), radius: 2, y: 1) |
| 286 | .lineLimit(1).truncationMode(.tail) |
| 287 | .frame(maxWidth: .infinity) |
| 288 | .opacity(chrome.visible ? 1 : 0) |
| 289 | .allowsHitTesting(false) |
| 290 | .accessibilityHidden(!chrome.visible) |
| 291 | .accessibilityAddTraits(.isHeader) |
| 292 | .accessibilityIdentifier("callTitle") |
| 293 | } |
| 294 | |
| 295 | ForEach(notices.filter { $0.important && !dismissedNotices.contains($0.id) }) { notice in |
| 296 | noticeView(notice) |
| 297 | } |
| 298 | ForEach(call.state?.waiting ?? []) { person in |
| 299 | HStack(spacing: 10) { |
| 300 | Text("\(person.name) is waiting").lineLimit(2) |
| 301 | Button("Let in") { call.admit(person.peer) }.buttonStyle(.borderedProminent) |
| 302 | } |
| 303 | .modifier(PhoneNoticeSurface()) |
| 304 | } |
| 305 | // The relay warning goes with the controls; it lived in the pill. |
| 306 | ForEach(notices.filter { !$0.important }) { notice in |
| 307 | noticeView(notice) |
| 308 | .opacity(chrome.visible ? 1 : 0) |
| 309 | .allowsHitTesting(chrome.visible) |
| 310 | .accessibilityHidden(!chrome.visible) |
| 311 | } |
| 312 | } |
| 313 | .padding(.top, max(safe.top, 12) + 4) |
| 314 | .padding(.leading, safe.leading + 12).padding(.trailing, safe.trailing + 12) |
| 315 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
| 316 | } |
| 317 | |
| 318 | @ViewBuilder private func noticeView(_ notice: CallNotice) -> some View { |
| 319 | HStack(spacing: 10) { |
| 320 | switch notice.kind { |
| 321 | case .sharing: |
| 322 | Label(notice.text, systemImage: "rectangle.on.rectangle") |
| 323 | .foregroundStyle(CallOverlay.alertText).bold() |
| 324 | case .screenRecovering: |
| 325 | Label(notice.text, systemImage: "arrow.clockwise") |
| 326 | case .connecting, .reconnecting, .waitingToJoin: |
| 327 | ProgressView().tint(.white) |
| 328 | Text(notice.text) |
| 329 | case .full: |
| 330 | Text(notice.text) |
| 331 | Button("Try joining again") { call.retryJoin() } |
| 332 | case .ended: |
| 333 | Label(notice.text, systemImage: "phone.down") |
| 334 | case .problem, .floor: |
| 335 | Label(notice.text, systemImage: "exclamationmark.triangle.fill") |
| 336 | } |
| 337 | if let action = notice.action { |
| 338 | // The same stop as the ⋯ sheet's, which ends the broadcast. |
| 339 | NoticeActionButton(action) { call.perform(action) } |
| 340 | } |
| 341 | if notice.dismissible { |
| 342 | Button("Dismiss", systemImage: "xmark") { dismissedNotices.insert(notice.id) } |
| 343 | .labelStyle(.iconOnly) |
| 344 | } |
| 345 | } |
| 346 | .modifier(PhoneNoticeSurface()) |
| 347 | } |
| 348 | |
| 349 | // MARK: Bar |
| 350 | |
| 351 | private func bar(safe: EdgeInsets) -> some View { |
| 352 | HStack(spacing: 14) { |
| 353 | Button { |
| 354 | showingMore = true |
| 355 | } label: { |
| 356 | Label { |
| 357 | Text("More controls") |
| 358 | } icon: { |
| 359 | CallGlyph("ellipsis", rotation: .degrees(90)) |
| 360 | } |
| 361 | .labelStyle(.iconOnly) |
| 362 | } |
| 363 | .buttonStyle(RoundCallButtonStyle(.normal)) |
| 364 | .overlay(alignment: .topTrailing) { |
| 365 | if unread > 0 { |
| 366 | Text("\(unread)").font(.caption.bold()).foregroundStyle(.white) |
| 367 | .padding(5).background(CallOverlay.badgeFill, in: Capsule()).accessibilityHidden(true) |
| 368 | } |
| 369 | } |
| 370 | .accessibilityLabel(unread > 0 ? "More controls, \(unread) new chat messages" : "More controls") |
| 371 | .accessibilityIdentifier("moreControls") |
| 372 | if micBesideMenu { |
| 373 | Button( |
| 374 | call.muted ? "Unmute microphone" : "Mute microphone", |
| 375 | glyph: call.muted ? "mic.slash.fill" : "mic.fill" |
| 376 | ) { call.mute() } |
| 377 | .labelStyle(.iconOnly) |
| 378 | .buttonStyle(RoundCallButtonStyle(call.muted ? .off : .normal)) |
| 379 | .accessibilityIdentifier("mute") |
| 380 | } |
| 381 | Spacer(minLength: 0) |
| 382 | Button("End call", glyph: "phone.down.fill", role: .destructive, action: leave) |
| 383 | .labelStyle(.iconOnly) |
| 384 | .buttonStyle(RoundCallButtonStyle(.leave)) |
| 385 | .accessibilityIdentifier("leave") |
| 386 | } |
| 387 | .padding(.leading, safe.leading + 20).padding(.trailing, safe.trailing + 20) |
| 388 | .padding(.bottom, max(safe.bottom, 16) + 4) |
| 389 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) |
| 390 | .background(alignment: .bottom) { |
| 391 | // Enough shade under the buttons to read them over a white wall. |
| 392 | LinearGradient(colors: [.clear, .black.opacity(0.35)], startPoint: .top, endPoint: .bottom) |
| 393 | .frame(height: Self.barHeight + safe.bottom + 40) |
| 394 | .allowsHitTesting(false) |
| 395 | } |
| 396 | } |
| 397 | } |
| 398 | |
| 399 | /// A round call button over the picture: a borderless glass circle with a |
| 400 | /// white symbol, or filled for End call's red and a muted microphone's |
| 401 | /// amber (see CallOverlay). |
| 402 | struct RoundCallButtonStyle: ButtonStyle { |
| 403 | let tone: CallOverlayButtonStyle.Tone |
| 404 | init(_ tone: CallOverlayButtonStyle.Tone) { self.tone = tone } |
| 405 | func makeBody(configuration: Configuration) -> some View { |
| 406 | configuration.label.font(.title2.weight(.semibold)) |
| 407 | .modifier(CallGlyphSizing(.round)) |
| 408 | .foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white) |
| 409 | .frame(width: PhoneCall.button, height: PhoneCall.button) |
| 410 | .background { |
| 411 | switch tone { |
| 412 | case .normal: Color.clear.callOverlaySurface(Circle()) |
| 413 | case .on: Circle().fill(CallOverlay.onFill) |
| 414 | case .off: Circle().fill(CallOverlay.offFill) |
| 415 | case .leave: Circle().fill(CallOverlay.leaveFill) |
| 416 | } |
| 417 | } |
| 418 | .opacity(configuration.isPressed ? 0.7 : 1) |
| 419 | .contentShape(Circle()) |
| 420 | } |
| 421 | } |
| 422 | |
| 423 | /// A notice over the picture: the overlay's borderless glass, white text, |
| 424 | /// and dark-appearance controls whatever the system's. |
| 425 | struct PhoneNoticeSurface: ViewModifier { |
| 426 | func body(content: Content) -> some View { |
| 427 | content.font(.callout).foregroundStyle(.white) |
| 428 | .environment(\.colorScheme, .dark) |
| 429 | .padding(.horizontal, 14).padding(.vertical, 8) |
| 430 | // No Liquid Glass rim: see NoticeSurface on the Mac. |
| 431 | .callOverlaySurface(RoundedRectangle(cornerRadius: 16, style: .continuous), rim: false) |
| 432 | .shadow(color: .black.opacity(0.25), radius: 8, y: 2) |
| 433 | } |
| 434 | } |
| 435 | |
| 436 | /// The controls menu: everything but End call, each a labelled button. |
| 437 | /// A sheet rather than a pop-up menu, so it can hold the system's screen |
| 438 | /// broadcast picker and a toggle, and show at a glance what is on. |
| 439 | struct MoreControls: View { |
| 440 | @ObservedObject var call: Call |
| 441 | @ObservedObject var arrangement: CallArrangement |
| 442 | let title: (StageItem) -> String |
| 443 | let unread: Int |
| 444 | let open: (MoreAction) -> Void |
| 445 | let close: () -> Void |
| 446 | @StateObject private var micModes = MicrophoneModes() |
| 447 | |
| 448 | private let columns = [GridItem(.adaptive(minimum: 96), spacing: 12)] |
| 449 | |
| 450 | var body: some View { |
| 451 | NavigationStack { |
| 452 | ScrollView { |
| 453 | VStack(alignment: .leading, spacing: 20) { |
| 454 | LazyVGrid(columns: columns, spacing: 16) { |
| 455 | tile(call.muted ? "Unmute" : "Mute") { |
| 456 | Button( |
| 457 | call.muted ? "Unmute microphone" : "Mute microphone", |
| 458 | glyph: call.muted ? "mic.slash.fill" : "mic.fill" |
| 459 | ) { call.mute() } |
| 460 | .buttonStyle( |
| 461 | CallActionStyle( |
| 462 | color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent, |
| 463 | emphasized: call.muted)) |
| 464 | .accessibilityIdentifier("mute") |
| 465 | } |
| 466 | tile(call.cameraOff ? "Camera on" : "Camera off") { |
| 467 | Button( |
| 468 | call.cameraOff ? "Turn camera on" : "Turn camera off", |
| 469 | glyph: call.cameraOff ? "video.slash.fill" : "video.fill" |
| 470 | ) { call.toggleCamera() } |
| 471 | .buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff)) |
| 472 | .accessibilityIdentifier("camera") |
| 473 | } |
| 474 | tile("Flip") { |
| 475 | Button("Flip camera", glyph: "arrow.triangle.2.circlepath.camera") { |
| 476 | if let next = CameraFlip.next(from: call.currentCamera, in: call.cameras) { |
| 477 | Task { await call.useCamera(next.id) } |
| 478 | } |
| 479 | } |
| 480 | .buttonStyle(CallActionStyle(color: .blue)) |
| 481 | .disabled(CameraFlip.next(from: call.currentCamera, in: call.cameras) == nil) |
| 482 | .accessibilityIdentifier("flipCamera") |
| 483 | } |
| 484 | tile(call.sharingScreen ? "Stop sharing" : "Share screen") { |
| 485 | if call.sharingScreen { |
| 486 | Button("Stop sharing your screen", glyph: "rectangle.on.rectangle.slash") { |
| 487 | call.stopSharingScreen() |
| 488 | } |
| 489 | .buttonStyle(CallActionStyle(color: .orange, emphasized: true)) |
| 490 | .accessibilityIdentifier("shareScreen") |
| 491 | } else { |
| 492 | BroadcastButton().frame(width: 44, height: 44) |
| 493 | .disabled(call.state?.phase != "admitted") |
| 494 | .accessibilityIdentifier("shareScreen") |
| 495 | } |
| 496 | } |
| 497 | tile("Devices") { |
| 498 | Button("Devices", glyph: "slider.horizontal.3") { open(.devices) } |
| 499 | .buttonStyle(CallActionStyle(color: .teal)) |
| 500 | .accessibilityIdentifier("devices") |
| 501 | } |
| 502 | tile("Mic Mode") { |
| 503 | Button("Mic Mode", glyph: "waveform") { micModes.showPicker() } |
| 504 | .buttonStyle(CallActionStyle(color: .teal)) |
| 505 | .accessibilityIdentifier("micModeQuick") |
| 506 | .accessibilityHint(micModes.summary) |
| 507 | } |
| 508 | tile(arrangement.layout.title) { |
| 509 | Menu { |
| 510 | LayoutChoices(call: call, arrangement: arrangement, title: title) |
| 511 | } label: { |
| 512 | Label { |
| 513 | Text("Video layout") |
| 514 | } icon: { |
| 515 | CallGlyph(arrangement.layout.symbol) |
| 516 | } |
| 517 | } |
| 518 | .buttonStyle(CallActionStyle(color: .gray)) |
| 519 | .accessibilityIdentifier("videoLayout") |
| 520 | } |
| 521 | if call.standing == "member" { |
| 522 | tile("Share link") { |
| 523 | MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo)) |
| 524 | } |
| 525 | } |
| 526 | tile(unread > 0 ? "Chat (\(unread))" : "Chat") { |
| 527 | Button(unread == 0 ? "Chat" : "Chat (\(unread) new)", glyph: "bubble.left.and.bubble.right.fill") { |
| 528 | open(.chat) |
| 529 | } |
| 530 | .buttonStyle(CallActionStyle(color: .purple, emphasized: unread > 0)) |
| 531 | .accessibilityIdentifier("openChat") |
| 532 | } |
| 533 | } |
| 534 | // Spec §15.26: this call's diagnostics, sent rather than copied. |
| 535 | ReportProblemButton { open(.report) } |
| 536 | .disabled(!call.canReportProblem) |
| 537 | VStack(alignment: .leading, spacing: 4) { |
| 538 | Toggle("Reduce background noise", isOn: $call.reduceNoise) |
| 539 | .accessibilityIdentifier("noiseReduction") |
| 540 | Text("Takes out typing, wind and room noise before others hear you.") |
| 541 | .font(.footnote).foregroundStyle(.secondary) |
| 542 | } |
| 543 | } |
| 544 | .padding(20) |
| 545 | } |
| 546 | .navigationTitle("Call controls").navigationBarTitleDisplayMode(.inline) |
| 547 | .toolbar { |
| 548 | ToolbarItem(placement: .confirmationAction) { Button("Done", action: close) } |
| 549 | } |
| 550 | .onAppear { micModes.refresh() } |
| 551 | } |
| 552 | } |
| 553 | |
| 554 | /// A button with its caption under it. The caption is for the eye; the |
| 555 | /// button carries its own full label for VoiceOver. |
| 556 | private func tile(_ caption: String, @ViewBuilder _ content: () -> some View) -> some View { |
| 557 | VStack(spacing: 6) { |
| 558 | content() |
| 559 | Text(caption).font(.caption).foregroundStyle(.secondary).lineLimit(1) |
| 560 | .minimumScaleFactor(0.8).accessibilityHidden(true) |
| 561 | } |
| 562 | .frame(maxWidth: .infinity) |
| 563 | } |
| 564 | } |
| 565 | |