#if os(macOS) import AppKit import SwiftUI public struct MacCallView: View { @ObservedObject var call: Call let leave: () -> Void let rejoin: (() -> Void)? @ObservedObject var arrangement: CallArrangement @ObservedObject var speakerNow: SpeakerNow @State var showingChat: Bool /// A tile being dragged to a new place, and where every tile is. @State var drag: TileDrag? @State var tileFrames: [String: CGRect] = [:] @State var readChatCount = 0 @State var showingDevices: Bool @State var showingLayout = false @State var showingInvitation = false @State var showingMore = false @State var devicesHeight: CGFloat = 360 @StateObject var chatWindow = MacChatWindow() @StateObject var chrome: ControlsVisibilityModel @AppStorage(CallLayoutStyle.key) private var layoutChoice = CallLayoutStyle.defaultStyle.rawValue @AppStorage(PiPCorner.key) var pipCorner = PiPCorner.bottomTrailing.rawValue @State var pipDrag: CGSize = .zero @State var menuTracking = false @State var sheetShowing = false @State var dismissedNotices: Set = [] @State var barFrame: CGRect = .zero @FocusState var focusedControl: CallControlID? @Environment(\.accessibilityReduceMotion) var reduceMotion @Environment(\.accessibilityVoiceOverEnabled) var voiceOver @Environment(\.fixtureBackdrop) var fixtureBackdrop private let style: CallLayoutStyle? /// - Parameters: /// - grid: Grid (true) or Speaker (false) for this call, when not /// the person's own choice (fixtures). /// - style: the layout, when not the person's own choice (fixtures). /// - controlsShown: pins the full-window controls shown or hidden /// (fixtures); nil lets the pointer decide. public init( call: Call, grid: Bool? = nil, showingChat: Bool = false, showingDevices: Bool = false, style: CallLayoutStyle? = nil, controlsShown: Bool? = nil, leave: @escaping () -> Void, rejoin: (() -> Void)? = nil ) { self.call = call self.leave = leave self.rejoin = rejoin self.style = style if let grid { call.arrangement.show(grid ? .grid : .speaker) } arrangement = call.arrangement speakerNow = call.levels.speaker _showingChat = State(initialValue: showingChat) _showingDevices = State(initialValue: showingDevices) _chrome = StateObject(wrappedValue: ControlsVisibilityModel(input: .pointer, pinned: controlsShown)) } var layout: CallLayoutStyle { style ?? CallLayoutStyle(rawValue: layoutChoice) ?? .defaultStyle } public var body: some View { Group { if layout == .classic { classic.padding(24) } else { immersive } } .overlay(alignment: .bottom) { CallToast(call: call).padding(.bottom, layout == .classic ? 100 : Self.barReserve + 16) } .onAppear { if showingChat { readChatCount = call.receivedChatCount } } .task { await call.watchAudio() } // Space mutes and unmutes, unless a text field or a control has the // keyboard. The Call menu's ⌘⇧M is the same toggle, for when it has. .spaceToMute(call) .onChange(of: call.receivedChatCount) { _, count in if showingChat || chatWindow.isOpen { readChatCount = count } } .onChange(of: showingChat) { _, visible in if visible { readChatCount = call.receivedChatCount } } .onChange(of: chatWindow.isOpen) { _, visible in if visible { readChatCount = call.receivedChatCount } } .onDisappear { chatWindow.close() } .reportProblemSheet(call) } /// The call as it was before the full-window layout: a title block, /// the stage in a rounded card, and the controls in a row beneath. private var classic: some View { VStack(spacing: 14) { HStack { VStack(alignment: .leading, spacing: 4) { Text(call.title).font(.title2.bold()).lineLimit(1) Text(call.status).font(.callout).foregroundStyle(.secondary) .accessibilityAddTraits(.updatesFrequently) // Said in words as well as by the button's state: a person // sharing a screen should never have to wonder whether they // still are. if call.sharingScreen { Label("You are sharing your screen", systemImage: "rectangle.on.rectangle") .font(.callout.bold()).foregroundStyle(.orange) if !call.screenRecovery.isEmpty { Text(call.screenRecovery).font(.callout).foregroundStyle(.secondary) .accessibilityIdentifier("screenRecovery") } } else if !call.screenNote.isEmpty { Text(call.screenNote).font(.callout).foregroundStyle(.secondary) } } Spacer() if !grid, peers.count > 1 || !call.screens.isEmpty { Menu { pinChoices } label: { Label("Pin to the stage", systemImage: arrangement.pinned == nil ? "pin" : "pin.fill") } .help("Pin a participant or screen to the main stage, or follow whoever is speaking") } layoutPicker.pickerStyle(.segmented).fixedSize() } if call.floor { Text("Direct connections only. People on some networks may be unable to connect.") .font(.callout).foregroundStyle(.secondary).frame( maxWidth: .infinity, alignment: .leading) } HStack(alignment: .top, spacing: 16) { stage.frame(maxWidth: .infinity, maxHeight: .infinity) if showingDevices { VStack(spacing: 0) { HStack { Text("Devices").font(.headline) Spacer() Button("Hide devices", systemImage: "xmark") { showingDevices = false } .labelStyle(.iconOnly).buttonStyle(.borderless).help("Hide devices") }.padding(.horizontal, 16).padding(.top, 16) DevicePanel(call: call) Spacer(minLength: 0) }.frame(width: 300) .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16)) } if showingChat { VStack(spacing: 14) { HStack { Text("Chat").font(.headline) Spacer() Button("Open chat in a window", systemImage: "arrow.up.forward.app") { showingChat = false chatWindow.open(call: call) }.help("Open chat in a separate window") Button("Hide chat", systemImage: "xmark") { showingChat = false } .help("Hide chat") }.labelStyle(.iconOnly).buttonStyle(.borderless) ChatView(call: call, draft: chatWindow.draft) }.padding(16).frame(width: 280) .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16)) } }.frame(maxWidth: .infinity, maxHeight: .infinity) ForEach(call.state?.waiting ?? []) { peer in HStack { Text("\(peer.name) is waiting") Spacer() Button("Let in") { call.admit(peer.peer) }.buttonStyle(.borderedProminent) } } if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } } // An ended call used to stay on screen as a call with no way back // into it but Leave and the lobby. if call.state?.phase == "ended" { HStack { if let rejoin { Button("Join again", action: rejoin).buttonStyle(.borderedProminent) .accessibilityIdentifier("joinAgain") } ReportProblemButton { call.reportProblem() }.buttonStyle(.bordered) } } controls(fullWindow: false) } } var grid: Bool { arrangement.layout == .grid } /// Everyone else, in this viewer's order. var peers: [RosterPeer] { let roster = call.state?.peers ?? [] return arrangement.others.compactMap { id in roster.first { $0.peer == id } } } /// What Speaker view's stage shows. var focused: StageItem? { arrangement.stage(active: speakerNow.peer, screens: call.screens) } /// Speaker view's filmstrip: everything not on the stage. var filmstrip: [StageItem] { arrangement.filmstrip(stage: focused, screens: call.screens) } /// You float over the stage, rather than sit in the filmstrip, when /// you would be all there is in it (see Stage.selfFloats). var selfFloats: Bool { Stage.selfFloats(filmstrip: filmstrip, me: arrangement.me) } /// Grid's tiles: the screens, newest first, then everyone in order. var gridItems: [StageItem] { call.screens.reversed().map(StageItem.screen) + arrangement.people.map(StageItem.camera) } /// A tile for anything the call can show, as it is seen now. func macTile( _ item: StageItem, cornerRadius: CGFloat = 16, compact: Bool = false, labelLift: CGFloat = 0, backdrop: Color = .black, onStage: Bool = false, iconOnly: Bool = false ) -> MacVideoTile { let pinned = onStage && arrangement.pinned == item switch item { case .screen(let peer): return MacVideoTile( track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), screen: true, cornerRadius: cornerRadius, compact: compact, labelLift: labelLift, backdrop: backdrop, onStage: onStage, pinned: pinned, iconOnly: iconOnly) case .camera(let peer) where peer == arrangement.me: return MacVideoTile( track: call.localTrack, label: "You", muted: call.muted, mirrored: true, cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: cornerRadius, compact: compact, labelLift: labelLift, backdrop: backdrop, audio: call.selfAudio, onStage: onStage, pinned: pinned, iconOnly: iconOnly) case .camera(let peer): return MacVideoTile( track: call.remoteTracks[peer], label: call.name(of: peer), muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer), cornerRadius: cornerRadius, compact: compact, labelLift: labelLift, backdrop: backdrop, audio: call.tileAudio(peer), onStage: onStage, pinned: pinned, iconOnly: iconOnly) } } /// The name a menu or VoiceOver uses for `item`. func itemTitle(_ item: StageItem) -> String { switch item { case .screen(let peer): screenCaption(call.name(of: peer)) case .camera(let peer): peer == arrangement.me ? "You" : call.name(of: peer) } } /// A tile of Grid or the filmstrip: dragged to a new place, pinned by /// a click (the filmstrip) or its menu, and both said to VoiceOver. func arrangeable(_ item: StageItem, tapToPin: Bool) -> ArrangeableTile { ArrangeableTile( item: item, title: itemTitle(item), arrangement: arrangement, drag: $drag, frames: tileFrames, tapToPin: tapToPin, touch: false, reduceMotion: reduceMotion, fixtureLift: item == .camera(arrangement.fixtureLift ?? "") ? CGSize(width: 36, height: -22) : nil) } var layoutPicker: some View { Picker( "Video layout", selection: Binding(get: { arrangement.layout }, set: { arrangement.choose($0) }) ) { ForEach(VideoLayout.allCases) { layout in Label(layout.title, systemImage: layout.symbol).tag(layout) } } } /// Automatic, then every screen and every person, as pin choices. @ViewBuilder var pinChoices: some View { Button { arrangement.pin(nil) } label: { Label( "Whoever is speaking", systemImage: arrangement.pinned == nil ? "checkmark" : "waveform") } ForEach(call.screens.reversed(), id: \.self) { peer in pinChoice(.screen(peer)) } ForEach(arrangement.people, id: \.self) { peer in pinChoice(.camera(peer)) } } private func pinChoice(_ item: StageItem) -> some View { Button { arrangement.pin(item) } label: { Label(itemTitle(item), systemImage: arrangement.pinned == item ? "checkmark" : "pin") } } var stageShowsScreen: Bool { if case .screen = focused { return true } return false } @ViewBuilder private var stage: some View { if grid { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) { ForEach(gridItems, id: \.self) { item in macTile(item) .aspectRatio(16 / 9, contentMode: .fit) .modifier(arrangeable(item, tapToPin: false)) } } } .coordinateSpace(name: arrangeSpace) .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 } } else { GeometryReader { geometry in ZStack(alignment: .bottomTrailing) { if let focused { macTile(focused, onStage: true) .overlay(alignment: .topTrailing) { if arrangement.pinned == focused { PinBadge(title: itemTitle(focused)) { arrangement.pin(nil) }.padding(10) } } } else { ContentUnavailableView( "Waiting for others", systemImage: "person.2", description: Text("Your meeting is ready. Others will appear here when they join.") ) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(CallsAppearance.background, in: RoundedRectangle(cornerRadius: 16)) } // Everyone not on the stage, as thumbnails up its trailing // edge; in a call of two, just you. let strip = selfFloats ? [StageItem.camera(arrangement.me ?? "")] : filmstrip let size = thumbnail(count: strip.count, in: geometry.size) VStack(spacing: 10) { ForEach(strip, id: \.self) { item in macTile(item) .frame(width: size.width, height: size.height) .modifier(arrangeable(item, tapToPin: !selfFloats)) } } .shadow(color: .black.opacity(0.25), radius: 8, y: 3).padding(14) } .coordinateSpace(name: arrangeSpace) .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 } } } } /// A thumbnail's size, when `count` of them stack up the stage's /// trailing edge: the size one always had, until there are enough of /// them that it would run off the top. func thumbnail(count: Int, in stage: CGSize) -> CGSize { let room = (stage.height - 28 - CGFloat(count - 1) * 10) / CGFloat(max(count, 1)) let height = max(60, min(135, stage.height * 0.3, room)) return CGSize(width: min(180, stage.width * 0.3, height * 4 / 3), height: height) } /// The controls this person's bar holds, in order (CallBarFit). var barControls: [CallBarFit.Control] { CallBarFit.Control.allCases.filter { $0 != .link || call.standing == "member" } } /// The call's buttons, in the classic row or the full-window bar. The /// bar adds the layout menu (the classic view has its picker above the /// stage) and tells the controls' visibility when one has focus. /// `fit` is the full-window bar's, which may tighten them and move /// some into ⋯ (CallBarFit); the classic row has none and shows /// everything. func controls(fullWindow: Bool, fit: CallBarFit? = nil) -> some View { HStack(spacing: fit?.spacing.between ?? (fullWindow ? 10 : 12)) { Button( call.muted ? "Unmute microphone" : "Mute microphone", glyph: call.muted ? "mic.slash.fill" : "mic.fill" ) { call.mute() } .buttonStyle( CallActionStyle( color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent, emphasized: call.muted, overlay: tone(fullWindow, call.muted ? .off : .normal)) ) .help(call.muted ? "Unmute microphone" : "Mute microphone") .accessibilityIdentifier("mute") .focused($focusedControl, equals: .mute) .modifier(CallBarEndProbe(last: false)) Button( call.cameraOff ? "Turn camera on" : "Turn camera off", glyph: call.cameraOff ? "video.slash.fill" : "video.fill" ) { call.toggleCamera() } .buttonStyle( CallActionStyle( color: call.cameraOff ? Color(red: 0.65, green: 0.30, blue: 0.02) : .blue, emphasized: call.cameraOff, overlay: tone(fullWindow, call.cameraOff ? .off : .normal)) ) .help(call.cameraOff ? "Turn camera on" : "Turn camera off") .accessibilityIdentifier("camera") .focused($focusedControl, equals: .camera) if fit?.shows(.share) ?? true { Button( call.sharingScreen ? "Stop sharing your screen" : "Share your screen", glyph: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle" ) { if call.sharingScreen { call.stopSharingScreen() } else { Task { await call.shareScreen(MacScreenCapture()) } } } .buttonStyle( CallActionStyle( color: .orange, emphasized: call.sharingScreen, overlay: tone(fullWindow, call.sharingScreen ? .on : .normal))) .help(call.sharingScreen ? "Stop sharing your screen" : "Share a window or your whole screen") .disabled(call.state?.phase != "admitted") .accessibilityIdentifier("shareScreen") .focused($focusedControl, equals: .share) } if fit?.shows(.devices) ?? true { Button("Devices", glyph: "slider.horizontal.3") { withAnimation(panelAnimation) { showingDevices.toggle() } } .buttonStyle( CallActionStyle( color: .teal, emphasized: showingDevices, overlay: tone(fullWindow, showingDevices ? .on : .normal))) .help("Choose the camera, microphone and speaker for this call") .accessibilityIdentifier("devices") .focused($focusedControl, equals: .devices) } if fullWindow, fit?.shows(.layout) ?? true { layoutMenu.focused($focusedControl, equals: .layout) } if fit?.shows(.link) ?? true { MeetingShareButton(call: call) .buttonStyle(CallActionStyle(color: .indigo, overlay: tone(fullWindow, .normal))) .help("Share meeting link") .focused($focusedControl, equals: .link) } if let fit, !fit.overflow.isEmpty { moreMenu(fit.overflow) } Spacer(minLength: fit?.spacing.gap ?? (fullWindow ? 24 : nil)) Button(chatTitle, glyph: "bubble.left.and.bubble.right.fill") { if chatWindow.isOpen { chatWindow.close() } withAnimation(panelAnimation) { showingChat.toggle() } }.buttonStyle( CallActionStyle( color: .purple, emphasized: showingChat || chatWindow.isOpen, overlay: tone(fullWindow, showingChat || chatWindow.isOpen ? .on : .normal))) .help(chatTitle) .accessibilityIdentifier("openChat") .focused($focusedControl, equals: .chat) .overlay(alignment: .topTrailing) { if unread > 0 { Text("\(unread)").font(.caption.bold()).foregroundStyle(.white) .padding(4).background(fullWindow ? CallOverlay.badgeFill : .purple, in: Capsule()) .offset(x: 5, y: -5) .accessibilityHidden(true) } } .contextMenu { Button("Open chat in a window", systemImage: "arrow.up.forward.app") { showingChat = false chatWindow.open(call: call) } } Button("Leave meeting", glyph: "phone.down.fill", role: .destructive, action: leave) .buttonStyle(CallActionStyle(color: .red, emphasized: true, overlay: tone(fullWindow, .leave))) .help("Leave meeting") .accessibilityIdentifier("leave") .focused($focusedControl, equals: .leave) .modifier(CallBarEndProbe(last: true)) }.frame(maxWidth: .infinity) } /// ⋯ on a bar too narrow for everything (CallBarFit): the controls /// that did not fit, as menu items, in the bar's order. private func moreMenu(_ overflow: [CallBarFit.Control]) -> some View { Button("More controls", glyph: "ellipsis") { showingMore.toggle() } .buttonStyle(CallActionStyle(color: .gray, emphasized: showingMore, overlay: showingMore ? .on : .normal)) .help("More controls") .accessibilityIdentifier("moreControls") .popover(isPresented: $showingMore, arrowEdge: .top) { VStack(alignment: .leading, spacing: 10) { ForEach(overflow, id: \.self) { control in moreItem(control) } // Spec §15.26: wherever the bar has a ⋯, "Report a problem" // is in it, as it is in the ⓘ panel and the Call menu. Divider() Button(ProblemWords.title, systemImage: "exclamationmark.bubble") { showingMore = false // The popover goes first: a sheet asked for while it is // still closing is not shown (DevicePanel's same wait). DispatchQueue.main.asyncAfter(deadline: .now() + DevicePanel.handOver) { call.reportProblem() } } .accessibilityIdentifier("moreReportProblem") } .buttonStyle(.borderless) .padding(14) .frame(minWidth: 220, alignment: .leading) } .sheet(isPresented: $showingInvitation) { MeetingInvitationView(call: call, close: { showingInvitation = false }) .task { await call.loadInvitation() } } } @ViewBuilder private func moreItem(_ control: CallBarFit.Control) -> some View { switch control { case .share: Button( call.sharingScreen ? "Stop sharing your screen" : "Share your screen", systemImage: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle" ) { showingMore = false if call.sharingScreen { call.stopSharingScreen() } else { Task { await call.shareScreen(MacScreenCapture()) } } } .disabled(call.state?.phase != "admitted") case .devices: Button(showingDevices ? "Hide devices" : "Devices", systemImage: "slider.horizontal.3") { showingMore = false withAnimation(panelAnimation) { showingDevices.toggle() } } case .layout: layoutPicker.pickerStyle(.segmented).labelsHidden().fixedSize() if !grid, peers.count > 1 || !call.screens.isEmpty { Text("On the stage").font(.headline) VStack(alignment: .leading, spacing: 6) { pinChoices } } case .link: Button("Share meeting link", systemImage: "square.and.arrow.up") { showingMore = false showingInvitation = true } default: EmptyView() } } /// Speaker or Grid, and in Speaker, what is pinned to the stage: the /// classic view's picker and pin menu, folded into one bar button and /// the small popover it opens. private var layoutMenu: some View { Button( "Video layout", glyph: arrangement.layout.symbol ) { showingLayout.toggle() } .buttonStyle(CallActionStyle(color: .gray, emphasized: showingLayout, overlay: showingLayout ? .on : .normal)) .help("Video layout: Speaker or Grid, and who is pinned to the stage") .accessibilityIdentifier("videoLayout") .popover(isPresented: $showingLayout, arrowEdge: .top) { VStack(alignment: .leading, spacing: 12) { layoutPicker.pickerStyle(.segmented).labelsHidden().fixedSize() if peers.count > 1 || !call.screens.isEmpty { Text("On the stage").font(.headline) VStack(alignment: .leading, spacing: 6) { pinChoices } .buttonStyle(.borderless) } } .padding(14) .frame(minWidth: 220, alignment: .leading) } } /// The full-window bar's look for a button in `state`; the classic /// row keeps its own colours. private func tone(_ fullWindow: Bool, _ state: CallOverlayButtonStyle.Tone) -> CallOverlayButtonStyle.Tone? { fullWindow ? state : nil } var panelAnimation: Animation? { reduceMotion ? .linear(duration: 0.1) : .easeOut(duration: 0.2) } var unread: Int { max(0, call.receivedChatCount - readChatCount) } var chatTitle: String { chatWindow.isOpen ? "Move chat into the sidebar" : unread > 0 ? "Chat (\(unread) new)" : showingChat ? "Hide chat" : "Show chat" } } #endif