@_spi(Fixtures) import CallsKit import SwiftUI import LiveKitWebRTC /// The delegate exists for one reason: Apple hands a device token to /// the application delegate and nowhere else, so a pure SwiftUI app has /// no way to receive one. final class CallsPhoneDelegate: NSObject, UIApplicationDelegate { @MainActor static var model: CallsModel? func application( _ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken token: Data ) { Task { @MainActor in CallsPhoneDelegate.model?.registerDevice(token: token) } } func application( _ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error ) { // Expected in the simulator and on any build without the push // entitlement. Silent, for the same reason as on the Mac. } } @main struct CallsPhoneApp: App { @UIApplicationDelegateAdaptor(CallsPhoneDelegate.self) private var appDelegate @StateObject private var model = CallsModel() @Environment(\.scenePhase) private var scenePhase var body: some Scene { WindowGroup { #if DEBUG if let level = PhoneCallFixture.rimGallery { WarmRimGallery(level: level).frame(maxWidth: .infinity, maxHeight: .infinity) .background(Color.black).preferredColorScheme(.dark) } else if let fixture = PhoneCallFixture.requested { PhoneCallFixture(fixture: fixture) } else { home } #else home #endif } } private var home: some View { PhoneHome(model: model) .onOpenURL { model.openMeetingURL($0) } .onAppear { CallsPhoneDelegate.model = model // UI tests launch with this set so a sign-in kept by an earlier // run, against a harness server long gone, cannot decide what // the next run sees first. The simulator's Keychain outlives the // app being reinstalled. if UserDefaults.standard.bool(forKey: "CallsForgetSignIn") { model.signOut() } else { model.restore() } } .onChange(of: scenePhase) { _, phase in // This milestone is foreground-only. Browser sign-in is kept alive; // an active call leaves cleanly instead of silently sending audio. // The one exception is a call sharing the whole screen, whose // point is the other apps the person is about to go to. if phase == .background && model.joining && !model.call.sharingScreen { model.leave() } UIApplication.shared.isIdleTimerDisabled = phase == .active && model.joining } .onChange(of: model.joining) { _, joining in UIApplication.shared.isIdleTimerDisabled = joining } } } struct PhoneHome: View { @ObservedObject var model: CallsModel @State private var showingSettings = false var body: some View { NavigationStack { Group { if model.joining { PhoneCall(call: model.call, leave: model.leave) } else { CallsLobby(model: model, openSettings: { showingSettings = true }) } }.tint(CallsAppearance.accent).navigationTitle("Calls").navigationBarTitleDisplayMode(.inline) .toolbar { // There is no Settings app entry: the gear is where the server // setting lives, reachable from home and never during a call. if !model.joining { ToolbarItem(placement: .topBarTrailing) { Button("Settings", systemImage: "gearshape") { showingSettings = true } .accessibilityIdentifier("settings") } } } .sheet(isPresented: $showingSettings) { ServerSettingsSheet(model: model) { showingSettings = false } .presentationDetents([.medium, .large]) } } } } struct PhoneCall: View { @ObservedObject var call: Call let leave: () -> Void @State var showingChat = false @State var showingDevices = false @State var showingMore = false /// What the controls sheet was closed to do, done once it has gone: /// one sheet at a time. @State var afterMore: MoreAction? @State var readChatCount = 0 @ObservedObject var arrangement: CallArrangement @ObservedObject var speakerNow: SpeakerNow /// A tile being dragged to a new place, and where every tile is. @State var drag: TileDrag? @State var tileFrames: [String: CGRect] = [:] @State var dismissedNotices: Set = [] @State var pipDrag: CGSize = .zero @StateObject var draft = ChatDraft() @StateObject var screenShare = PhoneScreenShare() @StateObject var chrome: ControlsVisibilityModel @AppStorage(CallLayoutStyle.key) private var layoutChoice = CallLayoutStyle.defaultStyle.rawValue @AppStorage(PiPCorner.key) var pipCorner = PiPCorner.bottomTrailing.rawValue /// A microphone button beside the controls menu, off by default: the /// chosen layout is the menu and End call alone. Set from the command /// line (`-CallsMicBesideMenu YES`) to try the other. @AppStorage("CallsMicBesideMenu") var micBesideMenu = false @Environment(\.accessibilityReduceMotion) var reduceMotion @Environment(\.accessibilityVoiceOverEnabled) var voiceOver @Environment(\.fixtureBackdrop) var fixtureBackdrop private let style: CallLayoutStyle? init( call: Call, leave: @escaping () -> Void, style: CallLayoutStyle? = nil, controlsShown: Bool? = nil, showingMore: Bool = false, grid: Bool? = nil ) { self.call = call self.leave = leave self.style = style _showingMore = State(initialValue: showingMore) if let grid { call.arrangement.show(grid ? .grid : .speaker) } arrangement = call.arrangement speakerNow = call.levels.speaker _chrome = StateObject(wrappedValue: ControlsVisibilityModel(input: .touch, pinned: controlsShown)) } var layout: CallLayoutStyle { style ?? CallLayoutStyle(rawValue: layoutChoice) ?? .defaultStyle } 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, and the filmstrip under it. var focused: StageItem? { arrangement.stage(active: speakerNow.peer, screens: call.screens) } var filmstrip: [StageItem] { arrangement.filmstrip(stage: focused, screens: call.screens) } /// You float over the stage only when you would be all the filmstrip /// holds (see Stage.selfFloats). var selfFloats: Bool { Stage.selfFloats(filmstrip: filmstrip, me: arrangement.me) } 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 phoneTile( _ item: StageItem, cornerRadius: CGFloat = 18, compact: Bool = false, labelInsets: EdgeInsets = EdgeInsets(), backdrop: Color = .black, onStage: Bool = false, iconOnly: Bool = false ) -> PhoneVideoTile { let pinned = onStage && arrangement.pinned == item switch item { case .screen(let peer): return PhoneVideoTile( track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), screen: true, cornerRadius: cornerRadius, compact: compact, labelInsets: labelInsets, backdrop: backdrop, onStage: onStage, pinned: pinned, iconOnly: iconOnly) case .camera(let peer) where peer == arrangement.me: return PhoneVideoTile( track: call.localTrack, label: "You", muted: call.muted, mirrored: true, cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: cornerRadius, compact: compact, labelInsets: labelInsets, backdrop: backdrop, audio: call.selfAudio, onStage: onStage, pinned: pinned, iconOnly: iconOnly) case .camera(let peer): return PhoneVideoTile( track: call.remoteTracks[peer], label: call.name(of: peer), muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer), cornerRadius: cornerRadius, compact: compact, labelInsets: labelInsets, backdrop: backdrop, audio: call.tileAudio(peer), onStage: onStage, pinned: pinned, iconOnly: iconOnly) } } 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: a long press picks it up to move /// it, a tap pins it (the filmstrip), and VoiceOver has both as actions. func arrangeable(_ item: StageItem, tapToPin: Bool) -> ArrangeableTile { ArrangeableTile( item: item, title: itemTitle(item), arrangement: arrangement, drag: $drag, frames: tileFrames, tapToPin: tapToPin, touch: true, reduceMotion: reduceMotion, fixtureLift: item == .camera(arrangement.fixtureLift ?? "") ? CGSize(width: 24, height: -30) : nil) } var body: some View { Group { if layout == .classic { classic } else { fullScreen } } .overlay(alignment: .bottom) { CallToast(call: call).padding(.bottom, 110) } .background { // ⌘⇧I on an iPad's keyboard, as on the Mac: the invite link to the // clipboard. A key command with no button to show for it. Button(InviteLinkCopy.title) { Task { await call.copyInviteLink() } } .keyboardShortcut(InviteLinkCopy.shortcut) .opacity(0).frame(width: 0, height: 0).accessibilityHidden(true) } .task { await call.watchAudio() } .onAppear { screenShare.listen(for: call, leave: leave) } .onDisappear { screenShare.stop() call.stopSharingScreen() } .sheet(isPresented: $showingDevices) { NavigationStack { ScrollView { DevicePanel(call: call).frame(maxWidth: .infinity, alignment: .leading) } .navigationTitle("Devices").navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showingDevices = false } } } }.presentationDetents([.medium, .large]) } .sheet(isPresented: $showingChat, onDismiss: { readChatCount = call.receivedChatCount }) { NavigationStack { ChatView(call: call, draft: draft).padding() .navigationTitle("Chat").navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showingChat = false } } } }.presentationDetents([.medium, .large]) } .onChange(of: call.receivedChatCount) { _, count in if showingChat { readChatCount = count } } .reportProblemSheet(call) } /// The call as it was before the full-screen layout: title and stage /// in the page, controls always showing along the bottom. private var classic: some View { VStack(spacing: 12) { HStack { VStack(alignment: .leading, spacing: 4) { Text(call.title).font(.headline).lineLimit(2) Text(call.status).font(.caption).foregroundStyle(.secondary) if call.sharingScreen { Label("You are sharing your screen", systemImage: "rectangle.on.rectangle") .font(.caption.bold()).foregroundStyle(.orange) } } Spacer() Menu { LayoutChoices(call: call, arrangement: arrangement, title: itemTitle) } label: { Label( "Video layout", systemImage: arrangement.layout.symbol ) .labelStyle(.iconOnly).frame(width: 44, height: 44) }.accessibilityIdentifier("videoLayout") } if call.floor { Text("Direct connections only. Some networks may be unable to connect.") .font(.footnote).foregroundStyle(.secondary) } if grid { ScrollView { LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) { ForEach(gridItems, id: \.self) { item in phoneTile(item) .aspectRatio(4 / 3, 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 { phoneTile(focused, onStage: true) } else { ContentUnavailableView( "Waiting for others", systemImage: "person.2", description: Text("Others will appear here when they join.") ) .frame(maxWidth: .infinity, maxHeight: .infinity) .background(CallsAppearance.background, in: RoundedRectangle(cornerRadius: 18)) } phoneTile(.camera(arrangement.me ?? "")) .frame( width: min(160, geometry.size.width * 0.32), height: min(120, geometry.size.height * 0.28) ) .shadow(color: .black.opacity(0.25), radius: 6, y: 2).padding(12) } } } if !(call.state?.waiting.isEmpty ?? true) { ScrollView { ForEach(call.state?.waiting ?? []) { person in HStack { Text("\(person.name) is waiting") Spacer() Button("Let in") { call.admit(person.peer) } } } }.frame(maxHeight: 90) } if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } } if call.state?.phase == "ended" { ReportProblemButton { call.reportProblem() }.buttonStyle(.bordered) } }.padding(.horizontal, 16).padding(.top, 8) .safeAreaInset(edge: .bottom) { HStack(spacing: 14) { Button( call.muted ? "Unmute microphone" : "Mute microphone", glyph: call.muted ? "mic.slash.fill" : "mic.fill" ) { call.mute() } // Space on a hardware keyboard (iPad, mostly). It is a // UIKeyCommand without wantsPriorityOverSystemBehavior, so a // text field being typed in and a control with keyboard focus // keep their Space; only otherwise does it mute. .keyboardShortcut(.space, modifiers: []) .buttonStyle( CallActionStyle( color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent, emphasized: call.muted)) 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)) 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") } Button("Devices", glyph: "slider.horizontal.3") { showingDevices = true } .buttonStyle(CallActionStyle(color: .teal)) .accessibilityIdentifier("devices") MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo)) Spacer(minLength: 0) Button(chatButtonTitle, glyph: "bubble.left.and.bubble.right.fill") { readChatCount = call.receivedChatCount showingChat = true }.buttonStyle(CallActionStyle(color: .purple)).accessibilityIdentifier("openChat") .overlay(alignment: .topTrailing) { if unread > 0 { Text("\(unread)").font(.caption.bold()).foregroundStyle(.white) .padding(4).background(.purple, in: Capsule()).accessibilityHidden(true) } } Button("Leave meeting", glyph: "phone.down.fill", role: .destructive, action: leave) .buttonStyle(CallActionStyle(color: .red, emphasized: true)) .accessibilityIdentifier("leave") }.padding(16).background(.bar) } } var unread: Int { max(0, call.receivedChatCount - readChatCount) } var chatButtonTitle: String { unread == 0 ? "Chat" : "Chat (\(unread) new)" } } struct PhoneVideoTile: View { let track: RTCVideoTrack? let label: String /// §15.19: the peer has said it is muted. False for unknown. var muted = false var mirrored = false var cameraOff = false /// A shared screen (§5.4), shown whole rather than cropped: on a phone /// more than anywhere, cropping a laptop's screen to a portrait tile /// would leave the middle third of it. var screen = false /// Your own tile: what the camera is doing, in place of a picture that /// is not arriving (see `Call.selfPlaceholder`). Nil shows the video. var placeholder: String? = nil /// The full-screen call's look: as rounded as `cornerRadius` says /// (square to the screen's edges for the stage), a smaller name with the /// muted mark inside it, kept `labelInsets` clear of the safe area and /// the controls. var cornerRadius: CGFloat = 18 var compact = false var labelInsets = EdgeInsets() /// Behind a picture that is not there: a shade lighter than the stage /// for a tile among others, so it reads as a tile. var backdrop = Color.black /// The sound on this tile (spec §15.20): your microphone's glow, or a /// remote person's with "No sound from" and their notices. Nil for a /// screen. var audio: TileAudio? = nil /// Speaker view's stage: VoiceOver hears "On stage: Ana", and whether /// it was pinned there. var onStage = false var pinned = false /// A filmstrip thumbnail: the symbol alone for a missing picture. var iconOnly = false /// The height of the name label and what is over it, which the /// placeholder keeps clear of. @State private var labelBlock: CGFloat = 0 @Environment(\.fixtureStagePicture) private var fixturePicture var body: some View { TileAudioReader(audio) { info in tile(info) } } private func tile(_ info: TileAudioInfo?) -> some View { ZStack { backdrop if let fixturePicture, onStage, track == nil, !cameraOff, placeholder == nil { FixtureVideo(picture: fixturePicture, screen: screen) } else if let track, !cameraOff, placeholder == nil { // A screen on the stage is fitted whole; around it, the same // picture soft and dimmed rather than black bars. if screen, onStage { ScreenBackdrop(track: track) } PhoneVideo(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) } else { TilePlaceholder( symbol: cameraOff ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle", words: cameraOff || iconOnly ? nil : placeholder, wordsFont: .caption, spacing: 6, reserve: labelBlock) } } .overlay(alignment: .bottomLeading) { VStack(alignment: .leading, spacing: 4) { TileAudioNotices(info) HStack(spacing: 5) { TileSpeakingName(label: label, muted: muted, compact: compact, glow: audio?.glow) TileAudioStatus(info) } } .padding(compact ? 6 : 8).padding(compact ? labelInsets : EdgeInsets()) // Room past the label's end for the rim's outer breath, when a long // name has it truncating. .padding(.trailing, 6) .tileLabelBlock($labelBlock) } .overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(8) } } .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) .accessibilityElement(children: .ignore) .accessibilityLabel(stageLabel(tileAccessibilityLabel(label, muted: muted, audio: info), onStage: onStage, pinned: pinned)) .accessibilityHint(info?.detail ?? "") .accessibilityValue( cameraOff ? "Camera off" : placeholder ?? (track == nil ? (screen ? "Waiting for the screen" : "Waiting for video") : "Video on")) } } struct PhoneVideo: UIViewRepresentable { let track: RTCVideoTrack var fit = false func makeCoordinator() -> Coordinator { Coordinator() } func makeUIView(context: Context) -> PlacedVideoView { let view = PlacedVideoView() view.fit = fit context.coordinator.attach(track, to: view.renderer) return view } func updateUIView(_ view: PlacedVideoView, context: Context) { view.fit = fit context.coordinator.attach(track, to: view.renderer) } static func dismantleUIView(_ view: PlacedVideoView, coordinator: Coordinator) { coordinator.track?.remove(view.renderer) } final class Coordinator { var track: RTCVideoTrack? func attach(_ next: RTCVideoTrack, to view: RTCMTLVideoView) { if track !== next { track?.remove(view) track = next next.add(view) } } } } /// The renderer at the size and place `VideoFrame` gives it: a camera /// filling the tile with more of a crop taken from the bottom than the /// top, so heads are not cut; a screen fitted whole. The container clips. final class PlacedVideoView: UIView, RTCVideoViewDelegate { let renderer = RTCMTLVideoView(frame: .zero) private var videoSize = CGSize(width: 16, height: 9) var fit = false { didSet { if fit != oldValue { setNeedsLayout() } } } override init(frame: CGRect) { super.init(frame: frame) clipsToBounds = true isUserInteractionEnabled = false renderer.videoContentMode = .scaleToFill renderer.delegate = self addSubview(renderer) } required init?(coder: NSCoder) { nil } nonisolated func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) { DispatchQueue.main.async { [weak self] in guard let self, size.width > 0, size.height > 0 else { return } self.videoSize = size self.setNeedsLayout() } } override func layoutSubviews() { super.layoutSubviews() renderer.frame = VideoFrame.place(video: videoSize, in: bounds.size, fit: fit) } } /// Speaker or Grid, and what is pinned to Speaker view's stage: the /// iPhone's layout menu, in the classic call and the controls sheet. struct LayoutChoices: View { @ObservedObject var call: Call @ObservedObject var arrangement: CallArrangement let title: (StageItem) -> String var body: 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) } } if arrangement.people.count > 2 || !call.screens.isEmpty { Section("On the stage") { Button { arrangement.pin(nil) } label: { Label("Whoever is speaking", systemImage: arrangement.pinned == nil ? "checkmark" : "waveform") } ForEach(call.screens.reversed(), id: \.self) { peer in choice(.screen(peer)) } ForEach(arrangement.others, id: \.self) { peer in choice(.camera(peer)) } } } } private func choice(_ item: StageItem) -> some View { Button { arrangement.pin(item) } label: { Label(title(item), systemImage: arrangement.pinned == item ? "checkmark" : "pin") } } }