| 1 | #if os(macOS) |
| 2 | import AppKit |
| 3 | import SwiftUI |
| 4 | |
| 5 | public struct MacCallView: View { |
| 6 | @ObservedObject var call: Call |
| 7 | let leave: () -> Void |
| 8 | let rejoin: (() -> Void)? |
| 9 | @ObservedObject var arrangement: CallArrangement |
| 10 | @ObservedObject var speakerNow: SpeakerNow |
| 11 | @State var showingChat: Bool |
| 12 | /// A tile being dragged to a new place, and where every tile is. |
| 13 | @State var drag: TileDrag? |
| 14 | @State var tileFrames: [String: CGRect] = [:] |
| 15 | @State var readChatCount = 0 |
| 16 | @State var showingDevices: Bool |
| 17 | @State var showingLayout = false |
| 18 | @State var showingInvitation = false |
| 19 | @State var showingMore = false |
| 20 | @State var devicesHeight: CGFloat = 360 |
| 21 | @StateObject var chatWindow = MacChatWindow() |
| 22 | @StateObject var chrome: ControlsVisibilityModel |
| 23 | @AppStorage(CallLayoutStyle.key) private var layoutChoice = CallLayoutStyle.defaultStyle.rawValue |
| 24 | @AppStorage(PiPCorner.key) var pipCorner = PiPCorner.bottomTrailing.rawValue |
| 25 | @State var pipDrag: CGSize = .zero |
| 26 | @State var menuTracking = false |
| 27 | @State var sheetShowing = false |
| 28 | @State var dismissedNotices: Set<String> = [] |
| 29 | @State var barFrame: CGRect = .zero |
| 30 | @FocusState var focusedControl: CallControlID? |
| 31 | @Environment(\.accessibilityReduceMotion) var reduceMotion |
| 32 | @Environment(\.accessibilityVoiceOverEnabled) var voiceOver |
| 33 | @Environment(\.fixtureBackdrop) var fixtureBackdrop |
| 34 | private let style: CallLayoutStyle? |
| 35 | |
| 36 | /// - Parameters: |
| 37 | /// - grid: Grid (true) or Speaker (false) for this call, when not |
| 38 | /// the person's own choice (fixtures). |
| 39 | /// - style: the layout, when not the person's own choice (fixtures). |
| 40 | /// - controlsShown: pins the full-window controls shown or hidden |
| 41 | /// (fixtures); nil lets the pointer decide. |
| 42 | public init( |
| 43 | call: Call, grid: Bool? = nil, showingChat: Bool = false, showingDevices: Bool = false, |
| 44 | style: CallLayoutStyle? = nil, controlsShown: Bool? = nil, leave: @escaping () -> Void, |
| 45 | rejoin: (() -> Void)? = nil |
| 46 | ) { |
| 47 | self.call = call |
| 48 | self.leave = leave |
| 49 | self.rejoin = rejoin |
| 50 | self.style = style |
| 51 | if let grid { call.arrangement.show(grid ? .grid : .speaker) } |
| 52 | arrangement = call.arrangement |
| 53 | speakerNow = call.levels.speaker |
| 54 | _showingChat = State(initialValue: showingChat) |
| 55 | _showingDevices = State(initialValue: showingDevices) |
| 56 | _chrome = StateObject(wrappedValue: ControlsVisibilityModel(input: .pointer, pinned: controlsShown)) |
| 57 | } |
| 58 | |
| 59 | var layout: CallLayoutStyle { style ?? CallLayoutStyle(rawValue: layoutChoice) ?? .defaultStyle } |
| 60 | |
| 61 | public var body: some View { |
| 62 | Group { |
| 63 | if layout == .classic { |
| 64 | classic.padding(24) |
| 65 | } else { |
| 66 | immersive |
| 67 | } |
| 68 | } |
| 69 | .overlay(alignment: .bottom) { |
| 70 | CallToast(call: call).padding(.bottom, layout == .classic ? 100 : Self.barReserve + 16) |
| 71 | } |
| 72 | .onAppear { |
| 73 | if showingChat { readChatCount = call.receivedChatCount } |
| 74 | } |
| 75 | .task { await call.watchAudio() } |
| 76 | // Space mutes and unmutes, unless a text field or a control has the |
| 77 | // keyboard. The Call menu's ⌘⇧M is the same toggle, for when it has. |
| 78 | .spaceToMute(call) |
| 79 | .onChange(of: call.receivedChatCount) { _, count in |
| 80 | if showingChat || chatWindow.isOpen { readChatCount = count } |
| 81 | } |
| 82 | .onChange(of: showingChat) { _, visible in |
| 83 | if visible { readChatCount = call.receivedChatCount } |
| 84 | } |
| 85 | .onChange(of: chatWindow.isOpen) { _, visible in |
| 86 | if visible { readChatCount = call.receivedChatCount } |
| 87 | } |
| 88 | .onDisappear { chatWindow.close() } |
| 89 | .reportProblemSheet(call) |
| 90 | } |
| 91 | |
| 92 | /// The call as it was before the full-window layout: a title block, |
| 93 | /// the stage in a rounded card, and the controls in a row beneath. |
| 94 | private var classic: some View { |
| 95 | VStack(spacing: 14) { |
| 96 | HStack { |
| 97 | VStack(alignment: .leading, spacing: 4) { |
| 98 | Text(call.title).font(.title2.bold()).lineLimit(1) |
| 99 | Text(call.status).font(.callout).foregroundStyle(.secondary) |
| 100 | .accessibilityAddTraits(.updatesFrequently) |
| 101 | // Said in words as well as by the button's state: a person |
| 102 | // sharing a screen should never have to wonder whether they |
| 103 | // still are. |
| 104 | if call.sharingScreen { |
| 105 | Label("You are sharing your screen", systemImage: "rectangle.on.rectangle") |
| 106 | .font(.callout.bold()).foregroundStyle(.orange) |
| 107 | if !call.screenRecovery.isEmpty { |
| 108 | Text(call.screenRecovery).font(.callout).foregroundStyle(.secondary) |
| 109 | .accessibilityIdentifier("screenRecovery") |
| 110 | } |
| 111 | } else if !call.screenNote.isEmpty { |
| 112 | Text(call.screenNote).font(.callout).foregroundStyle(.secondary) |
| 113 | } |
| 114 | } |
| 115 | Spacer() |
| 116 | if !grid, peers.count > 1 || !call.screens.isEmpty { |
| 117 | Menu { |
| 118 | pinChoices |
| 119 | } label: { |
| 120 | Label("Pin to the stage", systemImage: arrangement.pinned == nil ? "pin" : "pin.fill") |
| 121 | } |
| 122 | .help("Pin a participant or screen to the main stage, or follow whoever is speaking") |
| 123 | } |
| 124 | layoutPicker.pickerStyle(.segmented).fixedSize() |
| 125 | } |
| 126 | if call.floor { |
| 127 | Text("Direct connections only. People on some networks may be unable to connect.") |
| 128 | .font(.callout).foregroundStyle(.secondary).frame( |
| 129 | maxWidth: .infinity, alignment: .leading) |
| 130 | } |
| 131 | HStack(alignment: .top, spacing: 16) { |
| 132 | stage.frame(maxWidth: .infinity, maxHeight: .infinity) |
| 133 | if showingDevices { |
| 134 | VStack(spacing: 0) { |
| 135 | HStack { |
| 136 | Text("Devices").font(.headline) |
| 137 | Spacer() |
| 138 | Button("Hide devices", systemImage: "xmark") { showingDevices = false } |
| 139 | .labelStyle(.iconOnly).buttonStyle(.borderless).help("Hide devices") |
| 140 | }.padding(.horizontal, 16).padding(.top, 16) |
| 141 | DevicePanel(call: call) |
| 142 | Spacer(minLength: 0) |
| 143 | }.frame(width: 300) |
| 144 | .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16)) |
| 145 | } |
| 146 | if showingChat { |
| 147 | VStack(spacing: 14) { |
| 148 | HStack { |
| 149 | Text("Chat").font(.headline) |
| 150 | Spacer() |
| 151 | Button("Open chat in a window", systemImage: "arrow.up.forward.app") { |
| 152 | showingChat = false |
| 153 | chatWindow.open(call: call) |
| 154 | }.help("Open chat in a separate window") |
| 155 | Button("Hide chat", systemImage: "xmark") { showingChat = false } |
| 156 | .help("Hide chat") |
| 157 | }.labelStyle(.iconOnly).buttonStyle(.borderless) |
| 158 | ChatView(call: call, draft: chatWindow.draft) |
| 159 | }.padding(16).frame(width: 280) |
| 160 | .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16)) |
| 161 | } |
| 162 | }.frame(maxWidth: .infinity, maxHeight: .infinity) |
| 163 | ForEach(call.state?.waiting ?? []) { peer in |
| 164 | HStack { |
| 165 | Text("\(peer.name) is waiting") |
| 166 | Spacer() |
| 167 | Button("Let in") { call.admit(peer.peer) }.buttonStyle(.borderedProminent) |
| 168 | } |
| 169 | } |
| 170 | if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } } |
| 171 | // An ended call used to stay on screen as a call with no way back |
| 172 | // into it but Leave and the lobby. |
| 173 | if call.state?.phase == "ended" { |
| 174 | HStack { |
| 175 | if let rejoin { |
| 176 | Button("Join again", action: rejoin).buttonStyle(.borderedProminent) |
| 177 | .accessibilityIdentifier("joinAgain") |
| 178 | } |
| 179 | ReportProblemButton { call.reportProblem() }.buttonStyle(.bordered) |
| 180 | } |
| 181 | } |
| 182 | controls(fullWindow: false) |
| 183 | } |
| 184 | } |
| 185 | |
| 186 | var grid: Bool { arrangement.layout == .grid } |
| 187 | |
| 188 | /// Everyone else, in this viewer's order. |
| 189 | var peers: [RosterPeer] { |
| 190 | let roster = call.state?.peers ?? [] |
| 191 | return arrangement.others.compactMap { id in roster.first { $0.peer == id } } |
| 192 | } |
| 193 | /// What Speaker view's stage shows. |
| 194 | var focused: StageItem? { arrangement.stage(active: speakerNow.peer, screens: call.screens) } |
| 195 | /// Speaker view's filmstrip: everything not on the stage. |
| 196 | var filmstrip: [StageItem] { arrangement.filmstrip(stage: focused, screens: call.screens) } |
| 197 | /// You float over the stage, rather than sit in the filmstrip, when |
| 198 | /// you would be all there is in it (see Stage.selfFloats). |
| 199 | var selfFloats: Bool { Stage.selfFloats(filmstrip: filmstrip, me: arrangement.me) } |
| 200 | /// Grid's tiles: the screens, newest first, then everyone in order. |
| 201 | var gridItems: [StageItem] { |
| 202 | call.screens.reversed().map(StageItem.screen) + arrangement.people.map(StageItem.camera) |
| 203 | } |
| 204 | |
| 205 | /// A tile for anything the call can show, as it is seen now. |
| 206 | func macTile( |
| 207 | _ item: StageItem, cornerRadius: CGFloat = 16, compact: Bool = false, labelLift: CGFloat = 0, |
| 208 | backdrop: Color = .black, onStage: Bool = false, iconOnly: Bool = false |
| 209 | ) -> MacVideoTile { |
| 210 | let pinned = onStage && arrangement.pinned == item |
| 211 | switch item { |
| 212 | case .screen(let peer): |
| 213 | return MacVideoTile( |
| 214 | track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), screen: true, |
| 215 | cornerRadius: cornerRadius, compact: compact, labelLift: labelLift, backdrop: backdrop, |
| 216 | onStage: onStage, pinned: pinned, iconOnly: iconOnly) |
| 217 | case .camera(let peer) where peer == arrangement.me: |
| 218 | return MacVideoTile( |
| 219 | track: call.localTrack, label: "You", muted: call.muted, mirrored: true, |
| 220 | cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: cornerRadius, |
| 221 | compact: compact, labelLift: labelLift, backdrop: backdrop, audio: call.selfAudio, |
| 222 | onStage: onStage, pinned: pinned, iconOnly: iconOnly) |
| 223 | case .camera(let peer): |
| 224 | return MacVideoTile( |
| 225 | track: call.remoteTracks[peer], label: call.name(of: peer), muted: call.isMuted(peer), |
| 226 | cameraOff: call.isCameraOff(peer), cornerRadius: cornerRadius, compact: compact, |
| 227 | labelLift: labelLift, backdrop: backdrop, audio: call.tileAudio(peer), onStage: onStage, |
| 228 | pinned: pinned, iconOnly: iconOnly) |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | /// The name a menu or VoiceOver uses for `item`. |
| 233 | func itemTitle(_ item: StageItem) -> String { |
| 234 | switch item { |
| 235 | case .screen(let peer): screenCaption(call.name(of: peer)) |
| 236 | case .camera(let peer): peer == arrangement.me ? "You" : call.name(of: peer) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | /// A tile of Grid or the filmstrip: dragged to a new place, pinned by |
| 241 | /// a click (the filmstrip) or its menu, and both said to VoiceOver. |
| 242 | func arrangeable(_ item: StageItem, tapToPin: Bool) -> ArrangeableTile { |
| 243 | ArrangeableTile( |
| 244 | item: item, title: itemTitle(item), arrangement: arrangement, drag: $drag, |
| 245 | frames: tileFrames, tapToPin: tapToPin, touch: false, reduceMotion: reduceMotion, |
| 246 | fixtureLift: item == .camera(arrangement.fixtureLift ?? "") ? CGSize(width: 36, height: -22) : nil) |
| 247 | } |
| 248 | |
| 249 | var layoutPicker: some View { |
| 250 | Picker( |
| 251 | "Video layout", |
| 252 | selection: Binding(get: { arrangement.layout }, set: { arrangement.choose($0) }) |
| 253 | ) { |
| 254 | ForEach(VideoLayout.allCases) { layout in |
| 255 | Label(layout.title, systemImage: layout.symbol).tag(layout) |
| 256 | } |
| 257 | } |
| 258 | } |
| 259 | |
| 260 | /// Automatic, then every screen and every person, as pin choices. |
| 261 | @ViewBuilder var pinChoices: some View { |
| 262 | Button { |
| 263 | arrangement.pin(nil) |
| 264 | } label: { |
| 265 | Label( |
| 266 | "Whoever is speaking", |
| 267 | systemImage: arrangement.pinned == nil ? "checkmark" : "waveform") |
| 268 | } |
| 269 | ForEach(call.screens.reversed(), id: \.self) { peer in |
| 270 | pinChoice(.screen(peer)) |
| 271 | } |
| 272 | ForEach(arrangement.people, id: \.self) { peer in |
| 273 | pinChoice(.camera(peer)) |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | private func pinChoice(_ item: StageItem) -> some View { |
| 278 | Button { |
| 279 | arrangement.pin(item) |
| 280 | } label: { |
| 281 | Label(itemTitle(item), systemImage: arrangement.pinned == item ? "checkmark" : "pin") |
| 282 | } |
| 283 | } |
| 284 | |
| 285 | var stageShowsScreen: Bool { |
| 286 | if case .screen = focused { return true } |
| 287 | return false |
| 288 | } |
| 289 | |
| 290 | @ViewBuilder private var stage: some View { |
| 291 | if grid { |
| 292 | ScrollView { |
| 293 | LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) { |
| 294 | ForEach(gridItems, id: \.self) { item in |
| 295 | macTile(item) |
| 296 | .aspectRatio(16 / 9, contentMode: .fit) |
| 297 | .modifier(arrangeable(item, tapToPin: false)) |
| 298 | } |
| 299 | } |
| 300 | } |
| 301 | .coordinateSpace(name: arrangeSpace) |
| 302 | .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 } |
| 303 | } else { |
| 304 | GeometryReader { geometry in |
| 305 | ZStack(alignment: .bottomTrailing) { |
| 306 | if let focused { |
| 307 | macTile(focused, onStage: true) |
| 308 | .overlay(alignment: .topTrailing) { |
| 309 | if arrangement.pinned == focused { |
| 310 | PinBadge(title: itemTitle(focused)) { arrangement.pin(nil) }.padding(10) |
| 311 | } |
| 312 | } |
| 313 | } else { |
| 314 | ContentUnavailableView( |
| 315 | "Waiting for others", systemImage: "person.2", |
| 316 | description: Text("Your meeting is ready. Others will appear here when they join.") |
| 317 | ) |
| 318 | .frame(maxWidth: .infinity, maxHeight: .infinity) |
| 319 | .background(CallsAppearance.background, in: RoundedRectangle(cornerRadius: 16)) |
| 320 | } |
| 321 | // Everyone not on the stage, as thumbnails up its trailing |
| 322 | // edge; in a call of two, just you. |
| 323 | let strip = selfFloats ? [StageItem.camera(arrangement.me ?? "")] : filmstrip |
| 324 | let size = thumbnail(count: strip.count, in: geometry.size) |
| 325 | VStack(spacing: 10) { |
| 326 | ForEach(strip, id: \.self) { item in |
| 327 | macTile(item) |
| 328 | .frame(width: size.width, height: size.height) |
| 329 | .modifier(arrangeable(item, tapToPin: !selfFloats)) |
| 330 | } |
| 331 | } |
| 332 | .shadow(color: .black.opacity(0.25), radius: 8, y: 3).padding(14) |
| 333 | } |
| 334 | .coordinateSpace(name: arrangeSpace) |
| 335 | .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 } |
| 336 | } |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | /// A thumbnail's size, when `count` of them stack up the stage's |
| 341 | /// trailing edge: the size one always had, until there are enough of |
| 342 | /// them that it would run off the top. |
| 343 | func thumbnail(count: Int, in stage: CGSize) -> CGSize { |
| 344 | let room = (stage.height - 28 - CGFloat(count - 1) * 10) / CGFloat(max(count, 1)) |
| 345 | let height = max(60, min(135, stage.height * 0.3, room)) |
| 346 | return CGSize(width: min(180, stage.width * 0.3, height * 4 / 3), height: height) |
| 347 | } |
| 348 | |
| 349 | /// The controls this person's bar holds, in order (CallBarFit). |
| 350 | var barControls: [CallBarFit.Control] { |
| 351 | CallBarFit.Control.allCases.filter { $0 != .link || call.standing == "member" } |
| 352 | } |
| 353 | |
| 354 | /// The call's buttons, in the classic row or the full-window bar. The |
| 355 | /// bar adds the layout menu (the classic view has its picker above the |
| 356 | /// stage) and tells the controls' visibility when one has focus. |
| 357 | /// `fit` is the full-window bar's, which may tighten them and move |
| 358 | /// some into ⋯ (CallBarFit); the classic row has none and shows |
| 359 | /// everything. |
| 360 | func controls(fullWindow: Bool, fit: CallBarFit? = nil) -> some View { |
| 361 | HStack(spacing: fit?.spacing.between ?? (fullWindow ? 10 : 12)) { |
| 362 | Button( |
| 363 | call.muted ? "Unmute microphone" : "Mute microphone", |
| 364 | glyph: call.muted ? "mic.slash.fill" : "mic.fill" |
| 365 | ) { call.mute() } |
| 366 | .buttonStyle( |
| 367 | CallActionStyle( |
| 368 | color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent, |
| 369 | emphasized: call.muted, overlay: tone(fullWindow, call.muted ? .off : .normal)) |
| 370 | ) |
| 371 | .help(call.muted ? "Unmute microphone" : "Mute microphone") |
| 372 | .accessibilityIdentifier("mute") |
| 373 | .focused($focusedControl, equals: .mute) |
| 374 | .modifier(CallBarEndProbe(last: false)) |
| 375 | Button( |
| 376 | call.cameraOff ? "Turn camera on" : "Turn camera off", |
| 377 | glyph: call.cameraOff ? "video.slash.fill" : "video.fill" |
| 378 | ) { call.toggleCamera() } |
| 379 | .buttonStyle( |
| 380 | CallActionStyle( |
| 381 | color: call.cameraOff ? Color(red: 0.65, green: 0.30, blue: 0.02) : .blue, |
| 382 | emphasized: call.cameraOff, overlay: tone(fullWindow, call.cameraOff ? .off : .normal)) |
| 383 | ) |
| 384 | .help(call.cameraOff ? "Turn camera on" : "Turn camera off") |
| 385 | .accessibilityIdentifier("camera") |
| 386 | .focused($focusedControl, equals: .camera) |
| 387 | if fit?.shows(.share) ?? true { |
| 388 | Button( |
| 389 | call.sharingScreen ? "Stop sharing your screen" : "Share your screen", |
| 390 | glyph: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle" |
| 391 | ) { |
| 392 | if call.sharingScreen { |
| 393 | call.stopSharingScreen() |
| 394 | } else { |
| 395 | Task { await call.shareScreen(MacScreenCapture()) } |
| 396 | } |
| 397 | } |
| 398 | .buttonStyle( |
| 399 | CallActionStyle( |
| 400 | color: .orange, emphasized: call.sharingScreen, |
| 401 | overlay: tone(fullWindow, call.sharingScreen ? .on : .normal))) |
| 402 | .help(call.sharingScreen ? "Stop sharing your screen" : "Share a window or your whole screen") |
| 403 | .disabled(call.state?.phase != "admitted") |
| 404 | .accessibilityIdentifier("shareScreen") |
| 405 | .focused($focusedControl, equals: .share) |
| 406 | } |
| 407 | if fit?.shows(.devices) ?? true { |
| 408 | Button("Devices", glyph: "slider.horizontal.3") { |
| 409 | withAnimation(panelAnimation) { showingDevices.toggle() } |
| 410 | } |
| 411 | .buttonStyle( |
| 412 | CallActionStyle( |
| 413 | color: .teal, emphasized: showingDevices, |
| 414 | overlay: tone(fullWindow, showingDevices ? .on : .normal))) |
| 415 | .help("Choose the camera, microphone and speaker for this call") |
| 416 | .accessibilityIdentifier("devices") |
| 417 | .focused($focusedControl, equals: .devices) |
| 418 | } |
| 419 | if fullWindow, fit?.shows(.layout) ?? true { |
| 420 | layoutMenu.focused($focusedControl, equals: .layout) |
| 421 | } |
| 422 | if fit?.shows(.link) ?? true { |
| 423 | MeetingShareButton(call: call) |
| 424 | .buttonStyle(CallActionStyle(color: .indigo, overlay: tone(fullWindow, .normal))) |
| 425 | .help("Share meeting link") |
| 426 | .focused($focusedControl, equals: .link) |
| 427 | } |
| 428 | if let fit, !fit.overflow.isEmpty { |
| 429 | moreMenu(fit.overflow) |
| 430 | } |
| 431 | Spacer(minLength: fit?.spacing.gap ?? (fullWindow ? 24 : nil)) |
| 432 | Button(chatTitle, glyph: "bubble.left.and.bubble.right.fill") { |
| 433 | if chatWindow.isOpen { chatWindow.close() } |
| 434 | withAnimation(panelAnimation) { showingChat.toggle() } |
| 435 | }.buttonStyle( |
| 436 | CallActionStyle( |
| 437 | color: .purple, emphasized: showingChat || chatWindow.isOpen, |
| 438 | overlay: tone(fullWindow, showingChat || chatWindow.isOpen ? .on : .normal))) |
| 439 | .help(chatTitle) |
| 440 | .accessibilityIdentifier("openChat") |
| 441 | .focused($focusedControl, equals: .chat) |
| 442 | .overlay(alignment: .topTrailing) { |
| 443 | if unread > 0 { |
| 444 | Text("\(unread)").font(.caption.bold()).foregroundStyle(.white) |
| 445 | .padding(4).background(fullWindow ? CallOverlay.badgeFill : .purple, in: Capsule()) |
| 446 | .offset(x: 5, y: -5) |
| 447 | .accessibilityHidden(true) |
| 448 | } |
| 449 | } |
| 450 | .contextMenu { |
| 451 | Button("Open chat in a window", systemImage: "arrow.up.forward.app") { |
| 452 | showingChat = false |
| 453 | chatWindow.open(call: call) |
| 454 | } |
| 455 | } |
| 456 | Button("Leave meeting", glyph: "phone.down.fill", role: .destructive, action: leave) |
| 457 | .buttonStyle(CallActionStyle(color: .red, emphasized: true, overlay: tone(fullWindow, .leave))) |
| 458 | .help("Leave meeting") |
| 459 | .accessibilityIdentifier("leave") |
| 460 | .focused($focusedControl, equals: .leave) |
| 461 | .modifier(CallBarEndProbe(last: true)) |
| 462 | }.frame(maxWidth: .infinity) |
| 463 | } |
| 464 | |
| 465 | /// ⋯ on a bar too narrow for everything (CallBarFit): the controls |
| 466 | /// that did not fit, as menu items, in the bar's order. |
| 467 | private func moreMenu(_ overflow: [CallBarFit.Control]) -> some View { |
| 468 | Button("More controls", glyph: "ellipsis") { showingMore.toggle() } |
| 469 | .buttonStyle(CallActionStyle(color: .gray, emphasized: showingMore, overlay: showingMore ? .on : .normal)) |
| 470 | .help("More controls") |
| 471 | .accessibilityIdentifier("moreControls") |
| 472 | .popover(isPresented: $showingMore, arrowEdge: .top) { |
| 473 | VStack(alignment: .leading, spacing: 10) { |
| 474 | ForEach(overflow, id: \.self) { control in |
| 475 | moreItem(control) |
| 476 | } |
| 477 | // Spec §15.26: wherever the bar has a ⋯, "Report a problem" |
| 478 | // is in it, as it is in the ⓘ panel and the Call menu. |
| 479 | Divider() |
| 480 | Button(ProblemWords.title, systemImage: "exclamationmark.bubble") { |
| 481 | showingMore = false |
| 482 | // The popover goes first: a sheet asked for while it is |
| 483 | // still closing is not shown (DevicePanel's same wait). |
| 484 | DispatchQueue.main.asyncAfter(deadline: .now() + DevicePanel.handOver) { call.reportProblem() } |
| 485 | } |
| 486 | .accessibilityIdentifier("moreReportProblem") |
| 487 | } |
| 488 | .buttonStyle(.borderless) |
| 489 | .padding(14) |
| 490 | .frame(minWidth: 220, alignment: .leading) |
| 491 | } |
| 492 | .sheet(isPresented: $showingInvitation) { |
| 493 | MeetingInvitationView(call: call, close: { showingInvitation = false }) |
| 494 | .task { await call.loadInvitation() } |
| 495 | } |
| 496 | } |
| 497 | |
| 498 | @ViewBuilder private func moreItem(_ control: CallBarFit.Control) -> some View { |
| 499 | switch control { |
| 500 | case .share: |
| 501 | Button( |
| 502 | call.sharingScreen ? "Stop sharing your screen" : "Share your screen", |
| 503 | systemImage: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle" |
| 504 | ) { |
| 505 | showingMore = false |
| 506 | if call.sharingScreen { |
| 507 | call.stopSharingScreen() |
| 508 | } else { |
| 509 | Task { await call.shareScreen(MacScreenCapture()) } |
| 510 | } |
| 511 | } |
| 512 | .disabled(call.state?.phase != "admitted") |
| 513 | case .devices: |
| 514 | Button(showingDevices ? "Hide devices" : "Devices", systemImage: "slider.horizontal.3") { |
| 515 | showingMore = false |
| 516 | withAnimation(panelAnimation) { showingDevices.toggle() } |
| 517 | } |
| 518 | case .layout: |
| 519 | layoutPicker.pickerStyle(.segmented).labelsHidden().fixedSize() |
| 520 | if !grid, peers.count > 1 || !call.screens.isEmpty { |
| 521 | Text("On the stage").font(.headline) |
| 522 | VStack(alignment: .leading, spacing: 6) { pinChoices } |
| 523 | } |
| 524 | case .link: |
| 525 | Button("Share meeting link", systemImage: "square.and.arrow.up") { |
| 526 | showingMore = false |
| 527 | showingInvitation = true |
| 528 | } |
| 529 | default: |
| 530 | EmptyView() |
| 531 | } |
| 532 | } |
| 533 | |
| 534 | /// Speaker or Grid, and in Speaker, what is pinned to the stage: the |
| 535 | /// classic view's picker and pin menu, folded into one bar button and |
| 536 | /// the small popover it opens. |
| 537 | private var layoutMenu: some View { |
| 538 | Button( |
| 539 | "Video layout", glyph: arrangement.layout.symbol |
| 540 | ) { showingLayout.toggle() } |
| 541 | .buttonStyle(CallActionStyle(color: .gray, emphasized: showingLayout, overlay: showingLayout ? .on : .normal)) |
| 542 | .help("Video layout: Speaker or Grid, and who is pinned to the stage") |
| 543 | .accessibilityIdentifier("videoLayout") |
| 544 | .popover(isPresented: $showingLayout, arrowEdge: .top) { |
| 545 | VStack(alignment: .leading, spacing: 12) { |
| 546 | layoutPicker.pickerStyle(.segmented).labelsHidden().fixedSize() |
| 547 | if peers.count > 1 || !call.screens.isEmpty { |
| 548 | Text("On the stage").font(.headline) |
| 549 | VStack(alignment: .leading, spacing: 6) { pinChoices } |
| 550 | .buttonStyle(.borderless) |
| 551 | } |
| 552 | } |
| 553 | .padding(14) |
| 554 | .frame(minWidth: 220, alignment: .leading) |
| 555 | } |
| 556 | } |
| 557 | |
| 558 | /// The full-window bar's look for a button in `state`; the classic |
| 559 | /// row keeps its own colours. |
| 560 | private func tone(_ fullWindow: Bool, _ state: CallOverlayButtonStyle.Tone) |
| 561 | -> CallOverlayButtonStyle.Tone? |
| 562 | { |
| 563 | fullWindow ? state : nil |
| 564 | } |
| 565 | |
| 566 | var panelAnimation: Animation? { |
| 567 | reduceMotion ? .linear(duration: 0.1) : .easeOut(duration: 0.2) |
| 568 | } |
| 569 | |
| 570 | var unread: Int { max(0, call.receivedChatCount - readChatCount) } |
| 571 | var chatTitle: String { |
| 572 | chatWindow.isOpen |
| 573 | ? "Move chat into the sidebar" |
| 574 | : unread > 0 ? "Chat (\(unread) new)" : showingChat ? "Hide chat" : "Show chat" |
| 575 | } |
| 576 | } |
| 577 | #endif |
| 578 | |