@_spi(Fixtures) import CallsKit import SwiftUI /// What the controls sheet was closed in order to open. enum MoreAction { case devices, chat, report } /// The full-screen call: the picture under the status bar and the home /// indicator, both of which go with the controls. A tap shows the /// controls and another hides them; they go on their own 3 s after the /// last touch (see ControlsVisibility for why that long, and what keeps /// them up). The bar is a controls menu on the left and End call on the /// right; everything else is in the menu. extension PhoneCall { /// The round buttons' size, and the bar's height with its margin. static let button: CGFloat = 56 static let barHeight: CGFloat = 56 + 16 static let tileBackdrop = Color(white: 0.13) var fullScreen: some View { GeometryReader { geometry in let safe = geometry.safeAreaInsets let size = CGSize( width: geometry.size.width + safe.leading + safe.trailing, height: geometry.size.height + safe.top + safe.bottom) ZStack { Color.black stage(in: size, safe: safe) .contentShape(Rectangle()) .onTapGesture { chrome.tapped() } .accessibilityAddTraits(.allowsDirectInteraction) if !grid, selfFloats { selfView(in: size, safe: safe) } topOverlay(safe: safe) // Gone rather than transparent while hidden, so nothing invisible // can be tapped or found; the fade is the transition. if chrome.visible { bar(safe: safe).transition(.opacity) } // Space on a hardware keyboard, as the classic bar's Mute has it; // the full-screen Mute is in the menu, so this stands in for it. Button("Mute or unmute microphone") { call.mute() } .keyboardShortcut(.space, modifiers: []) .opacity(0).frame(width: 0, height: 0).accessibilityHidden(true) } .frame(width: size.width, height: size.height) .ignoresSafeArea() } .toolbar(.hidden, for: .navigationBar) .statusBarHidden(!chrome.visible) .persistentSystemOverlays(chrome.visible ? .automatic : .hidden) .animation(fade, value: chrome.visible) .onAppear { updateHolds() } .onChange(of: showingMore) { updateHolds() } .onChange(of: showingDevices) { updateHolds() } .onChange(of: showingChat) { updateHolds() } .onChange(of: voiceOver) { updateHolds() } .sheet( isPresented: $showingMore, onDismiss: { switch afterMore { case .devices?: showingDevices = true case .chat?: readChatCount = call.receivedChatCount showingChat = true case .report?: call.reportProblem() case nil: break } afterMore = nil } ) { MoreControls( call: call, arrangement: arrangement, title: itemTitle, unread: unread, open: { action in afterMore = action showingMore = false }, close: { showingMore = false } ) .presentationDetents([.medium, .large]) } } private var fade: Animation { ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion) } private func updateHolds() { chrome.set(.menuOpen, showingMore || showingDevices || showingChat) chrome.set(.voiceOver, voiceOver) } // MARK: Stage @ViewBuilder private func stage(in size: CGSize, safe: EdgeInsets) -> some View { Group { if let fixtureBackdrop { fixtureBackdrop.view.accessibilityHidden(true).accessibilityIdentifier("callStage") } else if grid { grid(in: size, safe: safe).accessibilityIdentifier("callStage") } else { speakerStage(in: size, safe: safe) } } .coordinateSpace(name: arrangeSpace) .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 } } /// Speaker view: the stage fills the whole screen, edge to edge, and /// everyone else is a filmstrip floating over its bottom that scrolls /// sideways, in this viewer's order (2026-10-05: until then the stage /// stopped above the filmstrip, a black band behind it). The filmstrip /// sits just above the home indicator and rises clear of the bar while /// the controls show, as the self view does; the stage's name rides just /// above it (StageLabel). In a call of two there is no filmstrip: the /// other person fills the screen and you float. private func speakerStage(in size: CGSize, safe: EdgeInsets) -> some View { let strip = selfFloats ? [] : filmstrip let thumb = Self.filmstripTile(in: size) let band = strip.isEmpty ? 0 : thumb.height + 2 * Self.filmstripPadding let raised = chrome.visible ? Self.barHeight : 0 let lift = EdgeInsets( top: 0, leading: safe.leading, bottom: safe.bottom + StageLabel.lift(bar: raised, band: band), trailing: 0) return ZStack(alignment: .bottom) { Group { if let focused { phoneTile(focused, cornerRadius: 0, compact: true, labelInsets: lift, onStage: true) } else { ContentUnavailableView( "Waiting for others", systemImage: "person.2", description: Text("Others will appear here when they join.") ) .foregroundStyle(.white.opacity(0.85)) .environment(\.colorScheme, .dark) .frame(maxWidth: .infinity, maxHeight: .infinity) } } // The stage, and not the filmstrip, is what the UI tests tap to // bring the controls back. .accessibilityIdentifier("callStage") .frame(maxWidth: .infinity, maxHeight: .infinity) // The pin, over the stage's top corner below the title; outside // the stage's own accessibility identifier, so it keeps its own. if let focused, arrangement.pinned == focused { PinBadge(title: itemTitle(focused)) { arrangement.pin(nil) } .padding(.top, max(safe.top, 12) + 60).padding(.trailing, safe.trailing + 12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) } if !strip.isEmpty { ScrollView(.horizontal) { HStack(spacing: 6) { ForEach(strip, id: \.self) { item in phoneTile(item, cornerRadius: 10, compact: true, backdrop: Self.tileBackdrop, iconOnly: true) .frame(width: thumb.width, height: thumb.height) .modifier(arrangeable(item, tapToPin: true)) } } .padding(.horizontal, 10).padding(.vertical, Self.filmstripPadding) .frame(minWidth: size.width - safe.leading - safe.trailing) } .scrollIndicators(.never) .accessibilityElement(children: .contain) .accessibilityLabel("Filmstrip") .accessibilityIdentifier("filmstrip") .padding(.leading, safe.leading).padding(.trailing, safe.trailing) .padding(.bottom, safe.bottom + raised) .animation(ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion), value: chrome.visible) } } } static let filmstripPadding: CGFloat = 6 /// A filmstrip tile: upright 3:4 in portrait, as a phone's camera sees /// a face, 4:3 in landscape; about an eighth of the screen's height. static func filmstripTile(in size: CGSize) -> CGSize { if size.height >= size.width { let height = min(132, max(96, (size.height / 7).rounded())) return CGSize(width: (height * 3 / 4).rounded(), height: height) } let height = min(96, max(64, (size.height / 5).rounded())) return CGSize(width: (height * 4 / 3).rounded(), height: height) } /// Every tile as large as fits inside the safe area, scrolling only /// once they would be too small to see. private func grid(in size: CGSize, safe: EdgeInsets) -> some View { let items = gridItems let count = items.count let area = CGSize( width: size.width - safe.leading - safe.trailing - 8, height: size.height - safe.top - safe.bottom - 8) let aspect: CGFloat = area.width > area.height ? 4 / 3 : 3 / 4 let columns = GridFit.columns(count: count, in: area, aspect: aspect) let fitted = GridFit.tile(count: count, columns: columns, in: area, aspect: aspect) let tile = fitted.width >= 110 ? fitted : CGSize(width: 110, height: 110 / aspect) let across = fitted.width >= 110 ? columns : max(1, Int((area.width + 4) / (tile.width + 4))) return ScrollView { LazyVGrid( columns: Array(repeating: GridItem(.fixed(tile.width), spacing: 4), count: across), spacing: 4 ) { ForEach(items, id: \.self) { item in phoneTile(item, cornerRadius: 8, compact: true, backdrop: Self.tileBackdrop) .frame(width: tile.width, height: tile.height) .modifier(arrangeable(item, tapToPin: false)) } } .padding(4) .frame(minHeight: area.height + 8) } .scrollIndicators(.never) .padding(safe) } // MARK: Self view /// You, small and floating: dragged anywhere, settling in the nearest /// corner, above the bar while it shows. Muted is said on it always, /// since the Mute button is a tap away inside the menu. private func selfView(in size: CGSize, safe: EdgeInsets) -> some View { let corner = PiPCorner(rawValue: pipCorner) ?? .bottomTrailing let pip = PiPLayout.size(in: size, touch: true) let insets = EdgeInsets( top: safe.top, leading: safe.leading, bottom: safe.bottom + (chrome.visible ? Self.barHeight : 0), trailing: safe.trailing) let frame = PiPLayout.frame(corner: corner, size: pip, in: size, insets: insets) return PhoneVideoTile( track: call.localTrack, label: "You", muted: call.muted, mirrored: true, cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: 14, compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio ) .frame(width: pip.width, height: pip.height) .shadow(color: .black.opacity(0.35), radius: 8, y: 3) .position(x: frame.midX + pipDrag.width, y: frame.midY + pipDrag.height) .onTapGesture { chrome.tapped() } .gesture( DragGesture() .onChanged { pipDrag = $0.translation } .onEnded { drag in let dropped = CGPoint( x: frame.midX + drag.translation.width, y: frame.midY + drag.translation.height) let headed = CGPoint( x: frame.midX + drag.predictedEndTranslation.width, y: frame.midY + drag.predictedEndTranslation.height) withAnimation(reduceMotion ? nil : .spring(duration: 0.3)) { pipCorner = PiPLayout.corner(droppedAt: dropped, predicted: headed, in: size).rawValue pipDrag = .zero } } ) .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: frame) .accessibilityIdentifier("selfView") .accessibilityActions { ForEach(PiPCorner.allCases, id: \.self) { place in Button(moveTitle(place)) { pipCorner = place.rawValue } } } } private func moveTitle(_ corner: PiPCorner) -> String { switch corner { case .topLeading: "Move self view to top left" case .topTrailing: "Move self view to top right" case .bottomLeading: "Move self view to bottom left" case .bottomTrailing: "Move self view to bottom right" } } // MARK: Title and notices private var notices: [CallNotice] { CallNotices.notices( phase: call.noticePhase, status: call.status, sharingScreen: call.sharingScreen, screenNote: call.screenNote, floor: call.floor, screenRecovery: call.screenRecovery) } private func topOverlay(safe: EdgeInsets) -> some View { VStack(spacing: 8) { // No pill (2026-10-02, Paul approved): how the call is going is the // notices' to say. A title someone chose is a small centred line at // the top, inside the safe area, fading with the controls; a title // Calls wrote is not shown (CallTitle). if let shown = CallTitle.shown(title: call.title, named: call.named) { Text(shown) .font(.footnote.weight(.semibold)).foregroundStyle(.white) .shadow(color: .black.opacity(0.5), radius: 2, y: 1) .lineLimit(1).truncationMode(.tail) .frame(maxWidth: .infinity) .opacity(chrome.visible ? 1 : 0) .allowsHitTesting(false) .accessibilityHidden(!chrome.visible) .accessibilityAddTraits(.isHeader) .accessibilityIdentifier("callTitle") } ForEach(notices.filter { $0.important && !dismissedNotices.contains($0.id) }) { notice in noticeView(notice) } ForEach(call.state?.waiting ?? []) { person in HStack(spacing: 10) { Text("\(person.name) is waiting").lineLimit(2) Button("Let in") { call.admit(person.peer) }.buttonStyle(.borderedProminent) } .modifier(PhoneNoticeSurface()) } // The relay warning goes with the controls; it lived in the pill. ForEach(notices.filter { !$0.important }) { notice in noticeView(notice) .opacity(chrome.visible ? 1 : 0) .allowsHitTesting(chrome.visible) .accessibilityHidden(!chrome.visible) } } .padding(.top, max(safe.top, 12) + 4) .padding(.leading, safe.leading + 12).padding(.trailing, safe.trailing + 12) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } @ViewBuilder private func noticeView(_ notice: CallNotice) -> some View { HStack(spacing: 10) { switch notice.kind { case .sharing: Label(notice.text, systemImage: "rectangle.on.rectangle") .foregroundStyle(CallOverlay.alertText).bold() case .screenRecovering: Label(notice.text, systemImage: "arrow.clockwise") case .connecting, .reconnecting, .waitingToJoin: ProgressView().tint(.white) Text(notice.text) case .full: Text(notice.text) Button("Try joining again") { call.retryJoin() } case .ended: Label(notice.text, systemImage: "phone.down") case .problem, .floor: Label(notice.text, systemImage: "exclamationmark.triangle.fill") } if let action = notice.action { // The same stop as the ⋯ sheet's, which ends the broadcast. NoticeActionButton(action) { call.perform(action) } } if notice.dismissible { Button("Dismiss", systemImage: "xmark") { dismissedNotices.insert(notice.id) } .labelStyle(.iconOnly) } } .modifier(PhoneNoticeSurface()) } // MARK: Bar private func bar(safe: EdgeInsets) -> some View { HStack(spacing: 14) { Button { showingMore = true } label: { Label { Text("More controls") } icon: { CallGlyph("ellipsis", rotation: .degrees(90)) } .labelStyle(.iconOnly) } .buttonStyle(RoundCallButtonStyle(.normal)) .overlay(alignment: .topTrailing) { if unread > 0 { Text("\(unread)").font(.caption.bold()).foregroundStyle(.white) .padding(5).background(CallOverlay.badgeFill, in: Capsule()).accessibilityHidden(true) } } .accessibilityLabel(unread > 0 ? "More controls, \(unread) new chat messages" : "More controls") .accessibilityIdentifier("moreControls") if micBesideMenu { Button( call.muted ? "Unmute microphone" : "Mute microphone", glyph: call.muted ? "mic.slash.fill" : "mic.fill" ) { call.mute() } .labelStyle(.iconOnly) .buttonStyle(RoundCallButtonStyle(call.muted ? .off : .normal)) .accessibilityIdentifier("mute") } Spacer(minLength: 0) Button("End call", glyph: "phone.down.fill", role: .destructive, action: leave) .labelStyle(.iconOnly) .buttonStyle(RoundCallButtonStyle(.leave)) .accessibilityIdentifier("leave") } .padding(.leading, safe.leading + 20).padding(.trailing, safe.trailing + 20) .padding(.bottom, max(safe.bottom, 16) + 4) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) .background(alignment: .bottom) { // Enough shade under the buttons to read them over a white wall. LinearGradient(colors: [.clear, .black.opacity(0.35)], startPoint: .top, endPoint: .bottom) .frame(height: Self.barHeight + safe.bottom + 40) .allowsHitTesting(false) } } } /// A round call button over the picture: a borderless glass circle with a /// white symbol, or filled for End call's red and a muted microphone's /// amber (see CallOverlay). struct RoundCallButtonStyle: ButtonStyle { let tone: CallOverlayButtonStyle.Tone init(_ tone: CallOverlayButtonStyle.Tone) { self.tone = tone } func makeBody(configuration: Configuration) -> some View { configuration.label.font(.title2.weight(.semibold)) .modifier(CallGlyphSizing(.round)) .foregroundStyle(tone == .on ? CallOverlay.onSymbol : .white) .frame(width: PhoneCall.button, height: PhoneCall.button) .background { switch tone { case .normal: Color.clear.callOverlaySurface(Circle()) case .on: Circle().fill(CallOverlay.onFill) case .off: Circle().fill(CallOverlay.offFill) case .leave: Circle().fill(CallOverlay.leaveFill) } } .opacity(configuration.isPressed ? 0.7 : 1) .contentShape(Circle()) } } /// A notice over the picture: the overlay's borderless glass, white text, /// and dark-appearance controls whatever the system's. struct PhoneNoticeSurface: ViewModifier { func body(content: Content) -> some View { content.font(.callout).foregroundStyle(.white) .environment(\.colorScheme, .dark) .padding(.horizontal, 14).padding(.vertical, 8) // No Liquid Glass rim: see NoticeSurface on the Mac. .callOverlaySurface(RoundedRectangle(cornerRadius: 16, style: .continuous), rim: false) .shadow(color: .black.opacity(0.25), radius: 8, y: 2) } } /// The controls menu: everything but End call, each a labelled button. /// A sheet rather than a pop-up menu, so it can hold the system's screen /// broadcast picker and a toggle, and show at a glance what is on. struct MoreControls: View { @ObservedObject var call: Call @ObservedObject var arrangement: CallArrangement let title: (StageItem) -> String let unread: Int let open: (MoreAction) -> Void let close: () -> Void @StateObject private var micModes = MicrophoneModes() private let columns = [GridItem(.adaptive(minimum: 96), spacing: 12)] var body: some View { NavigationStack { ScrollView { VStack(alignment: .leading, spacing: 20) { LazyVGrid(columns: columns, spacing: 16) { tile(call.muted ? "Unmute" : "Mute") { 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)) .accessibilityIdentifier("mute") } tile(call.cameraOff ? "Camera on" : "Camera off") { Button( call.cameraOff ? "Turn camera on" : "Turn camera off", glyph: call.cameraOff ? "video.slash.fill" : "video.fill" ) { call.toggleCamera() } .buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff)) .accessibilityIdentifier("camera") } tile("Flip") { Button("Flip camera", glyph: "arrow.triangle.2.circlepath.camera") { if let next = CameraFlip.next(from: call.currentCamera, in: call.cameras) { Task { await call.useCamera(next.id) } } } .buttonStyle(CallActionStyle(color: .blue)) .disabled(CameraFlip.next(from: call.currentCamera, in: call.cameras) == nil) .accessibilityIdentifier("flipCamera") } tile(call.sharingScreen ? "Stop sharing" : "Share screen") { if call.sharingScreen { Button("Stop sharing your screen", glyph: "rectangle.on.rectangle.slash") { call.stopSharingScreen() } .buttonStyle(CallActionStyle(color: .orange, emphasized: true)) .accessibilityIdentifier("shareScreen") } else { BroadcastButton().frame(width: 44, height: 44) .disabled(call.state?.phase != "admitted") .accessibilityIdentifier("shareScreen") } } tile("Devices") { Button("Devices", glyph: "slider.horizontal.3") { open(.devices) } .buttonStyle(CallActionStyle(color: .teal)) .accessibilityIdentifier("devices") } tile("Mic Mode") { Button("Mic Mode", glyph: "waveform") { micModes.showPicker() } .buttonStyle(CallActionStyle(color: .teal)) .accessibilityIdentifier("micModeQuick") .accessibilityHint(micModes.summary) } tile(arrangement.layout.title) { Menu { LayoutChoices(call: call, arrangement: arrangement, title: title) } label: { Label { Text("Video layout") } icon: { CallGlyph(arrangement.layout.symbol) } } .buttonStyle(CallActionStyle(color: .gray)) .accessibilityIdentifier("videoLayout") } if call.standing == "member" { tile("Share link") { MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo)) } } tile(unread > 0 ? "Chat (\(unread))" : "Chat") { Button(unread == 0 ? "Chat" : "Chat (\(unread) new)", glyph: "bubble.left.and.bubble.right.fill") { open(.chat) } .buttonStyle(CallActionStyle(color: .purple, emphasized: unread > 0)) .accessibilityIdentifier("openChat") } } // Spec §15.26: this call's diagnostics, sent rather than copied. ReportProblemButton { open(.report) } .disabled(!call.canReportProblem) VStack(alignment: .leading, spacing: 4) { Toggle("Reduce background noise", isOn: $call.reduceNoise) .accessibilityIdentifier("noiseReduction") Text("Takes out typing, wind and room noise before others hear you.") .font(.footnote).foregroundStyle(.secondary) } } .padding(20) } .navigationTitle("Call controls").navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done", action: close) } } .onAppear { micModes.refresh() } } } /// A button with its caption under it. The caption is for the eye; the /// button carries its own full label for VoiceOver. private func tile(_ caption: String, @ViewBuilder _ content: () -> some View) -> some View { VStack(spacing: 6) { content() Text(caption).font(.caption).foregroundStyle(.secondary).lineLimit(1) .minimumScaleFactor(0.8).accessibilityHidden(true) } .frame(maxWidth: .infinity) } }