Merge branch 'feat/immersive-call' into main
Full-window call on Mac and iPhone (controls that fade, floating self view, Classic kept as a setting), Copy Invite Link (⌘⇧I), audio details behind an ⓘ, and Calls 0.3.4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
27 files changed,
+2934
−155
native/DESIGN.md+55 −0native/README.md+15 −3native/apple/Sources/CallsInterop/CallSnapshots.swift+44 −0native/apple/Sources/CallsInterop/Interop.swift+7 −1native/apple/Sources/CallsInterop/LobbySnapshots.swift+1 −0native/apple/Sources/CallsKit/AudioHealth.swift+29 −0native/apple/Sources/CallsKit/Call.swift+28 −0native/apple/Sources/CallsKit/CallChrome.swift+421 −0native/apple/Sources/CallsKit/CallFixtures.swift+45 −0native/apple/Sources/CallsKit/CallsSettingsView.swift+14 −0native/apple/Sources/CallsKit/DevicePanel.swift+39 −7native/apple/Sources/CallsKit/InviteLinkCopy.swift+121 −0native/apple/Sources/CallsKit/MacCallView.swift+153 −45native/apple/Sources/CallsKit/MacFullWindowCall.swift+557 −0native/apple/Sources/CallsKit/MacVideoTile.swift+20 −6native/apple/Sources/CallsKit/MiniWindow.swift+2 −0native/apple/Sources/CallsKit/MutedBadge.swift+31 −0native/apple/Sources/CallsKit/ServerSettings.swift+20 −1native/apple/Sources/CallsMac/CallsMac.swift+24 −7native/apple/Tests/CallsKitTests/CallChromeTests.swift+432 −0native/ios/Sources/CallsPhone.swift+155 −76native/ios/Sources/PhoneCallFixture.swift+39 −0native/ios/Sources/PhoneFullScreenCall.swift+507 −0native/ios/Tests/CallsPhoneTests.swift+9 −2native/ios/UITests/CallsPhoneUITests.swift+135 −0native/ios/project.yml+1 −1test/browser/native.mjs+30 −6
CI passed — run details
diff --git a/native/DESIGN.md b/native/DESIGN.md| index d6085b1..bcf520e 100644 |
| --- a/native/DESIGN.md |
| +++ b/native/DESIGN.md |
| @@ -81,3 +81,58 @@ Safari member/guest sign-in and the real iPhone simulator native-media tests |
| passed after this change. The signed development app was built and installed on |
| Paul's paired iPhone. That installation does not substitute for a physical |
| camera/microphone and interruption acceptance call. |
| + |
| +## Full-window call · 2026-09-25 (0.3.4) |
| + |
| +The call fills the window (Mac) or the screen (iPhone), with the controls over |
| +it on demand. **Call layout: Full window (new) / Classic** — Calls → Settings… |
| +on Mac, the gear's sheet on iPhone — keeps the previous layout one choice away; |
| +Full window is the default. The mini window and tab are unchanged. |
| + |
| +- **When the controls show** is `ControlsVisibility` (CallChrome.swift), one |
| + state machine for both apps with the time passed in, so tests drive it. Mac: |
| + pointer movement shows them, 1.5 s still hides them, leaving the window hides |
| + them at once, and the pointer hides with them over the picture |
| + (`NSCursor.setHiddenUntilMouseMoves`). iPhone: a tap toggles, 2.5 s after the |
| + last touch hides — no hover says the person is still there, and a thumb needs |
| + the extra second to reach the bar. They stay up while the pointer is on the |
| + bar, a menu, popover, sheet or panel is open (AppKit's menu-tracking and |
| + window-sheet notifications cover the ones SwiftUI does not report), a bar |
| + control has keyboard focus, VoiceOver is running, or an alert shows. Tab |
| + reveals them. Space and ⌘⇧M work with them hidden. Fades are opacity only; |
| + Reduce Motion shortens them to 0.1 s and panels fade instead of sliding. |
| +- **The stage** is edge to edge: square tiles for Focus and a screen, a grid |
| + that fits every tile without scrolling until they would be under 200 pt |
| + (`GridFit`). On Mac the title bar is transparent with the traffic lights kept. |
| + Names are small capsules with the muted mark (shape and word) inside, lifted |
| + clear of the bar while it shows, visible while it does not. |
| +- **The self view** floats in Focus, draggable, settling in the corner it was |
| + flicked towards (`PiPLayout`), kept clear of the bar, an open chat and the |
| + camera strip beside a shared screen, and remembered. It always says Muted |
| + when you are. VoiceOver has "Move self view to …" actions. In Grid you are a |
| + tile, as before. |
| +- **Title and notices.** Title and status go with the controls. Reconnecting, |
| + waiting to be let in, full, ended (with Join again), problems (dismissible), |
| + "You are sharing your screen" and each person waiting with **Let in** stay |
| + until resolved (`CallNotices`); the relay-floor line fades. |
| +- **Mac bar**: a material bar along the bottom — microphone, camera, share, |
| + devices, layout (Focus/Grid and who is on the stage, in a popover), share link |
| + — with chat and Leave on the right. Devices is a floating panel over the |
| + stage above the bar, not a column that shrinks it. |
| +- **Mac chat** is an overlay sidebar sliding over the stage's trailing edge, |
| + with the existing pop-out to a separate window kept. Chosen over the window |
| + alone because the window is window management mid-call on a laptop screen, |
| + while the sidebar is one click and leaves the picture its full size underneath; |
| + anyone who wants it beside the call still has the window. |
| +- **iPhone bar**: a kebab (More controls) on the left and End call on the right. |
| + The kebab opens a sheet: mute, camera, flip camera, share screen (the system |
| + broadcast picker, which is why it is a sheet and not a menu), devices, Mic |
| + Mode, layout, share link, chat and Reduce background noise. The status bar and |
| + home indicator hide with the controls. `-CallsMicBesideMenu YES` puts a |
| + microphone button beside the kebab, off by default. |
| + |
| +Snapshots: `CallsInterop --call-snapshots <dir>` (also part of |
| +`--lobby-snapshots`) renders the Mac call, both layouts, light and dark. The |
| +iPhone's come from `CallsPhoneUITests.testFullScreenCallSnapshots` against the |
| +debug build's `-CallsCallFixture` calls (`CallFixture`), written to |
| +`CALLS_SNAPSHOT_DIR` or `/tmp/immersive-call`. |
diff --git a/native/README.md b/native/README.md| index 4ced9a7..000b175 100644 |
| --- a/native/README.md |
| +++ b/native/README.md |
| @@ -101,8 +101,11 @@ every rebuild, macOS may ask once per build whether Calls may read it. |
| Scheduling a meeting for later, ending one, and replacing invitations stay on |
| the web for this milestone. |
| -**Devices** in the call opens the camera, microphone and speaker panel, and |
| -the audio readout beneath them. |
| +**Devices** in the call opens the camera, microphone and speaker panel. The |
| +audio readout is behind its ⓘ (**Audio details**), with the screen-share line |
| +while sharing; the panel itself shows one warning line only when something is |
| +wrong (a stretched or broken-up signal, echo cancellation off, noise reduction |
| +bypassed, processing refused — `AudioWarning`). |
| The camera is a picker on both platforms. Switching rests on the capturer |
| feeding the same `RTCVideoSource`, so the `RTCVideoTrack` every peer |
| @@ -420,7 +423,16 @@ Microphone** (⌘⇧M) is the same toggle. The browser room does the same (never |
| an input, textarea, select, contenteditable, button, link or other control, and |
| announced through the polite live region), and on iPhone and iPad a hardware |
| keyboard's Space does it through a key command that yields to text editing and |
| -focus. Push-to-talk (holding Space while muted) is not built. It shows what the call window's stage would show by |
| +focus. Push-to-talk (holding Space while muted) is not built. |
| + |
| +**Call → Copy Invite Link** (⌘⇧I), beside Mute, puts the meeting's invite link |
| +on the clipboard and says "Invite link copied" briefly over the stage (and to |
| +VoiceOver), from the call window or the mini window, with the controls shown or |
| +not. It asks the server the way the Share button does, which returns the |
| +meeting's current sealed invitation and never replaces or revokes one. It is |
| +off outside a call; a guest pressing it is told only members can share. An |
| +iPad's hardware keyboard has the same shortcut. The browser room does not: |
| +⌘⇧I is Chrome's developer tools. It shows what the call window's stage would show by |
| default: the newest shared screen, otherwise the first other participant with |
| their muted and camera-off state (there is no active-speaker tracking yet), or |
| your own camera while you are alone. It is not |
diff --git a/native/apple/Sources/CallsInterop/CallSnapshots.swift b/native/apple/Sources/CallsInterop/CallSnapshots.swift| new file mode 100644 |
| index 0000000..a45d1e0 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsInterop/CallSnapshots.swift |
| @@ -0,0 +1,44 @@ |
| +import AppKit |
| +@_spi(Fixtures) import CallsKit |
| +import SwiftUI |
| + |
| +/// Offline visual fixtures of the Mac call window, full-window and |
| +/// classic, from described calls (see CallFixture): controls shown and |
| +/// hidden, Focus and Grid, a shared screen, the Devices panel open, and |
| +/// the notices that stay when the controls go. The tiles show their |
| +/// placeholders — an off-screen capture draws no Metal video anyway. |
| +@MainActor enum CallSnapshots { |
| + static func save(to folder: URL) async throws { |
| + NSApplication.shared.setActivationPolicy(.prohibited) |
| + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) |
| + let size = CGSize(width: 1000, height: 640) |
| + for scheme in [ColorScheme.light, .dark] { |
| + let suffix = LobbySnapshots.name(scheme) |
| + func shot( |
| + _ name: String, _ fixture: CallFixture, grid: Bool = false, shown: Bool = true, |
| + devices: Bool = false, chat: Bool = false, style: CallLayoutStyle = .fullWindow |
| + ) async throws { |
| + let call = Call(playAudio: false) |
| + fixture.apply(to: call) |
| + try await LobbySnapshots.render( |
| + MacCallView( |
| + call: call, grid: grid, showingChat: chat, showingDevices: devices, style: style, |
| + controlsShown: shown, leave: {}, rejoin: {} |
| + ).tint(CallsAppearance.accent), |
| + scheme: scheme, size: size, |
| + to: folder.appendingPathComponent("mac-\(name)-\(suffix).png")) |
| + } |
| + try await shot("focus-controls", .focus) |
| + try await shot("focus-hidden", .focus, shown: false) |
| + try await shot("grid-controls", .grid, grid: true) |
| + try await shot("grid-hidden", .grid, grid: true, shown: false) |
| + try await shot("screen-controls", .screen) |
| + try await shot("screen-hidden", .screen, shown: false) |
| + try await shot("devices", .focus, devices: true) |
| + try await shot("chat", .focus, chat: true) |
| + try await shot("notices-hidden", .notices, shown: false) |
| + try await shot("classic-focus", .focus, style: .classic) |
| + try await shot("classic-grid", .grid, grid: true, style: .classic) |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsInterop/Interop.swift b/native/apple/Sources/CallsInterop/Interop.swift| index 28cff12..6c3bb93 100644 |
| --- a/native/apple/Sources/CallsInterop/Interop.swift |
| +++ b/native/apple/Sources/CallsInterop/Interop.swift |
| @@ -24,6 +24,12 @@ struct Input: Decodable { |
| do { try await LobbySnapshots.save(to: CommandLine.arguments[2]) } catch { exit(1) } |
| return |
| } |
| + if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--call-snapshots" { |
| + do { |
| + try await CallSnapshots.save(to: URL(fileURLWithPath: CommandLine.arguments[2])) |
| + } catch { exit(1) } |
| + return |
| + } |
| guard let line = readLine(), |
| let input = try? JSONDecoder().decode(Input.self, from: Data(line.utf8)), |
| let url = URL(string: input.origin), url.scheme == "http", url.host == "127.0.0.1" |
| @@ -226,7 +232,7 @@ struct Input: Decodable { |
| ("call-grid.png", true, false), |
| ] { |
| view.rootView = AnyView( |
| - MacCallView(call: call, grid: grid, showingChat: chat, leave: {}).id(name) |
| + MacCallView(call: call, grid: grid, showingChat: chat, style: .classic, leave: {}).id(name) |
| .padding(20).tint(CallsAppearance.accent).background(Color.white) |
| .environment(\.colorScheme, .light)) |
| window.setContentSize(NSSize(width: 900, height: 540)) |
diff --git a/native/apple/Sources/CallsInterop/LobbySnapshots.swift b/native/apple/Sources/CallsInterop/LobbySnapshots.swift| index c64f432..bf0d971 100644 |
| --- a/native/apple/Sources/CallsInterop/LobbySnapshots.swift |
| +++ b/native/apple/Sources/CallsInterop/LobbySnapshots.swift |
| @@ -76,6 +76,7 @@ import SwiftUI |
| } |
| try await SettingsSnapshots.save(to: folder) |
| try await MiniWindowSnapshots.save(to: folder) |
| + try await CallSnapshots.save(to: folder) |
| } |
| static func name(_ scheme: ColorScheme) -> String { scheme == .dark ? "dark" : "light" } |
diff --git a/native/apple/Sources/CallsKit/AudioHealth.swift b/native/apple/Sources/CallsKit/AudioHealth.swift| index fbfc83b..019dec1 100644 |
| --- a/native/apple/Sources/CallsKit/AudioHealth.swift |
| +++ b/native/apple/Sources/CallsKit/AudioHealth.swift |
| @@ -77,3 +77,32 @@ public struct AudioHealth: Equatable, Sendable { |
| return "Audio is arriving cleanly (\(loss)% filled in, \(drift)% resampled)." |
| } |
| } |
| + |
| +/// The one line the device panel shows about audio, and only when |
| +/// something is actually wrong; the full readout is a tap away behind |
| +/// "Audio details". Nil keeps the panel clean. Worst first: a voice that |
| +/// sounds wrong, then one that breaks up, then processing that is not |
| +/// doing what was asked. |
| +public enum AudioWarning { |
| + public static func line( |
| + health: AudioHealth?, processing: ProcessingReport?, reduceNoise: Bool |
| + ) -> String? { |
| + if let health, !health.silent { |
| + if health.driftRate >= 0.02 { |
| + return "Incoming audio is being stretched to keep time, so voices may sound slowed or growling." |
| + } |
| + if health.concealmentRate >= 0.05 { |
| + return "\(Int((health.concealmentRate * 100).rounded()))% of incoming audio is going missing." |
| + } |
| + } |
| + guard let processing, processing.started else { return nil } |
| + if processing.refused != nil { return "WebRTC refused the audio processing Calls asked for." } |
| + if processing.echoCancellation == "off" { |
| + return "Echo cancellation is off, so others may hear themselves." |
| + } |
| + if reduceNoise, case .unsupportedRate(let rate) = processing.suppression { |
| + return "Noise reduction is bypassed: the microphone runs at \(rate / 1000) kHz." |
| + } |
| + return nil |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/Call.swift b/native/apple/Sources/CallsKit/Call.swift| index e0a2ce3..12c1697 100644 |
| --- a/native/apple/Sources/CallsKit/Call.swift |
| +++ b/native/apple/Sources/CallsKit/Call.swift |
| @@ -66,6 +66,9 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| @Published public private(set) var invitationURL: URL? |
| @Published public private(set) var invitationError = "" |
| @Published public private(set) var loadingInvitation = false |
| + /// A brief message over the stage, such as "Invite link copied". |
| + @Published public internal(set) var toast: String? |
| + var toastSerial = 0 |
| /// What the last device change had to say, and usually nothing. |
| @Published public private(set) var deviceNote = "" |
| /// The audio-health readout, once a call has been up long enough for |
| @@ -635,3 +638,28 @@ private final class Events: NSObject, BridgeListenerProtocol { |
| return values |
| } |
| } |
| + |
| +// --------------------------------------------------------------- |
| +// Fixtures |
| +// --------------------------------------------------------------- |
| + |
| +extension Call { |
| + /// Puts the call into a described state without a server, for the |
| + /// offline snapshots of the call views and the iPhone's UI test of its |
| + /// controls. `state` is a roster in the wire's own JSON. Never used by |
| + /// a call that has joined anything. |
| + @_spi(Fixtures) public func showFixture( |
| + title: String, standing: String = "member", status: String = "In the meeting", |
| + state json: String, screens: [String] = [], sharingScreen: Bool = false, |
| + muted: Bool = false, floor: Bool = false |
| + ) { |
| + self.title = title |
| + self.standing = standing |
| + self.state = try? JSONDecoder().decode(RoomState.self, from: Data(json.utf8)) |
| + self.status = status |
| + self.screens = screens |
| + self.sharingScreen = sharingScreen |
| + self.muted = muted |
| + self.floor = floor |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/CallChrome.swift b/native/apple/Sources/CallsKit/CallChrome.swift| new file mode 100644 |
| index 0000000..df456f5 |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/CallChrome.swift |
| @@ -0,0 +1,421 @@ |
| +import CoreGraphics |
| +import Foundation |
| +import SwiftUI |
| + |
| +// The rules of the full-window call, for Mac and iPhone alike, kept apart |
| +// from the views that apply them so tests can hold them without a window, |
| +// a pointer or a finger: when the controls show, where the self view |
| +// sits, which notices outlive the controls, and how a grid fills a window. |
| + |
| +/// Which call layout the person has chosen. "Full window" is the call |
| +/// filling the window with its controls over it, appearing on demand; |
| +/// "Classic" is the call as it was before 0.3.4, kept so the two can be |
| +/// compared and the old one gone back to. |
| +public enum CallLayoutStyle: String, CaseIterable, Identifiable, Sendable { |
| + case fullWindow |
| + case classic |
| + |
| + public static let key = "callLayout" |
| + public static let defaultStyle = CallLayoutStyle.fullWindow |
| + public var id: String { rawValue } |
| + public var title: String { |
| + switch self { |
| + case .fullWindow: "Full window (new)" |
| + case .classic: "Classic" |
| + } |
| + } |
| +} |
| + |
| +// MARK: - Controls visibility |
| + |
| +/// Whether the call's controls are showing, as a small state machine. |
| +/// |
| +/// Inputs are what a person does — moves the pointer, leaves the window, |
| +/// taps, presses a key, uses a control — and what is going on around the |
| +/// controls that should keep them up: a menu or panel open, keyboard |
| +/// focus in them, VoiceOver, an alert. The one output is `visible`, and |
| +/// `hideAt`, the moment it will turn false if nothing else happens, for |
| +/// whoever owns the clock to wake at. |
| +/// |
| +/// Time is plain seconds on any monotonic clock, passed in, so a test is |
| +/// a sequence of calls with made-up times. |
| +public struct ControlsVisibility: Equatable, Sendable { |
| + /// How the person is pointing: a Mac's pointer, or a finger. |
| + public enum Input: Sendable { case pointer, touch } |
| + |
| + /// Things that keep the controls up for as long as they last. |
| + public struct Holds: OptionSet, Hashable, Sendable { |
| + public let rawValue: Int |
| + public init(rawValue: Int) { self.rawValue = rawValue } |
| + /// The pointer is over the control bar itself. |
| + public static let pointerOverControls = Holds(rawValue: 1 << 0) |
| + /// A menu, popover, sheet or panel from the controls is open. |
| + public static let menuOpen = Holds(rawValue: 1 << 1) |
| + /// Keyboard focus is on one of the controls. |
| + public static let keyboardFocus = Holds(rawValue: 1 << 2) |
| + /// VoiceOver is running: controls that come and go are controls a |
| + /// screen reader cannot find, so they stay. |
| + public static let voiceOver = Holds(rawValue: 1 << 3) |
| + /// An alert or window sheet is showing. |
| + public static let alert = Holds(rawValue: 1 << 4) |
| + } |
| + |
| + /// A Mac hides its controls 1.5 s after the pointer stops. The pointer |
| + /// is still there, and moving it a millimetre brings them back, so the |
| + /// delay only has to outlast the pause between a glance and a click. |
| + public static let pointerDelay: TimeInterval = 1.5 |
| + /// A touch screen waits 2.5 s. There is no hover to say the person is |
| + /// still looking: a tap is the only sign, and after it the thumb still |
| + /// has to find its target and the eyes have to read the bar — at 1.5 s |
| + /// the bar would go mid-reach. Apple's own players wait about three; |
| + /// 2.5 keeps the picture uncluttered a little sooner without racing |
| + /// the thumb. |
| + public static let touchDelay: TimeInterval = 2.5 |
| + |
| + public let input: Input |
| + public private(set) var visible: Bool |
| + public private(set) var holds: Holds = [] |
| + /// When the controls hide unless something happens first. Nil when |
| + /// they are hidden, or held up. |
| + public private(set) var hideAt: TimeInterval? |
| + /// Whether the pointer is inside the window. Always false for touch. |
| + public private(set) var pointerInside = false |
| + |
| + public var delay: TimeInterval { input == .pointer ? Self.pointerDelay : Self.touchDelay } |
| + public var held: Bool { !holds.isEmpty } |
| + |
| + /// Controls start visible, so a call opens with them in view, and fade |
| + /// on the usual schedule. |
| + public init(input: Input, now: TimeInterval) { |
| + self.input = input |
| + visible = true |
| + hideAt = now + (input == .pointer ? Self.pointerDelay : Self.touchDelay) |
| + } |
| + |
| + /// The pointer moved over the window: show, and start the wait again. |
| + public mutating func pointerMoved(now: TimeInterval) { |
| + pointerInside = true |
| + reveal(now: now) |
| + } |
| + |
| + /// The pointer left the window: hide at once, unless something holds |
| + /// the controls up. Leaving the window is also leaving the bar. |
| + public mutating func pointerExited(now: TimeInterval) { |
| + pointerInside = false |
| + holds.remove(.pointerOverControls) |
| + if held { return } |
| + hide() |
| + } |
| + |
| + /// A tap on the picture (touch). It toggles: a tap shows hidden |
| + /// controls and hides showing ones — unless they are held, when it only |
| + /// starts the wait again. |
| + public mutating func tapped(now: TimeInterval) { |
| + if visible, !held { |
| + hide() |
| + } else { |
| + reveal(now: now) |
| + } |
| + } |
| + |
| + /// Anything else that says the person is here: a key such as Tab, a |
| + /// control being used. Shows the controls and starts the wait again. |
| + public mutating func activity(now: TimeInterval) { |
| + reveal(now: now) |
| + } |
| + |
| + /// Something that holds the controls up started (`on`) or stopped. |
| + /// Holding shows them; letting go of the last hold starts the usual wait |
| + /// rather than hiding them under the person's nose. |
| + public mutating func set(_ hold: Holds, _ on: Bool, now: TimeInterval) { |
| + if on { |
| + holds.formUnion(hold) |
| + visible = true |
| + hideAt = nil |
| + } else { |
| + guard !holds.isDisjoint(with: hold) else { return } |
| + holds.subtract(hold) |
| + if !held, visible { hideAt = now + delay } |
| + } |
| + } |
| + |
| + /// The clock: hides the controls once `hideAt` has passed. |
| + public mutating func tick(now: TimeInterval) { |
| + guard !held, let hideAt, now >= hideAt else { return } |
| + hide() |
| + } |
| + |
| + /// Whether the pointer should be hidden too: on a Mac, when the |
| + /// controls have gone and the pointer is over the call. Moving it |
| + /// brings both back. |
| + public var hidesPointer: Bool { input == .pointer && !visible && pointerInside } |
| + |
| + /// How long the controls take to fade in or out. Opacity only, never a |
| + /// slide; with Reduce Motion a short cross-fade rather than none, so |
| + /// the change is still noticed without anything moving. |
| + public static func fadeDuration(showing: Bool, reduceMotion: Bool) -> TimeInterval { |
| + if reduceMotion { return 0.1 } |
| + return showing ? 0.2 : 0.35 |
| + } |
| + |
| + private mutating func reveal(now: TimeInterval) { |
| + visible = true |
| + hideAt = held ? nil : now + delay |
| + } |
| + |
| + private mutating func hide() { |
| + visible = false |
| + hideAt = nil |
| + } |
| +} |
| + |
| +/// Owns a `ControlsVisibility` and the clock it needs: wakes at `hideAt` |
| +/// and ticks. Views talk to this; tests talk to the struct. |
| +@MainActor public final class ControlsVisibilityModel: ObservableObject { |
| + @Published public private(set) var state: ControlsVisibility |
| + /// Pins the controls shown or hidden regardless of input, for fixtures. |
| + public let pinned: Bool? |
| + /// Called whenever `state` changes, after it has. |
| + public var changed: ((ControlsVisibility) -> Void)? |
| + private var timer: Task<Void, Never>? |
| + |
| + public init(input: ControlsVisibility.Input, pinned: Bool? = nil) { |
| + state = ControlsVisibility(input: input, now: Self.now) |
| + self.pinned = pinned |
| + schedule() |
| + } |
| + |
| + public var visible: Bool { pinned ?? state.visible } |
| + |
| + public func pointerMoved() { update { $0.pointerMoved(now: Self.now) } } |
| + public func pointerExited() { update { $0.pointerExited(now: Self.now) } } |
| + public func tapped() { update { $0.tapped(now: Self.now) } } |
| + public func activity() { update { $0.activity(now: Self.now) } } |
| + public func set(_ hold: ControlsVisibility.Holds, _ on: Bool) { |
| + guard on ? !state.holds.isSuperset(of: hold) : !state.holds.isDisjoint(with: hold) else { |
| + return |
| + } |
| + update { $0.set(hold, on, now: Self.now) } |
| + } |
| + |
| + private static var now: TimeInterval { ProcessInfo.processInfo.systemUptime } |
| + |
| + private func update(_ change: (inout ControlsVisibility) -> Void) { |
| + var next = state |
| + change(&next) |
| + if next != state { |
| + state = next |
| + changed?(next) |
| + } |
| + schedule() |
| + } |
| + |
| + private func schedule() { |
| + timer?.cancel() |
| + guard let hideAt = state.hideAt else { |
| + timer = nil |
| + return |
| + } |
| + let wait = max(0, hideAt - Self.now) |
| + timer = Task { [weak self] in |
| + try? await Task.sleep(for: .seconds(wait)) |
| + guard !Task.isCancelled, let self else { return } |
| + self.update { $0.tick(now: Self.now) } |
| + } |
| + } |
| +} |
| + |
| +// MARK: - Self view picture-in-picture |
| + |
| +/// A corner of the call, where the self view settles. |
| +public enum PiPCorner: String, CaseIterable, Sendable { |
| + case topLeading, topTrailing, bottomLeading, bottomTrailing |
| + |
| + public static let key = "selfViewCorner" |
| + public var top: Bool { self == .topLeading || self == .topTrailing } |
| + public var leading: Bool { self == .topLeading || self == .bottomLeading } |
| +} |
| + |
| +/// Where the floating self view goes, and where it goes when dropped. |
| +public enum PiPLayout { |
| + /// The gap between the self view and whatever it is kept clear of. |
| + public static let margin: CGFloat = 12 |
| + |
| + /// The self view's size in a stage of `container`: about a fifth of the |
| + /// width on a Mac (16:9), a little over a quarter on a phone, upright |
| + /// in portrait since that is how a phone's camera sees you. |
| + public static func size(in container: CGSize, touch: Bool) -> CGSize { |
| + if touch { |
| + let portrait = container.height >= container.width |
| + let width = min(max(container.width * (portrait ? 0.28 : 0.2), 88), 150) |
| + return CGSize(width: width, height: (width * (portrait ? 4 / 3 : 3 / 4)).rounded()) |
| + } |
| + let width = min(max(container.width * 0.2, 150), 260) |
| + return CGSize(width: width, height: (width * 9 / 16).rounded()) |
| + } |
| + |
| + /// The self view's frame in `corner`, `margin` in from the stage's |
| + /// edges and from anything `insets` keeps it clear of — the control |
| + /// bar while it shows, a sidebar, the safe area. |
| + public static func frame( |
| + corner: PiPCorner, size: CGSize, in container: CGSize, insets: EdgeInsets = EdgeInsets() |
| + ) -> CGRect { |
| + let x = |
| + corner.leading |
| + ? insets.leading + margin |
| + : container.width - insets.trailing - margin - size.width |
| + let y = |
| + corner.top |
| + ? insets.top + margin |
| + : container.height - insets.bottom - margin - size.height |
| + // A stage too small for the insets still keeps the view on it. |
| + return CGRect( |
| + x: min(max(x, 0), max(container.width - size.width, 0)), |
| + y: min(max(y, 0), max(container.height - size.height, 0)), |
| + width: size.width, height: size.height) |
| + } |
| + |
| + /// The corner a dropped self view settles in: the quarter of the stage |
| + /// its centre was headed for when let go. `predicted` is where the |
| + /// drag's momentum would carry the centre, so a flick towards a corner |
| + /// reaches it without being dragged the whole way. |
| + public static func corner(droppedAt center: CGPoint, predicted: CGPoint? = nil, in container: CGSize) |
| + -> PiPCorner |
| + { |
| + let point = predicted ?? center |
| + let leading = point.x < container.width / 2 |
| + let top = point.y < container.height / 2 |
| + switch (top, leading) { |
| + case (true, true): return .topLeading |
| + case (true, false): return .topTrailing |
| + case (false, true): return .bottomLeading |
| + case (false, false): return .bottomTrailing |
| + } |
| + } |
| +} |
| + |
| +// MARK: - Notices |
| + |
| +/// Something the call needs to say, over the picture. |
| +public struct CallNotice: Identifiable, Equatable, Sendable { |
| + public enum Kind: Equatable, Sendable { |
| + /// The connection dropped and is coming back. |
| + case reconnecting |
| + /// You are waiting for a member to let you in. |
| + case waitingToJoin |
| + /// The meeting is full; "Try joining again". |
| + case full |
| + /// The call is over for you — ended, open elsewhere, removed — and |
| + /// "Join again" may be offered. |
| + case ended |
| + /// Something went wrong that the person should know about. |
| + case problem |
| + /// This app is sharing its screen: said in words for as long as it is. |
| + case sharing |
| + /// Relay unavailable: direct connections only. |
| + case floor |
| + } |
| + public let kind: Kind |
| + public let text: String |
| + public var id: String { "\(kind):\(text)" } |
| + |
| + /// Important notices stay on screen with the controls hidden, until what |
| + /// they are about is over or the person dismisses them. The rest fade |
| + /// with the controls. |
| + public var important: Bool { kind != .floor } |
| + /// Whether the notice has a close button. Notices that describe a state |
| + /// that ends on its own (reconnecting, sharing) go when it does; a |
| + /// problem or an ended call is the person's to put away. |
| + public var dismissible: Bool { kind == .problem } |
| +} |
| + |
| +public enum CallNotices { |
| + /// What to say over the call, most urgent first. `status` is the call's |
| + /// own line ("In the meeting"), which is only a notice when it reports |
| + /// a problem. |
| + public static func notices( |
| + phase: String?, status: String, sharingScreen: Bool, screenNote: String, floor: Bool |
| + ) -> [CallNotice] { |
| + var out: [CallNotice] = [] |
| + switch phase { |
| + case "reconnecting": out.append(CallNotice(kind: .reconnecting, text: status)) |
| + case "waiting": out.append(CallNotice(kind: .waitingToJoin, text: status)) |
| + case "full": out.append(CallNotice(kind: .full, text: status)) |
| + case "ended": out.append(CallNotice(kind: .ended, text: status)) |
| + default: |
| + if status.hasPrefix("Could not") { out.append(CallNotice(kind: .problem, text: status)) } |
| + } |
| + if sharingScreen { |
| + out.append(CallNotice(kind: .sharing, text: "You are sharing your screen")) |
| + } else if !screenNote.isEmpty { |
| + out.append(CallNotice(kind: .problem, text: screenNote)) |
| + } |
| + if floor { |
| + out.append( |
| + CallNotice( |
| + kind: .floor, |
| + text: "Direct connections only. People on some networks may be unable to connect.")) |
| + } |
| + return out |
| + } |
| +} |
| + |
| +// MARK: - Grid |
| + |
| +/// How a grid of tiles fills a stage without scrolling. |
| +public enum GridFit { |
| + /// The column count that makes `count` tiles of `aspect` (width over |
| + /// height) as large as they can be in `size`, `spacing` apart. |
| + public static func columns(count: Int, in size: CGSize, aspect: CGFloat, spacing: CGFloat = 4) |
| + -> Int |
| + { |
| + guard count > 1, size.width > 0, size.height > 0 else { return 1 } |
| + var best = 1 |
| + var bestWidth: CGFloat = 0 |
| + for columns in 1...count { |
| + let rows = (count + columns - 1) / columns |
| + let width = tileWidth(columns: columns, rows: rows, in: size, aspect: aspect, spacing: spacing) |
| + if width > bestWidth + 0.5 { |
| + best = columns |
| + bestWidth = width |
| + } |
| + } |
| + return best |
| + } |
| + |
| + /// The tile size for `count` tiles in `columns` columns. |
| + public static func tile(count: Int, columns: Int, in size: CGSize, aspect: CGFloat, spacing: CGFloat = 4) |
| + -> CGSize |
| + { |
| + let rows = (max(count, 1) + max(columns, 1) - 1) / max(columns, 1) |
| + let width = tileWidth( |
| + columns: max(columns, 1), rows: rows, in: size, aspect: aspect, spacing: spacing) |
| + return CGSize(width: width, height: width / aspect) |
| + } |
| + |
| + private static func tileWidth( |
| + columns: Int, rows: Int, in size: CGSize, aspect: CGFloat, spacing: CGFloat |
| + ) -> CGFloat { |
| + let across = (size.width - CGFloat(columns - 1) * spacing) / CGFloat(columns) |
| + let down = (size.height - CGFloat(rows - 1) * spacing) / CGFloat(rows) |
| + return max(0, min(across, down * aspect)) |
| + } |
| +} |
| + |
| +// MARK: - Camera flip |
| + |
| +public enum CameraFlip { |
| + /// The camera "Flip camera" moves to: from the built-in (front) camera |
| + /// to the first other one, and from any other back to the built-in — |
| + /// front to back and back to front on an iPhone. Nil with nothing to |
| + /// flip to. |
| + public static func next(from current: String?, in cameras: [CameraDevice]) -> CameraDevice? { |
| + guard cameras.count > 1 else { return nil } |
| + guard let current, let now = cameras.first(where: { $0.id == current }) else { |
| + return cameras.first |
| + } |
| + if let other = cameras.first(where: { $0.builtIn != now.builtIn }) { return other } |
| + let index = cameras.firstIndex(of: now) ?? 0 |
| + return cameras[(index + 1) % cameras.count] |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/CallFixtures.swift b/native/apple/Sources/CallsKit/CallFixtures.swift| new file mode 100644 |
| index 0000000..2bb5f7a |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/CallFixtures.swift |
| @@ -0,0 +1,45 @@ |
| +import Foundation |
| + |
| +/// Described calls for the offline snapshots of the call views (Mac, from |
| +/// CallsInterop) and the iPhone's UI test of its controls (a debug build |
| +/// launched with `-CallsCallFixture <name>`). No server, no media: the |
| +/// tiles show their placeholders, which is what an off-screen capture |
| +/// would show of video anyway. |
| +@_spi(Fixtures) public enum CallFixture: String, CaseIterable, Sendable { |
| + /// One other person, muted, on the stage. |
| + case focus |
| + /// Four people in the call. |
| + case grid |
| + /// Somebody sharing a screen, with two cameras beside it, and you |
| + /// sharing too. |
| + case screen |
| + /// Somebody waiting to be let in, and the connection coming back. |
| + case notices |
| + |
| + public static let title = "Call, 24 Sep 10:21" |
| + |
| + @MainActor public func apply(to call: Call) { |
| + let me = #"{"peer":"me","name":"Paul Campbell","kind":"member"}"# |
| + let ana = #"{"peer":"ana","name":"Ana García","kind":"member","media":{"muted":true,"cameraOff":false}}"# |
| + let tom = #"{"peer":"tom","name":"Tom Byrne","kind":"guest","media":{"muted":false,"cameraOff":true}}"# |
| + let li = #"{"peer":"li","name":"Li Wei","kind":"member"}"# |
| + func roster(_ phase: String, _ peers: [String], waiting: [String] = []) -> String { |
| + #"{"phase":"\#(phase)","peer":"me","boot":"fixture","peers":[\#(peers.joined(separator: ","))],"waiting":[\#(waiting.joined(separator: ","))]}"# |
| + } |
| + switch self { |
| + case .focus: |
| + call.showFixture(title: Self.title, state: roster("admitted", [me, ana])) |
| + case .grid: |
| + call.showFixture(title: Self.title, state: roster("admitted", [me, ana, tom, li])) |
| + case .screen: |
| + call.showFixture( |
| + title: Self.title, state: roster("admitted", [me, ana, tom, li]), screens: ["li"], |
| + sharingScreen: true) |
| + case .notices: |
| + let knock = #"{"peer":"sam","name":"Sam Kelly","kind":"guest"}"# |
| + call.showFixture( |
| + title: Self.title, status: "Reconnecting…", |
| + state: roster("reconnecting", [me, ana], waiting: [knock]), muted: true, floor: true) |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/CallsSettingsView.swift b/native/apple/Sources/CallsKit/CallsSettingsView.swift| index 7eea294..6a82659 100644 |
| --- a/native/apple/Sources/CallsKit/CallsSettingsView.swift |
| +++ b/native/apple/Sources/CallsKit/CallsSettingsView.swift |
| @@ -12,12 +12,26 @@ |
| @AppStorage(MiniWindowPreferences.placementKey) private var placement = |
| MiniWindowPreferences.defaultPlacement |
| @AppStorage(MiniWindowPreferences.positionsKey) private var positions: String? |
| + @AppStorage(CallLayoutStyle.key) private var callLayout = CallLayoutStyle.defaultStyle |
| public init(model: CallsModel) { self.model = model } |
| public var body: some View { |
| Form { |
| Section { |
| + Picker(selection: $callLayout) { |
| + ForEach(CallLayoutStyle.allCases) { style in |
| + Text(style.title).tag(style) |
| + } |
| + } label: { |
| + Text("Call layout") |
| + Text( |
| + callLayout == .fullWindow |
| + ? "The call fills the window. Move the pointer over it to show the controls." |
| + : "The call sits above a row of controls that are always showing." |
| + ) |
| + } |
| + .accessibilityIdentifier("callLayout") |
| Toggle(isOn: $miniWindow) { |
| Text("Show a mini window when you switch to another app") |
| Text( |
diff --git a/native/apple/Sources/CallsKit/DevicePanel.swift b/native/apple/Sources/CallsKit/DevicePanel.swift| index 0594867..5d1bf8b 100644 |
| --- a/native/apple/Sources/CallsKit/DevicePanel.swift |
| +++ b/native/apple/Sources/CallsKit/DevicePanel.swift |
| @@ -17,6 +17,7 @@ import SwiftUI |
| @ObservedObject private var audio: AudioRoutes |
| @State private var camera: String? |
| @State private var cameraRequest = 0 |
| + @State private var showingAudioDetails = false |
| #if os(iOS) |
| @StateObject private var micModes = MicrophoneModes() |
| #endif |
| @@ -53,8 +54,7 @@ import SwiftUI |
| screenSection |
| - Divider() |
| - audioReadout |
| + audioFoot |
| } |
| .padding(16) |
| .onAppear { |
| @@ -263,13 +263,37 @@ import SwiftUI |
| Text(call.screenMode.explanation) |
| .font(.footnote).foregroundStyle(.secondary) |
| .fixedSize(horizontal: false, vertical: true) |
| - if call.sharingScreen { |
| - Text(call.screenReport?.summary ?? "Screen: starting…") |
| - .font(.callout).foregroundStyle(.secondary) |
| + } |
| + } |
| + |
| + /// The panel's foot: a warning line only when something is wrong, and |
| + /// the ⓘ that opens the full readout. The readout used to fill the |
| + /// bottom of the panel all the time; most of the time it said all is |
| + /// well, at length. |
| + private var audioFoot: some View { |
| + HStack(alignment: .firstTextBaseline, spacing: 8) { |
| + if let warning = AudioWarning.line( |
| + health: call.audioHealth, processing: call.audioProcessing, reduceNoise: call.reduceNoise) |
| + { |
| + Label(warning, systemImage: "exclamationmark.triangle.fill") |
| + .font(.callout).foregroundStyle(.orange) |
| .fixedSize(horizontal: false, vertical: true) |
| - .textSelection(.enabled) |
| - .accessibilityIdentifier("screenReport") |
| + .accessibilityIdentifier("audioWarning") |
| } |
| + Spacer(minLength: 0) |
| + Button("Audio details", systemImage: "info.circle") { showingAudioDetails.toggle() } |
| + .labelStyle(.iconOnly) |
| + .buttonStyle(.borderless) |
| + .help("Audio details") |
| + .accessibilityIdentifier("audioDetails") |
| + .popover(isPresented: $showingAudioDetails) { |
| + ScrollView { audioReadout.padding(16) } |
| + .frame(minWidth: 280, idealWidth: 320, maxWidth: 360, maxHeight: 420) |
| + .fixedSize(horizontal: false, vertical: true) |
| + #if os(iOS) |
| + .presentationCompactAdaptation(.popover) |
| + #endif |
| + } |
| } |
| } |
| @@ -291,7 +315,15 @@ import SwiftUI |
| .fixedSize(horizontal: false, vertical: true) |
| .accessibilityIdentifier("audioProcessing") |
| } |
| + if call.sharingScreen { |
| + Text("Screen sharing").font(.headline).padding(.top, 6) |
| + Text(call.screenReport?.summary ?? "Screen: starting…") |
| + .font(.callout).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + .accessibilityIdentifier("screenReport") |
| + } |
| } |
| + .textSelection(.enabled) |
| } |
| private func row(_ label: String, _ value: String) -> some View { |
diff --git a/native/apple/Sources/CallsKit/InviteLinkCopy.swift b/native/apple/Sources/CallsKit/InviteLinkCopy.swift| new file mode 100644 |
| index 0000000..aa6ab1e |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/InviteLinkCopy.swift |
| @@ -0,0 +1,121 @@ |
| +import SwiftUI |
| + |
| +#if os(iOS) |
| + import UIKit |
| +#else |
| + import AppKit |
| +#endif |
| + |
| +/// Call → Copy Invite Link (⌘⇧I): the meeting's invitation on the |
| +/// clipboard without opening the share sheet. |
| +/// |
| +/// It asks the server the way the Share button does — `POST |
| +/// /m/{sqid}/share`, which hands a member the meeting's current sealed |
| +/// invitation, and mints one only if there is none. It never replaces or |
| +/// revokes a link (see store.shareLink and |
| +/// TestShareReusesSealedInvitationAndPreservesLegacyLinks). |
| +public enum InviteLinkCopy { |
| + public enum Rule: Equatable, Sendable { |
| + /// The menu item is off: there is no call to invite anyone to. |
| + case unavailable |
| + /// On, and a press copies the link. |
| + case copy |
| + /// On, and a press says why there is nothing to copy, rather than a |
| + /// beep: a guest cannot share a meeting, and before the meeting has |
| + /// said who you are, nobody knows yet. |
| + case explain(String) |
| + } |
| + |
| + public static let shortcut = KeyboardShortcut("i", modifiers: [.command, .shift]) |
| + public static let title = "Copy Invite Link" |
| + |
| + /// Whether ⌘⇧I does anything, and what. |
| + public static func rule(inCall: Bool, phase: String?, standing: String) -> Rule { |
| + guard inCall, phase != "ended" else { return .unavailable } |
| + switch standing { |
| + case "member": return .copy |
| + case "": return .explain("The invite link isn’t ready yet. Try again in a moment.") |
| + default: return .explain("Only members of this meeting can share its invite link.") |
| + } |
| + } |
| +} |
| + |
| +extension Call { |
| + /// Copies the meeting's invite link, or says why not, in `notice` (the |
| + /// brief message over the stage) and to VoiceOver. |
| + public func copyInviteLink(inCall: Bool = true) async { |
| + switch InviteLinkCopy.rule(inCall: inCall, phase: state?.phase, standing: standing) { |
| + case .unavailable: return |
| + case .explain(let why): say(why) |
| + case .copy: |
| + if invitationURL == nil { await loadInvitation() } |
| + guard let url = invitationURL else { |
| + say("Could not get the invite link. Try again.") |
| + return |
| + } |
| + #if os(iOS) |
| + UIPasteboard.general.url = url |
| + #else |
| + NSPasteboard.general.clearContents() |
| + NSPasteboard.general.setString(url.absoluteString, forType: .string) |
| + #endif |
| + say("Invite link copied") |
| + } |
| + } |
| + |
| + /// A short message over the call that goes by itself: never a dialog. |
| + func say(_ text: String) { |
| + toastSerial += 1 |
| + let serial = toastSerial |
| + toast = text |
| + AccessibilityNotification.Announcement(text).post() |
| + Task { @MainActor [weak self] in |
| + try? await Task.sleep(for: .seconds(2.5)) |
| + guard let self, self.toastSerial == serial else { return } |
| + self.toast = nil |
| + } |
| + } |
| +} |
| + |
| +/// The call's brief message ("Invite link copied"), over the stage. |
| +/// Announced when it is set, so this is hidden from VoiceOver. |
| +public struct CallToast: View { |
| + @ObservedObject private var call: Call |
| + public init(call: Call) { self.call = call } |
| + public var body: some View { |
| + VStack { |
| + if let text = call.toast { |
| + Label(text, systemImage: text == "Invite link copied" ? "link" : "info.circle") |
| + .font(.callout.weight(.medium)) |
| + .padding(.horizontal, 14).padding(.vertical, 8) |
| + .background(.regularMaterial, in: Capsule()) |
| + .shadow(color: .black.opacity(0.2), radius: 8, y: 2) |
| + .transition(.opacity) |
| + .accessibilityHidden(true) |
| + } |
| + } |
| + .animation(.easeInOut(duration: 0.15), value: call.toast) |
| + .allowsHitTesting(false) |
| + } |
| +} |
| + |
| +/// The Call menu's items, in order, as data the app builds its menu from |
| +/// and the tests can read: title and shortcut. |
| +public enum CallMenuItem: CaseIterable, Sendable { |
| + case mute |
| + case copyInviteLink |
| + |
| + public func title(muted: Bool) -> String { |
| + switch self { |
| + case .mute: muted ? "Unmute Microphone" : "Mute Microphone" |
| + case .copyInviteLink: InviteLinkCopy.title |
| + } |
| + } |
| + |
| + public var shortcut: KeyboardShortcut { |
| + switch self { |
| + case .mute: KeyboardShortcut("m", modifiers: [.command, .shift]) |
| + case .copyInviteLink: InviteLinkCopy.shortcut |
| + } |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift| index cff43bf..e3607bf 100644 |
| --- a/native/apple/Sources/CallsKit/MacCallView.swift |
| +++ b/native/apple/Sources/CallsKit/MacCallView.swift |
| @@ -1,29 +1,91 @@ |
| #if os(macOS) |
| + import AppKit |
| import SwiftUI |
| public struct MacCallView: View { |
| - @ObservedObject private var call: Call |
| - private let leave: () -> Void |
| - private let rejoin: (() -> Void)? |
| - @State private var grid: Bool |
| - @State private var showingChat: Bool |
| - @State private var chosen: StageItem? |
| - @State private var readChatCount = 0 |
| - @State private var showingDevices = false |
| - @StateObject private var chatWindow = MacChatWindow() |
| + @ObservedObject var call: Call |
| + let leave: () -> Void |
| + let rejoin: (() -> Void)? |
| + @State var grid: Bool |
| + @State var showingChat: Bool |
| + @State var chosen: StageItem? |
| + @State var readChatCount = 0 |
| + @State var showingDevices: Bool |
| + @State var showingLayout = 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<String> = [] |
| + @State var barFrame: CGRect = .zero |
| + @FocusState var focusedControl: CallControlID? |
| + @Environment(\.accessibilityReduceMotion) var reduceMotion |
| + @Environment(\.accessibilityVoiceOverEnabled) var voiceOver |
| + private let style: CallLayoutStyle? |
| + /// - Parameters: |
| + /// - 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 = false, showingChat: Bool = false, leave: @escaping () -> Void, |
| + call: Call, grid: Bool = false, 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 |
| _grid = State(initialValue: grid) |
| _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: call.screens) { before, after in |
| + // Somebody starting to share is asking to be looked at, so a new |
| + // screen takes the stage from whatever was chosen before it. |
| + if after.contains(where: { !before.contains($0) }) { chosen = nil } |
| + } |
| + .onChange(of: showingChat) { _, visible in |
| + if visible { readChatCount = call.receivedChatCount } |
| + } |
| + .onChange(of: chatWindow.isOpen) { _, visible in |
| + if visible { readChatCount = call.receivedChatCount } |
| + } |
| + .onDisappear { chatWindow.close() } |
| + } |
| + |
| + /// 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) { |
| @@ -55,7 +117,7 @@ |
| .help("Choose the participant or screen to show on the main stage") |
| } |
| Picker("Video layout", selection: $grid) { |
| - Label("Focus", systemImage: "rectangle.inset.bottomtrailing").tag(false) |
| + Label("Focus", systemImage: "pip").tag(false) |
| Label("Grid", systemImage: "square.grid.2x2").tag(true) |
| }.pickerStyle(.segmented).fixedSize() |
| } |
| @@ -110,39 +172,17 @@ |
| Button("Join again", action: rejoin).buttonStyle(.borderedProminent) |
| .accessibilityIdentifier("joinAgain") |
| } |
| - controls |
| - } |
| - .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: call.screens) { before, after in |
| - // Somebody starting to share is asking to be looked at, so a new |
| - // screen takes the stage from whatever was chosen before it. |
| - if after.contains(where: { !before.contains($0) }) { chosen = nil } |
| - } |
| - .onChange(of: showingChat) { _, visible in |
| - if visible { readChatCount = call.receivedChatCount } |
| - } |
| - .onChange(of: chatWindow.isOpen) { _, visible in |
| - if visible { readChatCount = call.receivedChatCount } |
| + controls(fullWindow: false) |
| } |
| - .onDisappear { chatWindow.close() } |
| } |
| - private var peers: [RosterPeer] { |
| + var peers: [RosterPeer] { |
| call.state?.peers.filter { $0.peer != call.state?.peer } ?? [] |
| } |
| - private var focused: StageItem? { |
| + var focused: StageItem? { |
| Stage.focus(chosen: chosen, peers: peers.map(\.peer), screens: call.screens) |
| } |
| - private var stageShowsScreen: Bool { |
| + var stageShowsScreen: Bool { |
| if case .screen = focused { return true } |
| return false |
| } |
| @@ -219,14 +259,17 @@ |
| /// 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. |
| - private func thumbnail(count: Int, in stage: CGSize) -> CGSize { |
| + 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) |
| } |
| - private var controls: some View { |
| - HStack(spacing: 12) { |
| + /// 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. |
| + func controls(fullWindow: Bool) -> some View { |
| + HStack(spacing: fullWindow ? 10 : 12) { |
| Button( |
| call.muted ? "Unmute microphone" : "Mute microphone", |
| systemImage: call.muted ? "mic.slash.fill" : "mic.fill" |
| @@ -237,6 +280,8 @@ |
| emphasized: call.muted) |
| ) |
| .help(call.muted ? "Unmute microphone" : "Mute microphone") |
| + .accessibilityIdentifier("mute") |
| + .focused($focusedControl, equals: .mute) |
| Button( |
| call.cameraOff ? "Turn camera on" : "Turn camera off", |
| systemImage: call.cameraOff ? "video.slash.fill" : "video.fill" |
| @@ -247,6 +292,8 @@ |
| emphasized: call.cameraOff) |
| ) |
| .help(call.cameraOff ? "Turn camera on" : "Turn camera off") |
| + .accessibilityIdentifier("camera") |
| + .focused($focusedControl, equals: .camera) |
| Button( |
| call.sharingScreen ? "Stop sharing your screen" : "Share your screen", |
| systemImage: call.sharingScreen ? "rectangle.on.rectangle.slash" : "rectangle.on.rectangle" |
| @@ -261,18 +308,28 @@ |
| .help(call.sharingScreen ? "Stop sharing your screen" : "Share a window or your whole screen") |
| .disabled(call.state?.phase != "admitted") |
| .accessibilityIdentifier("shareScreen") |
| - Button("Devices", systemImage: "slider.horizontal.3") { showingDevices.toggle() } |
| + .focused($focusedControl, equals: .share) |
| + Button("Devices", systemImage: "slider.horizontal.3") { |
| + withAnimation(panelAnimation) { showingDevices.toggle() } |
| + } |
| .buttonStyle(CallActionStyle(color: .teal, emphasized: showingDevices)) |
| .help("Choose the camera, microphone and speaker for this call") |
| .accessibilityIdentifier("devices") |
| + .focused($focusedControl, equals: .devices) |
| + if fullWindow { |
| + layoutMenu.focused($focusedControl, equals: .layout) |
| + } |
| MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo)) |
| .help("Share meeting link") |
| - Spacer() |
| + .focused($focusedControl, equals: .link) |
| + Spacer(minLength: fullWindow ? 24 : nil) |
| Button(chatTitle, systemImage: "bubble.left.and.bubble.right.fill") { |
| if chatWindow.isOpen { chatWindow.close() } |
| - showingChat.toggle() |
| + withAnimation(panelAnimation) { showingChat.toggle() } |
| }.buttonStyle(CallActionStyle(color: .purple, emphasized: showingChat || chatWindow.isOpen)) |
| .help(chatTitle) |
| + .accessibilityIdentifier("openChat") |
| + .focused($focusedControl, equals: .chat) |
| .overlay(alignment: .topTrailing) { |
| if unread > 0 { |
| Text("\(unread)").font(.caption.bold()).foregroundStyle(.white) |
| @@ -288,10 +345,61 @@ |
| } |
| Button("Leave meeting", systemImage: "phone.down.fill", role: .destructive, action: leave) |
| .buttonStyle(CallActionStyle(color: .red, emphasized: true)).help("Leave meeting") |
| + .accessibilityIdentifier("leave") |
| + .focused($focusedControl, equals: .leave) |
| }.frame(maxWidth: .infinity) |
| } |
| - private var unread: Int { max(0, call.receivedChatCount - readChatCount) } |
| - private var chatTitle: String { |
| + |
| + /// Focus or Grid, and in Focus, who is on the stage: the classic |
| + /// view's picker and participant menu, folded into one bar button and |
| + /// the small popover it opens. |
| + private var layoutMenu: some View { |
| + Button( |
| + "Video layout", systemImage: grid ? "square.grid.2x2" : "pip" |
| + ) { showingLayout.toggle() } |
| + .buttonStyle(CallActionStyle(color: .gray, emphasized: showingLayout)) |
| + .help("Video layout: Focus or Grid, and who is on the main stage") |
| + .accessibilityIdentifier("videoLayout") |
| + .popover(isPresented: $showingLayout, arrowEdge: .top) { |
| + VStack(alignment: .leading, spacing: 12) { |
| + Picker("Video layout", selection: $grid) { |
| + Label("Focus", systemImage: "pip").tag(false) |
| + Label("Grid", systemImage: "square.grid.2x2").tag(true) |
| + } |
| + .pickerStyle(.segmented).labelsHidden().fixedSize() |
| + if peers.count > 1 || !call.screens.isEmpty { |
| + Text("Show on the main stage").font(.headline) |
| + ForEach(call.screens, id: \.self) { peer in |
| + stageChoice(screenCaption(call.name(of: peer)), .screen(peer)) |
| + } |
| + ForEach(peers) { peer in |
| + stageChoice(peer.name, .camera(peer.peer)) |
| + } |
| + } |
| + } |
| + .padding(14) |
| + .frame(minWidth: 220, alignment: .leading) |
| + } |
| + } |
| + |
| + private func stageChoice(_ title: String, _ item: StageItem) -> some View { |
| + Button { |
| + chosen = item |
| + grid = false |
| + showingLayout = false |
| + } label: { |
| + Label(title, systemImage: !grid && focused == item ? "checkmark" : "person") |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| + } |
| + .buttonStyle(.borderless) |
| + } |
| + |
| + 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" |
diff --git a/native/apple/Sources/CallsKit/MacFullWindowCall.swift b/native/apple/Sources/CallsKit/MacFullWindowCall.swift| new file mode 100644 |
| index 0000000..3e2a2cf |
| --- /dev/null |
| +++ b/native/apple/Sources/CallsKit/MacFullWindowCall.swift |
| @@ -0,0 +1,557 @@ |
| +#if os(macOS) |
| + import AppKit |
| + import SwiftUI |
| + |
| + /// The controls in the call bar, for keyboard focus: while one has it, |
| + /// the bar stays up. |
| + enum CallControlID: Hashable { |
| + case mute, camera, share, devices, layout, link, chat, leave |
| + } |
| + |
| + /// The full-window call: the picture edge to edge under the window's |
| + /// traffic lights, with the title, the controls and the panels over it. |
| + /// The controls come with the pointer and go 1.5 s after it stops, or |
| + /// as soon as it leaves the window (see ControlsVisibility for what |
| + /// keeps them up). Notices that matter stay whatever the controls do. |
| + extension MacCallView { |
| + /// Room kept clear at the top for the title bar's traffic lights. |
| + static let titleBarHeight: CGFloat = 32 |
| + /// Height of the floating bar with its margin below: what the self |
| + /// view and a tile's name move up by while the bar shows. |
| + static let barReserve: CGFloat = 44 + 20 + 16 |
| + static let panelWidth: CGFloat = 320 |
| + static let tileBackdrop = Color(white: 0.13) |
| + |
| + var immersive: some View { |
| + GeometryReader { geometry in |
| + let size = geometry.size |
| + ZStack { |
| + Color.black |
| + fullWindowStage(in: size) |
| + if !grid { selfView(in: size) } |
| + topOverlay |
| + panels(in: size) |
| + bar |
| + } |
| + .coordinateSpace(name: "call") |
| + .frame(width: size.width, height: size.height) |
| + } |
| + .ignoresSafeArea() |
| + .background(FullWindowTitleBar()) |
| + .background( |
| + CallWindowActivity( |
| + moved: { point in |
| + chrome.pointerMoved() |
| + chrome.set(.pointerOverControls, chrome.visible && barFrame.contains(point)) |
| + }, |
| + exited: { chrome.pointerExited() }, |
| + activity: { chrome.activity() }, |
| + menuTracking: { menuTracking = $0 }, |
| + sheet: { sheetShowing = $0 }) |
| + ) |
| + .animation(fade, value: chrome.visible) |
| + .onAppear { updateHolds() } |
| + .onChange(of: showingDevices) { updateHolds() } |
| + .onChange(of: showingChat) { updateHolds() } |
| + .onChange(of: showingLayout) { updateHolds() } |
| + .onChange(of: menuTracking) { updateHolds() } |
| + .onChange(of: sheetShowing) { updateHolds() } |
| + .onChange(of: voiceOver) { updateHolds() } |
| + .onChange(of: focusedControl) { _, now in |
| + chrome.set(.keyboardFocus, now != nil) |
| + } |
| + .onChange(of: chrome.state.hidesPointer) { _, hide in |
| + // Hidden until it moves, which is also what brings the controls |
| + // back: AppKit shows it again on the first movement by itself. |
| + if hide, chrome.pinned == nil { NSCursor.setHiddenUntilMouseMoves(true) } |
| + } |
| + } |
| + |
| + private var fade: Animation { |
| + .easeInOut( |
| + duration: ControlsVisibility.fadeDuration( |
| + showing: chrome.visible, reduceMotion: reduceMotion)) |
| + } |
| + |
| + private func updateHolds() { |
| + chrome.set(.menuOpen, showingDevices || showingChat || showingLayout || menuTracking) |
| + chrome.set(.alert, sheetShowing) |
| + chrome.set(.voiceOver, voiceOver) |
| + } |
| + |
| + /// How far up a name at the bottom of the stage sits, clear of the bar. |
| + private var lift: CGFloat { chrome.visible ? Self.barReserve - 8 : 0 } |
| + |
| + // MARK: Stage |
| + |
| + @ViewBuilder private func fullWindowStage(in size: CGSize) -> some View { |
| + if grid { |
| + fullWindowGrid(in: size) |
| + } else { |
| + ZStack(alignment: .topTrailing) { |
| + switch focused { |
| + case .screen(let peer): |
| + MacVideoTile( |
| + track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), |
| + screen: true, cornerRadius: 0, compact: true, labelLift: lift) |
| + case .camera(let peer): |
| + MacVideoTile( |
| + track: call.remoteTracks[peer], label: call.name(of: peer), |
| + muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer), |
| + cornerRadius: 0, compact: true, labelLift: lift) |
| + case nil: |
| + ContentUnavailableView( |
| + "Waiting for others", systemImage: "person.2", |
| + description: Text("Your meeting is ready. Others will appear here when they join.") |
| + ) |
| + .foregroundStyle(.white.opacity(0.85)) |
| + .environment(\.colorScheme, .dark) |
| + .frame(maxWidth: .infinity, maxHeight: .infinity) |
| + } |
| + // With a screen on the stage every camera would otherwise be |
| + // off it, so the people come down to a strip at the side. You |
| + // are the floating self view, as always. |
| + if stageShowsScreen, !peers.isEmpty { |
| + let thumb = strip(in: size) |
| + VStack(spacing: 8) { |
| + ForEach(peers) { peer in |
| + MacVideoTile( |
| + track: call.remoteTracks[peer.peer], label: peer.name, |
| + muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer), |
| + cornerRadius: 10, compact: true, backdrop: Self.tileBackdrop |
| + ) |
| + .frame(width: thumb.width, height: thumb.height) |
| + } |
| + } |
| + .shadow(color: .black.opacity(0.3), radius: 6, y: 2) |
| + .padding(.top, Self.titleBarHeight + 12).padding(.trailing, 12) |
| + } |
| + } |
| + } |
| + } |
| + |
| + /// The camera thumbnails beside a shared screen. |
| + private func strip(in size: CGSize) -> CGSize { |
| + let room = size.height - Self.titleBarHeight - 12 - Self.barReserve |
| + return thumbnail(count: peers.count, in: CGSize(width: size.width, height: max(room, 120))) |
| + } |
| + |
| + /// Every tile as large as it can be without scrolling, until there are |
| + /// so many that they would be too small to see, when it scrolls. |
| + @ViewBuilder private func fullWindowGrid(in size: CGSize) -> some View { |
| + let count = call.screens.count + 1 + peers.count |
| + let area = CGSize(width: size.width - 8, height: size.height - 8) |
| + let columns = GridFit.columns(count: count, in: area, aspect: 16 / 9) |
| + let fitted = GridFit.tile(count: count, columns: columns, in: area, aspect: 16 / 9) |
| + let tile = fitted.width >= 200 ? fitted : CGSize(width: 200, height: 200 * 9 / 16) |
| + let across = |
| + fitted.width >= 200 |
| + ? columns : max(1, Int((area.width + 4) / (tile.width + 4))) |
| + ScrollView { |
| + LazyVGrid( |
| + columns: Array(repeating: GridItem(.fixed(tile.width), spacing: 4), count: across), |
| + spacing: 4 |
| + ) { |
| + ForEach(call.screens, id: \.self) { peer in |
| + MacVideoTile( |
| + track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), |
| + screen: true, cornerRadius: 6, compact: true, backdrop: Self.tileBackdrop |
| + ) |
| + .frame(width: tile.width, height: tile.height) |
| + } |
| + MacVideoTile( |
| + track: call.localTrack, label: "You", muted: call.muted, mirrored: true, |
| + cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: 6, |
| + compact: true, backdrop: Self.tileBackdrop |
| + ) |
| + .frame(width: tile.width, height: tile.height) |
| + ForEach(peers) { peer in |
| + MacVideoTile( |
| + track: call.remoteTracks[peer.peer], label: peer.name, |
| + muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer), |
| + cornerRadius: 6, compact: true, backdrop: Self.tileBackdrop |
| + ) |
| + .frame(width: tile.width, height: tile.height) |
| + } |
| + } |
| + .padding(4) |
| + .frame(minHeight: size.height) |
| + } |
| + .scrollIndicators(.never) |
| + } |
| + |
| + // MARK: Self view |
| + |
| + /// You, small and floating: dragged anywhere, settling in the nearest |
| + /// corner, kept clear of the bar while it shows and of an open chat. |
| + /// Muted shows on it whether or not the controls do. |
| + private func selfView(in size: CGSize) -> some View { |
| + let corner = PiPCorner(rawValue: pipCorner) ?? .bottomTrailing |
| + let pip = PiPLayout.size(in: size, touch: false) |
| + let frame = PiPLayout.frame(corner: corner, size: pip, in: size, insets: selfInsets(in: size)) |
| + return MacVideoTile( |
| + track: call.localTrack, label: "You", muted: call.muted, mirrored: true, |
| + cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: 10, |
| + compact: true, backdrop: Self.tileBackdrop |
| + ) |
| + .frame(width: pip.width, height: pip.height) |
| + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(.white.opacity(0.18))) |
| + .shadow(color: .black.opacity(0.35), radius: 8, y: 3) |
| + .position(x: frame.midX + pipDrag.width, y: frame.midY + pipDrag.height) |
| + .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) |
| + .accessibilityActions { |
| + ForEach(PiPCorner.allCases, id: \.self) { place in |
| + Button(place.moveTitle) { pipCorner = place.rawValue } |
| + } |
| + } |
| + } |
| + |
| + private func selfInsets(in size: CGSize) -> EdgeInsets { |
| + var trailing: CGFloat = 0 |
| + if showingChat { trailing += Self.panelWidth + 12 } |
| + if stageShowsScreen, !peers.isEmpty { trailing += strip(in: size).width + 12 } |
| + return EdgeInsets( |
| + top: Self.titleBarHeight, leading: 0, |
| + bottom: chrome.visible ? Self.barReserve - 12 : 0, trailing: trailing) |
| + } |
| + |
| + // MARK: Title and notices |
| + |
| + private var notices: [CallNotice] { |
| + CallNotices.notices( |
| + phase: call.state?.phase, status: call.status, sharingScreen: call.sharingScreen, |
| + screenNote: call.screenNote, floor: call.floor) |
| + } |
| + |
| + private var topOverlay: some View { |
| + ZStack(alignment: .top) { |
| + // The title and how the call is going, beside the traffic lights. |
| + // They go with the controls; the notices below them do not. |
| + HStack { |
| + VStack(alignment: .leading, spacing: 2) { |
| + Text(call.title).font(.headline).lineLimit(1) |
| + Text(call.status).font(.callout).foregroundStyle(.secondary).lineLimit(1) |
| + .accessibilityAddTraits(.updatesFrequently) |
| + ForEach(notices.filter { !$0.important }) { notice in |
| + Text(notice.text).font(.callout).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + } |
| + } |
| + .padding(.horizontal, 12).padding(.vertical, 7) |
| + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) |
| + .frame(maxWidth: 420, alignment: .leading) |
| + Spacer(minLength: 0) |
| + } |
| + .padding(.leading, 84).padding(.top, 8).padding(.trailing, 12) |
| + .opacity(chrome.visible ? 1 : 0) |
| + .allowsHitTesting(chrome.visible) |
| + .accessibilityHidden(!chrome.visible) |
| + |
| + VStack(spacing: 8) { |
| + ForEach(notices.filter { $0.important && !dismissedNotices.contains($0.id) }) { |
| + notice in |
| + noticeView(notice) |
| + } |
| + ForEach(call.state?.waiting ?? []) { peer in |
| + HStack(spacing: 12) { |
| + Label("\(peer.name) is waiting", systemImage: "person.badge.clock") |
| + Button("Let in") { call.admit(peer.peer) }.buttonStyle(.borderedProminent) |
| + } |
| + .modifier(NoticeSurface()) |
| + } |
| + } |
| + .padding(.top, Self.titleBarHeight + 36) |
| + .padding(.horizontal, 90) |
| + } |
| + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
| + } |
| + |
| + @ViewBuilder private func noticeView(_ notice: CallNotice) -> some View { |
| + HStack(spacing: 12) { |
| + switch notice.kind { |
| + case .sharing: |
| + Label(notice.text, systemImage: "rectangle.on.rectangle") |
| + .foregroundStyle(.orange).bold() |
| + case .reconnecting, .waitingToJoin: |
| + ProgressView().controlSize(.small) |
| + Text(notice.text) |
| + case .full: |
| + Label(notice.text, systemImage: "person.3.fill") |
| + Button("Try joining again") { call.retryJoin() } |
| + case .ended: |
| + Label(notice.text, systemImage: "phone.down") |
| + if let rejoin { |
| + Button("Join again", action: rejoin).buttonStyle(.borderedProminent) |
| + .accessibilityIdentifier("joinAgain") |
| + } |
| + case .problem, .floor: |
| + Label(notice.text, systemImage: "exclamationmark.triangle.fill") |
| + } |
| + if notice.dismissible { |
| + Button("Dismiss", systemImage: "xmark") { dismissedNotices.insert(notice.id) } |
| + .labelStyle(.iconOnly).buttonStyle(.borderless) |
| + .help("Dismiss") |
| + } |
| + } |
| + .modifier(NoticeSurface()) |
| + .accessibilityElement(children: .contain) |
| + } |
| + |
| + // MARK: Panels |
| + |
| + @ViewBuilder private func panels(in size: CGSize) -> some View { |
| + let top = Self.titleBarHeight + 8 |
| + // Below the title, which shows while a panel is open. |
| + let devicesTop = Self.titleBarHeight + 40 |
| + let bottom = Self.barReserve + 8 |
| + if showingDevices { |
| + VStack(spacing: 0) { |
| + HStack { |
| + Text("Devices").font(.headline) |
| + Spacer() |
| + Button("Hide devices", systemImage: "xmark") { |
| + withAnimation(panelAnimation) { showingDevices = false } |
| + } |
| + .labelStyle(.iconOnly).buttonStyle(.borderless).help("Hide devices") |
| + }.padding(.horizontal, 16).padding(.top, 14) |
| + // Its own height when that fits, scrolling when it does not. |
| + ScrollView { |
| + DevicePanel(call: call) |
| + .background( |
| + GeometryReader { panel in |
| + Color.clear |
| + .onAppear { devicesHeight = panel.size.height } |
| + .onChange(of: panel.size.height) { _, now in devicesHeight = now } |
| + }) |
| + } |
| + .frame(height: max(80, min(devicesHeight, size.height - devicesTop - bottom - 44))) |
| + } |
| + .frame(width: Self.panelWidth) |
| + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) |
| + .shadow(color: .black.opacity(0.25), radius: 12, y: 4) |
| + .padding(.leading, 16).padding(.bottom, bottom) |
| + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) |
| + .transition(panelTransition(edge: .bottom)) |
| + } |
| + 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") { |
| + withAnimation(panelAnimation) { showingChat = false } |
| + } |
| + .help("Hide chat") |
| + }.labelStyle(.iconOnly).buttonStyle(.borderless) |
| + ChatView(call: call, draft: chatWindow.draft) |
| + } |
| + .padding(16).frame(width: Self.panelWidth) |
| + .frame(maxHeight: .infinity) |
| + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) |
| + .shadow(color: .black.opacity(0.25), radius: 12, y: 4) |
| + .padding(.top, top).padding(.trailing, 12).padding(.bottom, bottom) |
| + .frame(maxWidth: .infinity, alignment: .trailing) |
| + .transition(panelTransition(edge: .trailing)) |
| + } |
| + } |
| + |
| + /// Panels slide in from their edge; with Reduce Motion they only fade. |
| + private func panelTransition(edge: Edge) -> AnyTransition { |
| + reduceMotion ? .opacity : .move(edge: edge).combined(with: .opacity) |
| + } |
| + |
| + // MARK: Bar |
| + |
| + private var bar: some View { |
| + controls(fullWindow: true) |
| + .padding(10) |
| + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 20)) |
| + .overlay(RoundedRectangle(cornerRadius: 20).strokeBorder(.white.opacity(0.08))) |
| + .shadow(color: .black.opacity(0.25), radius: 12, y: 4) |
| + .frame(maxWidth: 760) |
| + .background( |
| + GeometryReader { bar in |
| + Color.clear |
| + .onAppear { barFrame = bar.frame(in: .named("call")) } |
| + .onChange(of: bar.frame(in: .named("call"))) { _, now in barFrame = now } |
| + } |
| + ) |
| + .padding(.horizontal, 16).padding(.bottom, 16) |
| + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) |
| + .opacity(chrome.visible ? 1 : 0) |
| + .allowsHitTesting(chrome.visible) |
| + // Hidden from sight but never from VoiceOver, which holds the bar |
| + // up anyway; hidden from the keyboard's Tab, which brings it back. |
| + .accessibilityHidden(!chrome.visible) |
| + } |
| + } |
| + |
| + extension PiPCorner { |
| + var moveTitle: String { |
| + switch self { |
| + 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" |
| + } |
| + } |
| + } |
| + |
| + /// A notice over the picture: a small material capsule. |
| + struct NoticeSurface: ViewModifier { |
| + func body(content: Content) -> some View { |
| + content.font(.callout) |
| + .padding(.horizontal, 14).padding(.vertical, 8) |
| + .background(.regularMaterial, in: Capsule()) |
| + .shadow(color: .black.opacity(0.2), radius: 8, y: 2) |
| + } |
| + } |
| + |
| + /// Takes the call window's title bar out of the picture's way while the |
| + /// full-window call is showing — transparent, no title, content under |
| + /// it — and puts it back after. The traffic lights stay. |
| + struct FullWindowTitleBar: NSViewRepresentable { |
| + func makeNSView(context: Context) -> TitleBarView { TitleBarView() } |
| + func updateNSView(_ view: TitleBarView, context: Context) {} |
| + |
| + final class TitleBarView: NSView { |
| + private weak var styled: NSWindow? |
| + private var saved: (transparent: Bool, title: NSWindow.TitleVisibility, full: Bool)? |
| + |
| + override func viewWillMove(toWindow next: NSWindow?) { |
| + super.viewWillMove(toWindow: next) |
| + restore() |
| + } |
| + override func viewDidMoveToWindow() { |
| + super.viewDidMoveToWindow() |
| + guard let window, !(window is NSPanel) else { return } |
| + saved = ( |
| + window.titlebarAppearsTransparent, window.titleVisibility, |
| + window.styleMask.contains(.fullSizeContentView) |
| + ) |
| + styled = window |
| + window.titlebarAppearsTransparent = true |
| + window.titleVisibility = .hidden |
| + window.styleMask.insert(.fullSizeContentView) |
| + } |
| + private func restore() { |
| + guard let window = styled, let saved else { return } |
| + window.titlebarAppearsTransparent = saved.transparent |
| + window.titleVisibility = saved.title |
| + if !saved.full { window.styleMask.remove(.fullSizeContentView) } |
| + styled = nil |
| + self.saved = nil |
| + } |
| + } |
| + } |
| + |
| + /// What the call window's pointer and keyboard are doing, for the |
| + /// controls' visibility: the pointer moving (with where, in the view's |
| + /// own top-left coordinates) and leaving, Tab pressed, a menu tracking |
| + /// anywhere in the app, a sheet on the window. Tracks the pointer even |
| + /// while the app is not active, as a video player does. |
| + struct CallWindowActivity: NSViewRepresentable { |
| + var moved: (CGPoint) -> Void |
| + var exited: () -> Void |
| + /// Tab pressed, or a click: the person is here. |
| + var activity: () -> Void |
| + var menuTracking: (Bool) -> Void |
| + var sheet: (Bool) -> Void |
| + |
| + func makeNSView(context: Context) -> ActivityView { ActivityView() } |
| + func updateNSView(_ view: ActivityView, context: Context) { view.handlers = self } |
| + static func dismantleNSView(_ view: ActivityView, coordinator: ()) { view.stop() } |
| + |
| + final class ActivityView: NSView { |
| + var handlers: CallWindowActivity? |
| + private var tracking: NSTrackingArea? |
| + private var monitor: Any? |
| + private var observers: [NSObjectProtocol] = [] |
| + |
| + override var isFlipped: Bool { true } |
| + |
| + override func updateTrackingAreas() { |
| + super.updateTrackingAreas() |
| + if let tracking { removeTrackingArea(tracking) } |
| + let area = NSTrackingArea( |
| + rect: .zero, |
| + options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], |
| + owner: self) |
| + addTrackingArea(area) |
| + tracking = area |
| + } |
| + |
| + override func mouseEntered(with event: NSEvent) { report(event) } |
| + override func mouseMoved(with event: NSEvent) { report(event) } |
| + override func mouseExited(with event: NSEvent) { handlers?.exited() } |
| + |
| + private func report(_ event: NSEvent) { |
| + handlers?.moved(convert(event.locationInWindow, from: nil)) |
| + } |
| + |
| + override func viewDidMoveToWindow() { |
| + super.viewDidMoveToWindow() |
| + stop() |
| + guard let window else { return } |
| + window.acceptsMouseMovedEvents = true |
| + // Tab is keyboard navigation starting: bring the controls to it. |
| + monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .leftMouseDown]) { |
| + [weak self] event in |
| + nonisolated(unsafe) let event = event |
| + MainActor.assumeIsolated { |
| + guard let self, event.window === self.window else { return } |
| + if event.type == .leftMouseDown || event.keyCode == 48 { self.handlers?.activity() } |
| + } |
| + return event |
| + } |
| + let center = NotificationCenter.default |
| + observers = [ |
| + center.addObserver(forName: NSMenu.didBeginTrackingNotification, object: nil, queue: .main) |
| + { [weak self] _ in MainActor.assumeIsolated { self?.handlers?.menuTracking(true) } }, |
| + center.addObserver(forName: NSMenu.didEndTrackingNotification, object: nil, queue: .main) |
| + { [weak self] _ in MainActor.assumeIsolated { self?.handlers?.menuTracking(false) } }, |
| + center.addObserver( |
| + forName: NSWindow.willBeginSheetNotification, object: window, queue: .main |
| + ) { [weak self] _ in MainActor.assumeIsolated { self?.handlers?.sheet(true) } }, |
| + center.addObserver( |
| + forName: NSWindow.didEndSheetNotification, object: window, queue: .main |
| + ) { [weak self] _ in MainActor.assumeIsolated { self?.handlers?.sheet(false) } }, |
| + ] |
| + } |
| + |
| + func stop() { |
| + if let monitor { NSEvent.removeMonitor(monitor) } |
| + monitor = nil |
| + observers.forEach(NotificationCenter.default.removeObserver) |
| + observers = [] |
| + } |
| + |
| + deinit { |
| + if let monitor { NSEvent.removeMonitor(monitor) } |
| + observers.forEach(NotificationCenter.default.removeObserver) |
| + } |
| + |
| + // A layer of bookkeeping, not of content: clicks go through it. |
| + override func hitTest(_ point: NSPoint) -> NSView? { nil } |
| + } |
| + } |
| +#endif |
diff --git a/native/apple/Sources/CallsKit/MacVideoTile.swift b/native/apple/Sources/CallsKit/MacVideoTile.swift| index 9ba4ab0..773ecf8 100644 |
| --- a/native/apple/Sources/CallsKit/MacVideoTile.swift |
| +++ b/native/apple/Sources/CallsKit/MacVideoTile.swift |
| @@ -17,10 +17,20 @@ |
| /// 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-window call's look: square to the window's edges (or as |
| + /// rounded as `cornerRadius` says), a smaller name and muted mark, and |
| + /// the name lifted by `labelLift` clear of the controls. |
| + var cornerRadius: CGFloat = 16 |
| + var compact = false |
| + var labelLift: CGFloat = 0 |
| + /// What shows behind a picture that is not there, or around one that |
| + /// does not fill the tile. A tile among others on the full-window |
| + /// stage is a shade lighter than the stage, so it reads as a tile. |
| + var backdrop = Color.black |
| var body: some View { |
| ZStack { |
| - Color.black |
| + backdrop |
| if let track, !cameraOff, placeholder == nil { |
| MacVideoView(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| } else { |
| @@ -35,12 +45,16 @@ |
| } |
| } |
| .overlay(alignment: .bottomLeading) { |
| - Text(label).font(.callout.bold()).foregroundStyle(.white) |
| - .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6) |
| - .background(.black.opacity(0.65), in: Capsule()).padding(10) |
| + if compact { |
| + TileNameLabel(label: label, muted: muted).padding(8).padding(.bottom, labelLift) |
| + } else { |
| + Text(label).font(.callout.bold()).foregroundStyle(.white) |
| + .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6) |
| + .background(.black.opacity(0.65), in: Capsule()).padding(10) |
| + } |
| } |
| - .overlay(alignment: .topLeading) { if muted { MutedBadge().padding(10) } } |
| - .clipShape(RoundedRectangle(cornerRadius: 16)) |
| + .overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(10) } } |
| + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) |
| .accessibilityElement(children: .ignore) |
| .accessibilityLabel(tileAccessibilityLabel(label, muted: muted)) |
| .accessibilityValue(cameraOff ? "Camera off" : track == nil || placeholder != nil ? waiting : "Video on") |
diff --git a/native/apple/Sources/CallsKit/MiniWindow.swift b/native/apple/Sources/CallsKit/MiniWindow.swift| index 2da30ef..841a696 100644 |
| --- a/native/apple/Sources/CallsKit/MiniWindow.swift |
| +++ b/native/apple/Sources/CallsKit/MiniWindow.swift |
| @@ -375,6 +375,8 @@ |
| ZStack { |
| if let call = content.call { |
| MiniCallStage(call: call, showingControls: showingControls, actions: actions) |
| + CallToast(call: call).padding(8) |
| + .frame(maxHeight: .infinity, alignment: .top) |
| } else { |
| Color.black |
| } |
diff --git a/native/apple/Sources/CallsKit/MutedBadge.swift b/native/apple/Sources/CallsKit/MutedBadge.swift| index a06440b..96ee2db 100644 |
| --- a/native/apple/Sources/CallsKit/MutedBadge.swift |
| +++ b/native/apple/Sources/CallsKit/MutedBadge.swift |
| @@ -21,3 +21,34 @@ public struct MutedBadge: View { |
| public func tileAccessibilityLabel(_ label: String, muted: Bool) -> String { |
| muted ? "\(label), muted" : label |
| } |
| + |
| +/// The full-window call's name label: small, and carrying the muted mark |
| +/// inside it — shape and word, as §15.19 has it — rather than in a |
| +/// second badge, so a tile keeps one quiet |
| +/// label in its corner that can stay while the controls come and go. |
| +/// Hidden from assistive technology, as the tile's own label says both. |
| +public struct TileNameLabel: View { |
| + let label: String |
| + let muted: Bool |
| + public init(label: String, muted: Bool) { |
| + self.label = label |
| + self.muted = muted |
| + } |
| + public var body: some View { |
| + HStack(spacing: 5) { |
| + Text(label).lineLimit(1) |
| + if muted { |
| + Label("Muted", systemImage: "mic.slash.fill").labelStyle(.titleAndIcon) |
| + .padding(.leading, 5) |
| + .overlay(alignment: .leading) { |
| + Rectangle().fill(.white.opacity(0.4)).frame(width: 1, height: 10) |
| + } |
| + } |
| + } |
| + .font(.caption.weight(.semibold)).foregroundStyle(.white) |
| + .fixedSize() |
| + .padding(.horizontal, 7).padding(.vertical, 3) |
| + .background(.black.opacity(0.55), in: Capsule()) |
| + .accessibilityHidden(true) |
| + } |
| +} |
diff --git a/native/apple/Sources/CallsKit/ServerSettings.swift b/native/apple/Sources/CallsKit/ServerSettings.swift| index b105cee..0de8899 100644 |
| --- a/native/apple/Sources/CallsKit/ServerSettings.swift |
| +++ b/native/apple/Sources/CallsKit/ServerSettings.swift |
| @@ -99,6 +99,7 @@ import SwiftUI |
| @MainActor public struct ServerSettingsSheet: View { |
| @ObservedObject private var model: CallsModel |
| private let done: () -> Void |
| + @AppStorage(CallLayoutStyle.key) private var callLayout = CallLayoutStyle.defaultStyle |
| public init(model: CallsModel, done: @escaping () -> Void) { |
| self.model = model |
| @@ -107,7 +108,25 @@ import SwiftUI |
| public var body: some View { |
| NavigationStack { |
| - Form { ServerSettingsSection(model: model) } |
| + Form { |
| + Section { |
| + Picker("Call layout", selection: $callLayout) { |
| + ForEach(CallLayoutStyle.allCases) { style in |
| + Text(style.title).tag(style) |
| + } |
| + } |
| + .accessibilityIdentifier("callLayout") |
| + } header: { |
| + Text("During a call") |
| + } footer: { |
| + Text( |
| + callLayout == .fullWindow |
| + ? "The call fills the screen. Tap it to show the controls." |
| + : "The call sits above a row of controls that are always showing." |
| + ) |
| + } |
| + ServerSettingsSection(model: model) |
| + } |
| .formStyle(.grouped) |
| .navigationTitle("Settings") |
| #if os(iOS) |
diff --git a/native/apple/Sources/CallsMac/CallsMac.swift b/native/apple/Sources/CallsMac/CallsMac.swift| index 8360c11..3dd13b9 100644 |
| --- a/native/apple/Sources/CallsMac/CallsMac.swift |
| +++ b/native/apple/Sources/CallsMac/CallsMac.swift |
| @@ -98,19 +98,34 @@ final class CallsAppDelegate: NSObject, NSApplicationDelegate { |
| } |
| /// The Call menu. Mute is here so that its shortcut can be found, and so |
| -/// that there is one when Space belongs to a text field or a button. |
| +/// that there is one when Space belongs to a text field or a button. Copy |
| +/// Invite Link puts the meeting's link on the clipboard without the share |
| +/// sheet. Both work from the mini window too: the menu is the app's. |
| struct CallMenuItems: View { |
| @ObservedObject var model: CallsModel |
| - var body: some View { MuteMenuItem(call: model.call, inCall: model.joining) } |
| + var body: some View { CallMenuButtons(call: model.call, inCall: model.joining) } |
| } |
| -private struct MuteMenuItem: View { |
| +private struct CallMenuButtons: View { |
| @ObservedObject var call: Call |
| let inCall: Bool |
| var body: some View { |
| - Button(call.muted ? "Unmute Microphone" : "Mute Microphone") { call.mute() } |
| - .keyboardShortcut("m", modifiers: [.command, .shift]) |
| - .disabled(!inCall) |
| + ForEach(CallMenuItem.allCases, id: \.self) { item in |
| + switch item { |
| + case .mute: |
| + Button(item.title(muted: call.muted)) { call.mute() } |
| + .keyboardShortcut(item.shortcut) |
| + .disabled(!inCall) |
| + case .copyInviteLink: |
| + Button(item.title(muted: call.muted)) { |
| + Task { await call.copyInviteLink(inCall: inCall) } |
| + } |
| + .keyboardShortcut(item.shortcut) |
| + .disabled( |
| + InviteLinkCopy.rule(inCall: inCall, phase: call.state?.phase, standing: call.standing) |
| + == .unavailable) |
| + } |
| + } |
| } |
| } |
| @@ -120,7 +135,9 @@ struct ContentView: View { |
| var body: some View { |
| Group { |
| if model.joining { |
| - MacCallView(call: model.call, leave: model.leave, rejoin: model.join).padding(24) |
| + // The layout pads itself: the classic view sits in a margin, the |
| + // full-window one goes to the window's edges. |
| + MacCallView(call: model.call, leave: model.leave, rejoin: model.join) |
| } else { |
| // The home screen is the only place an update is mentioned: never the call. |
| CallsLobby(model: model).safeAreaInset(edge: .bottom) { |
diff --git a/native/apple/Tests/CallsKitTests/CallChromeTests.swift b/native/apple/Tests/CallsKitTests/CallChromeTests.swift| new file mode 100644 |
| index 0000000..84bdf06 |
| --- /dev/null |
| +++ b/native/apple/Tests/CallsKitTests/CallChromeTests.swift |
| @@ -0,0 +1,432 @@ |
| +import SwiftUI |
| +import XCTest |
| + |
| +@testable @_spi(Fixtures) import CallsKit |
| + |
| +/// The full-window call's rules: when its controls show and hide, where |
| +/// the self view settles, which notices outlive the controls, and how a |
| +/// grid fits. |
| +final class ControlsVisibilityTests: XCTestCase { |
| + typealias V = ControlsVisibility |
| + |
| + func testStartsVisibleAndHidesAfterTheDelay() { |
| + var pointer = V(input: .pointer, now: 0) |
| + XCTAssertTrue(pointer.visible) |
| + XCTAssertEqual(pointer.hideAt, 1.5) |
| + pointer.tick(now: 1.4) |
| + XCTAssertTrue(pointer.visible, "not before the delay") |
| + pointer.tick(now: 1.5) |
| + XCTAssertFalse(pointer.visible) |
| + XCTAssertNil(pointer.hideAt) |
| + |
| + var touch = V(input: .touch, now: 10) |
| + XCTAssertEqual(touch.hideAt, 12.5, "touch waits longer") |
| + touch.tick(now: 12.4) |
| + XCTAssertTrue(touch.visible) |
| + touch.tick(now: 12.5) |
| + XCTAssertFalse(touch.visible) |
| + } |
| + |
| + func testTheDelaysAreOneAndAHalfAndTwoAndAHalfSeconds() { |
| + XCTAssertEqual(V.pointerDelay, 1.5) |
| + XCTAssertEqual(V.touchDelay, 2.5) |
| + } |
| + |
| + func testPointerMovementShowsAndRestartsTheWait() { |
| + var state = V(input: .pointer, now: 0) |
| + state.tick(now: 2) |
| + XCTAssertFalse(state.visible) |
| + state.pointerMoved(now: 3) |
| + XCTAssertTrue(state.visible) |
| + XCTAssertEqual(state.hideAt, 4.5) |
| + state.pointerMoved(now: 4) |
| + state.tick(now: 4.5) |
| + XCTAssertTrue(state.visible, "movement at 4 pushed the hide to 5.5") |
| + state.tick(now: 5.5) |
| + XCTAssertFalse(state.visible) |
| + } |
| + |
| + func testLeavingTheWindowHidesAtOnce() { |
| + var state = V(input: .pointer, now: 0) |
| + state.pointerMoved(now: 0.2) |
| + state.pointerExited(now: 0.3) |
| + XCTAssertFalse(state.visible, "no waiting out the delay") |
| + XCTAssertFalse(state.pointerInside) |
| + XCTAssertFalse(state.hidesPointer, "no pointer to hide outside the window") |
| + } |
| + |
| + func testLeavingTheWindowDoesNotHideHeldControls() { |
| + var state = V(input: .pointer, now: 0) |
| + state.set(.menuOpen, true, now: 0.1) |
| + state.pointerExited(now: 0.2) |
| + XCTAssertTrue(state.visible, "a menu is open") |
| + state.set(.menuOpen, false, now: 3) |
| + XCTAssertTrue(state.visible, "letting go starts the wait rather than hiding at once") |
| + XCTAssertEqual(state.hideAt, 4.5) |
| + state.tick(now: 4.5) |
| + XCTAssertFalse(state.visible) |
| + } |
| + |
| + func testLeavingTheWindowIsLeavingTheBar() { |
| + var state = V(input: .pointer, now: 0) |
| + state.set(.pointerOverControls, true, now: 0.1) |
| + state.pointerExited(now: 0.2) |
| + XCTAssertFalse(state.holds.contains(.pointerOverControls)) |
| + XCTAssertFalse(state.visible) |
| + } |
| + |
| + /// Every rule that keeps the controls up does so for as long as it |
| + /// lasts, however long that is, and each lets go on its own. |
| + func testEveryHoldKeepsTheControlsUp() { |
| + let holds: [V.Holds] = [.pointerOverControls, .menuOpen, .keyboardFocus, .voiceOver, .alert] |
| + for input in [V.Input.pointer, .touch] { |
| + for hold in holds { |
| + var state = V(input: input, now: 0) |
| + state.set(hold, true, now: 0.5) |
| + XCTAssertNil(state.hideAt, "\(hold) \(input)") |
| + state.tick(now: 1000) |
| + XCTAssertTrue(state.visible, "\(hold) \(input) holds") |
| + state.set(hold, false, now: 1000) |
| + state.tick(now: 1000 + state.delay) |
| + XCTAssertFalse(state.visible, "\(hold) \(input) lets go") |
| + } |
| + } |
| + } |
| + |
| + func testAHoldShowsHiddenControls() { |
| + var state = V(input: .pointer, now: 0) |
| + state.tick(now: 5) |
| + XCTAssertFalse(state.visible) |
| + state.set(.voiceOver, true, now: 6) |
| + XCTAssertTrue(state.visible, "VoiceOver starting brings the controls back") |
| + state = V(input: .pointer, now: 0) |
| + state.tick(now: 5) |
| + state.set(.keyboardFocus, true, now: 6) |
| + XCTAssertTrue(state.visible, "focus moving into the bar shows it") |
| + } |
| + |
| + func testOneHoldEndingLeavesTheOthers() { |
| + var state = V(input: .pointer, now: 0) |
| + state.set(.menuOpen, true, now: 0) |
| + state.set(.alert, true, now: 0) |
| + state.set(.menuOpen, false, now: 1) |
| + state.tick(now: 100) |
| + XCTAssertTrue(state.visible, "the alert still holds") |
| + state.set(.alert, false, now: 100) |
| + state.tick(now: 101.5) |
| + XCTAssertFalse(state.visible) |
| + } |
| + |
| + func testReleasingAHoldThatWasNotHeldChangesNothing() { |
| + var state = V(input: .pointer, now: 0) |
| + state.tick(now: 2) |
| + state.set(.menuOpen, false, now: 3) |
| + XCTAssertFalse(state.visible) |
| + XCTAssertNil(state.hideAt) |
| + } |
| + |
| + func testVoiceOverNeverLetsATapHideTheControls() { |
| + var state = V(input: .touch, now: 0) |
| + state.set(.voiceOver, true, now: 0) |
| + state.tapped(now: 1) |
| + XCTAssertTrue(state.visible) |
| + state.tick(now: 1000) |
| + XCTAssertTrue(state.visible) |
| + var pointer = V(input: .pointer, now: 0) |
| + pointer.set(.voiceOver, true, now: 0) |
| + pointer.pointerExited(now: 1) |
| + XCTAssertTrue(pointer.visible, "nor the pointer leaving") |
| + } |
| + |
| + func testATapToggles() { |
| + var state = V(input: .touch, now: 0) |
| + state.tapped(now: 0.5) |
| + XCTAssertFalse(state.visible, "a tap on showing controls hides them") |
| + state.tapped(now: 1) |
| + XCTAssertTrue(state.visible) |
| + XCTAssertEqual(state.hideAt, 3.5) |
| + state.tick(now: 3.5) |
| + XCTAssertFalse(state.visible) |
| + } |
| + |
| + func testATapOnHeldControlsKeepsThem() { |
| + var state = V(input: .touch, now: 0) |
| + state.set(.menuOpen, true, now: 0) |
| + state.tapped(now: 1) |
| + XCTAssertTrue(state.visible) |
| + XCTAssertNil(state.hideAt) |
| + } |
| + |
| + func testActivityShowsAndRestarts() { |
| + var state = V(input: .pointer, now: 0) |
| + state.tick(now: 2) |
| + state.activity(now: 3) // Tab, say |
| + XCTAssertTrue(state.visible) |
| + XCTAssertEqual(state.hideAt, 4.5) |
| + } |
| + |
| + func testThePointerHidesWithTheControlsOnlyOverTheCall() { |
| + var state = V(input: .pointer, now: 0) |
| + state.pointerMoved(now: 0) |
| + XCTAssertFalse(state.hidesPointer, "controls showing") |
| + state.tick(now: 1.5) |
| + XCTAssertTrue(state.hidesPointer) |
| + state.pointerMoved(now: 2) |
| + XCTAssertFalse(state.hidesPointer, "movement brings it back") |
| + var touch = V(input: .touch, now: 0) |
| + touch.tick(now: 5) |
| + XCTAssertFalse(touch.hidesPointer) |
| + } |
| + |
| + func testReduceMotionIsAShortCrossFade() { |
| + XCTAssertLessThanOrEqual(V.fadeDuration(showing: true, reduceMotion: true), 0.1) |
| + XCTAssertLessThanOrEqual(V.fadeDuration(showing: false, reduceMotion: true), 0.1) |
| + XCTAssertGreaterThan(V.fadeDuration(showing: false, reduceMotion: false), 0.1) |
| + XCTAssertLessThan( |
| + V.fadeDuration(showing: true, reduceMotion: false), |
| + V.fadeDuration(showing: false, reduceMotion: false), "they arrive faster than they go") |
| + } |
| + |
| + @MainActor func testTheModelHidesOnItsOwnClock() async throws { |
| + let model = ControlsVisibilityModel(input: .pointer) |
| + XCTAssertTrue(model.visible) |
| + try await Task.sleep(for: .seconds(1.8)) |
| + XCTAssertFalse(model.visible, "hidden by its own timer") |
| + model.pointerMoved() |
| + XCTAssertTrue(model.visible) |
| + model.set(.menuOpen, true) |
| + try await Task.sleep(for: .seconds(1.8)) |
| + XCTAssertTrue(model.visible, "held") |
| + } |
| + |
| + @MainActor func testAPinnedModelIgnoresInput() { |
| + let hidden = ControlsVisibilityModel(input: .touch, pinned: false) |
| + hidden.tapped() |
| + XCTAssertFalse(hidden.visible) |
| + let shown = ControlsVisibilityModel(input: .pointer, pinned: true) |
| + shown.pointerExited() |
| + XCTAssertTrue(shown.visible) |
| + } |
| +} |
| + |
| +final class PiPLayoutTests: XCTestCase { |
| + let stage = CGSize(width: 1000, height: 600) |
| + |
| + func testEachCornerSitsTheMarginIn() { |
| + let size = CGSize(width: 200, height: 112) |
| + let m = PiPLayout.margin |
| + XCTAssertEqual( |
| + PiPLayout.frame(corner: .topLeading, size: size, in: stage).origin, CGPoint(x: m, y: m)) |
| + XCTAssertEqual( |
| + PiPLayout.frame(corner: .topTrailing, size: size, in: stage).origin, |
| + CGPoint(x: 1000 - m - 200, y: m)) |
| + XCTAssertEqual( |
| + PiPLayout.frame(corner: .bottomLeading, size: size, in: stage).origin, |
| + CGPoint(x: m, y: 600 - m - 112)) |
| + XCTAssertEqual( |
| + PiPLayout.frame(corner: .bottomTrailing, size: size, in: stage).origin, |
| + CGPoint(x: 1000 - m - 200, y: 600 - m - 112)) |
| + } |
| + |
| + func testInsetsKeepItClearOfTheBarAndSidebar() { |
| + let size = CGSize(width: 200, height: 112) |
| + let insets = EdgeInsets(top: 32, leading: 0, bottom: 80, trailing: 332) |
| + let frame = PiPLayout.frame(corner: .bottomTrailing, size: size, in: stage, insets: insets) |
| + XCTAssertEqual(frame.maxY, 600 - 80 - PiPLayout.margin, "above the bar") |
| + XCTAssertEqual(frame.maxX, 1000 - 332 - PiPLayout.margin, "beside the chat") |
| + let top = PiPLayout.frame(corner: .topLeading, size: size, in: stage, insets: insets) |
| + XCTAssertEqual(top.minY, 32 + PiPLayout.margin, "under the title bar") |
| + } |
| + |
| + func testATinyStageStillHoldsIt() { |
| + let frame = PiPLayout.frame( |
| + corner: .bottomTrailing, size: CGSize(width: 200, height: 112), |
| + in: CGSize(width: 150, height: 100), insets: EdgeInsets(top: 0, leading: 0, bottom: 300, trailing: 300)) |
| + XCTAssertGreaterThanOrEqual(frame.minX, 0) |
| + XCTAssertGreaterThanOrEqual(frame.minY, 0) |
| + } |
| + |
| + func testADropSettlesInTheQuarterItWasHeadedFor() { |
| + XCTAssertEqual(PiPLayout.corner(droppedAt: CGPoint(x: 100, y: 100), in: stage), .topLeading) |
| + XCTAssertEqual(PiPLayout.corner(droppedAt: CGPoint(x: 900, y: 100), in: stage), .topTrailing) |
| + XCTAssertEqual(PiPLayout.corner(droppedAt: CGPoint(x: 100, y: 500), in: stage), .bottomLeading) |
| + XCTAssertEqual(PiPLayout.corner(droppedAt: CGPoint(x: 900, y: 500), in: stage), .bottomTrailing) |
| + } |
| + |
| + func testAFlickCarriesItToTheCornerItWasThrownAt() { |
| + // Let go just left of centre, moving fast to the right and up. |
| + let corner = PiPLayout.corner( |
| + droppedAt: CGPoint(x: 480, y: 320), predicted: CGPoint(x: 1300, y: -80), in: stage) |
| + XCTAssertEqual(corner, .topTrailing) |
| + } |
| + |
| + func testSizes() { |
| + let mac = PiPLayout.size(in: stage, touch: false) |
| + XCTAssertEqual(mac.width, 200) |
| + XCTAssertEqual(mac.height, 113) |
| + XCTAssertEqual(PiPLayout.size(in: CGSize(width: 3000, height: 2000), touch: false).width, 260) |
| + XCTAssertEqual(PiPLayout.size(in: CGSize(width: 600, height: 400), touch: false).width, 150) |
| + let portrait = PiPLayout.size(in: CGSize(width: 390, height: 844), touch: true) |
| + XCTAssertGreaterThan(portrait.height, portrait.width, "upright on an upright phone") |
| + let landscape = PiPLayout.size(in: CGSize(width: 844, height: 390), touch: true) |
| + XCTAssertGreaterThan(landscape.width, landscape.height) |
| + } |
| +} |
| + |
| +final class CallNoticesTests: XCTestCase { |
| + func testAnOrdinaryCallHasNothingToSay() { |
| + XCTAssertEqual( |
| + CallNotices.notices( |
| + phase: "admitted", status: "In the meeting", sharingScreen: false, screenNote: "", |
| + floor: false), []) |
| + } |
| + |
| + func testWhatStaysAndWhatFades() { |
| + let notices = CallNotices.notices( |
| + phase: "reconnecting", status: "Reconnecting…", sharingScreen: true, screenNote: "", |
| + floor: true) |
| + XCTAssertEqual(notices.map(\.kind), [.reconnecting, .sharing, .floor]) |
| + XCTAssertEqual(notices.map(\.important), [true, true, false], "the relay line fades") |
| + } |
| + |
| + func testEndedFullAndWaitingAreImportant() { |
| + for (phase, kind) in [ |
| + ("ended", CallNotice.Kind.ended), ("full", .full), ("waiting", .waitingToJoin), |
| + ] { |
| + let notice = CallNotices.notices( |
| + phase: phase, status: "x", sharingScreen: false, screenNote: "", floor: false |
| + ).first |
| + XCTAssertEqual(notice?.kind, kind) |
| + XCTAssertEqual(notice?.important, true) |
| + } |
| + } |
| + |
| + func testProblemsAreImportantAndDismissible() { |
| + let notices = CallNotices.notices( |
| + phase: "admitted", status: "Could not send to the meeting. Try again.", |
| + sharingScreen: false, screenNote: "Screen sharing was not allowed.", floor: false) |
| + XCTAssertEqual(notices.map(\.kind), [.problem, .problem]) |
| + XCTAssertTrue(notices.allSatisfy { $0.important && $0.dismissible }) |
| + XCTAssertFalse( |
| + CallNotice(kind: .reconnecting, text: "").dismissible, "reconnecting ends on its own") |
| + } |
| +} |
| + |
| +final class GridFitTests: XCTestCase { |
| + func testOneTileFillsTheStage() { |
| + XCTAssertEqual(GridFit.columns(count: 1, in: CGSize(width: 1600, height: 900), aspect: 16 / 9), 1) |
| + } |
| + |
| + func testTwoSideBySideOnAWideStageAndStackedOnATallOne() { |
| + XCTAssertEqual(GridFit.columns(count: 2, in: CGSize(width: 1600, height: 600), aspect: 16 / 9), 2) |
| + XCTAssertEqual(GridFit.columns(count: 2, in: CGSize(width: 390, height: 844), aspect: 3 / 4), 1) |
| + } |
| + |
| + func testFourMakeTwoByTwo() { |
| + XCTAssertEqual(GridFit.columns(count: 4, in: CGSize(width: 1600, height: 900), aspect: 16 / 9), 2) |
| + let tile = GridFit.tile( |
| + count: 4, columns: 2, in: CGSize(width: 1604, height: 904), aspect: 16 / 9, spacing: 4) |
| + XCTAssertEqual(tile.width, 800, accuracy: 0.01) |
| + } |
| +} |
| + |
| +final class CameraFlipTests: XCTestCase { |
| + let front = CameraDevice(id: "front", name: "Front Camera", builtIn: true) |
| + let back = CameraDevice(id: "back", name: "Back Camera") |
| + let wide = CameraDevice(id: "wide", name: "Back Ultra Wide Camera") |
| + |
| + func testFrontAndBackSwap() { |
| + XCTAssertEqual(CameraFlip.next(from: "front", in: [front, back, wide]), back) |
| + XCTAssertEqual(CameraFlip.next(from: "wide", in: [front, back, wide]), front) |
| + } |
| + |
| + func testNothingToFlipTo() { |
| + XCTAssertNil(CameraFlip.next(from: "front", in: [front])) |
| + XCTAssertNil(CameraFlip.next(from: nil, in: [])) |
| + } |
| +} |
| + |
| +final class InviteLinkCopyTests: XCTestCase { |
| + func testOnlyInACall() { |
| + XCTAssertEqual( |
| + InviteLinkCopy.rule(inCall: false, phase: nil, standing: "member"), .unavailable) |
| + XCTAssertEqual( |
| + InviteLinkCopy.rule(inCall: true, phase: "ended", standing: "member"), .unavailable, |
| + "an ended meeting has no link to share") |
| + } |
| + |
| + func testAMemberCopies() { |
| + for phase in ["admitted", "waiting", "reconnecting", nil] { |
| + XCTAssertEqual(InviteLinkCopy.rule(inCall: true, phase: phase, standing: "member"), .copy) |
| + } |
| + } |
| + |
| + func testAGuestIsToldWhyNot() { |
| + guard case .explain(let why) = InviteLinkCopy.rule(inCall: true, phase: "admitted", standing: "guest") |
| + else { return XCTFail("a guest is told, not beeped at") } |
| + XCTAssertTrue(why.contains("Only members")) |
| + guard case .explain = InviteLinkCopy.rule(inCall: true, phase: nil, standing: "") else { |
| + return XCTFail("before the meeting says who you are") |
| + } |
| + } |
| + |
| + func testTheCallMenuHasCopyInviteLinkBesideMute() { |
| + XCTAssertEqual(CallMenuItem.allCases, [.mute, .copyInviteLink]) |
| + XCTAssertEqual(CallMenuItem.copyInviteLink.title(muted: false), "Copy Invite Link") |
| + XCTAssertEqual(CallMenuItem.copyInviteLink.shortcut.key, "i") |
| + XCTAssertEqual(CallMenuItem.copyInviteLink.shortcut.modifiers, [.command, .shift]) |
| + XCTAssertEqual(CallMenuItem.mute.shortcut.key, "m") |
| + XCTAssertEqual(CallMenuItem.mute.shortcut.modifiers, [.command, .shift]) |
| + } |
| + |
| + @MainActor func testAGuestPressGivesAToastAndNoClipboard() async { |
| + let call = Call(playAudio: false) |
| + call.showFixture( |
| + title: "x", standing: "guest", |
| + state: #"{"phase":"admitted","peer":"me","boot":"b","peers":[],"waiting":[]}"#) |
| + await call.copyInviteLink() |
| + XCTAssertEqual(call.toast, "Only members of this meeting can share its invite link.") |
| + } |
| +} |
| + |
| +final class AudioWarningTests: XCTestCase { |
| + func report( |
| + _ suppression: SuppressionState = .running(engine: "DeepFilterNet", cpu: 0.1), |
| + echo: String = "WebRTC", refused: String? = nil, started: Bool = true |
| + ) -> ProcessingReport { |
| + ProcessingReport( |
| + suppression: suppression, echoCancellation: echo, noiseSuppression: "off", |
| + gainControl: "Calls, after noise reduction", refused: refused, started: started) |
| + } |
| + let clean = AudioHealth(concealed: 10, total: 10_000, inserted: 5, removed: 5, jitterBufferMs: 60) |
| + |
| + func testAHealthyCallSaysNothing() { |
| + XCTAssertNil(AudioWarning.line(health: clean, processing: report(), reduceNoise: true)) |
| + XCTAssertNil(AudioWarning.line(health: nil, processing: nil, reduceNoise: true)) |
| + } |
| + |
| + func testGrowlAndDropouts() { |
| + let growl = AudioHealth(concealed: 0, total: 10_000, inserted: 400, removed: 0, jitterBufferMs: 60) |
| + XCTAssertTrue( |
| + AudioWarning.line(health: growl, processing: report(), reduceNoise: true)!.contains("growling")) |
| + let lossy = AudioHealth(concealed: 900, total: 10_000, inserted: 0, removed: 0, jitterBufferMs: 60) |
| + XCTAssertEqual( |
| + AudioWarning.line(health: lossy, processing: report(), reduceNoise: true), |
| + "9% of incoming audio is going missing.") |
| + } |
| + |
| + func testProcessingProblems() { |
| + XCTAssertNotNil(AudioWarning.line(health: clean, processing: report(echo: "off"), reduceNoise: true)) |
| + XCTAssertNotNil( |
| + AudioWarning.line(health: clean, processing: report(refused: "aec"), reduceNoise: true)) |
| + XCTAssertEqual( |
| + AudioWarning.line(health: clean, processing: report(.unsupportedRate(16_000)), reduceNoise: true), |
| + "Noise reduction is bypassed: the microphone runs at 16 kHz.") |
| + XCTAssertNil( |
| + AudioWarning.line(health: clean, processing: report(.standingAside), reduceNoise: true), |
| + "Voice Isolation standing in is a choice, not a fault") |
| + XCTAssertNil( |
| + AudioWarning.line(health: clean, processing: report(echo: "off", started: false), reduceNoise: true), |
| + "before the microphone starts, the defaults mean nothing") |
| + } |
| +} |
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift| index 11651d7..0a8521c 100644 |
| --- a/native/ios/Sources/CallsPhone.swift |
| +++ b/native/ios/Sources/CallsPhone.swift |
| @@ -28,33 +28,45 @@ final class CallsPhoneDelegate: NSObject, UIApplicationDelegate { |
| @Environment(\.scenePhase) private var scenePhase |
| var body: some Scene { |
| WindowGroup { |
| - 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 |
| + #if DEBUG |
| + 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 { |
| @@ -90,20 +102,105 @@ struct PhoneHome: View { |
| struct PhoneCall: View { |
| @ObservedObject var call: Call |
| let leave: () -> Void |
| - @State private var showingChat = false |
| - @State private var showingDevices = false |
| - @State private var readChatCount = 0 |
| - @State private var grid = false |
| - @State private var chosen: StageItem? |
| - @StateObject private var draft = ChatDraft() |
| - @StateObject private var screenShare = PhoneScreenShare() |
| + @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 |
| + @State var grid = false |
| + @State var chosen: StageItem? |
| + @State var dismissedNotices: Set<String> = [] |
| + @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 |
| + private let style: CallLayoutStyle? |
| - private var peers: [RosterPeer] { call.state?.peers.filter { $0.peer != call.state?.peer } ?? [] } |
| - private var focused: StageItem? { |
| + init( |
| + call: Call, leave: @escaping () -> Void, style: CallLayoutStyle? = nil, |
| + controlsShown: Bool? = nil, showingMore: Bool = false, grid: Bool = false |
| + ) { |
| + self.call = call |
| + self.leave = leave |
| + self.style = style |
| + _showingMore = State(initialValue: showingMore) |
| + _grid = State(initialValue: grid) |
| + _chrome = StateObject(wrappedValue: ControlsVisibilityModel(input: .touch, pinned: controlsShown)) |
| + } |
| + |
| + var layout: CallLayoutStyle { style ?? CallLayoutStyle(rawValue: layoutChoice) ?? .defaultStyle } |
| + |
| + var peers: [RosterPeer] { call.state?.peers.filter { $0.peer != call.state?.peer } ?? [] } |
| + var focused: StageItem? { |
| Stage.focus(chosen: chosen, peers: peers.map(\.peer), screens: call.screens) |
| } |
| 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() |
| + } |
| + .onChange(of: call.screens) { before, after in |
| + // A new screen takes the stage, as it does on the Mac. |
| + if after.contains(where: { !before.contains($0) }) { chosen = nil } |
| + } |
| + .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 } |
| + } |
| + } |
| + |
| + /// 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) { |
| @@ -117,7 +214,7 @@ struct PhoneCall: View { |
| Spacer() |
| Menu { |
| Picker("Video layout", selection: $grid) { |
| - Label("Focus", systemImage: "rectangle.inset.bottomtrailing").tag(false) |
| + Label("Focus", systemImage: "pip").tag(false) |
| Label("Grid", systemImage: "square.grid.2x2").tag(true) |
| } |
| if peers.count > 1 || !call.screens.isEmpty { |
| @@ -138,7 +235,7 @@ struct PhoneCall: View { |
| } |
| } label: { |
| Label( |
| - "Video layout", systemImage: grid ? "square.grid.2x2" : "rectangle.inset.bottomtrailing" |
| + "Video layout", systemImage: grid ? "square.grid.2x2" : "pip" |
| ) |
| .labelStyle(.iconOnly).frame(width: 44, height: 44) |
| }.accessibilityIdentifier("videoLayout") |
| @@ -264,44 +361,12 @@ struct PhoneCall: View { |
| } |
| Button("Leave meeting", systemImage: "phone.down.fill", role: .destructive, action: leave) |
| .buttonStyle(CallActionStyle(color: .red, emphasized: true)) |
| + .accessibilityIdentifier("leave") |
| }.padding(16).background(.bar) |
| } |
| - .task { await call.watchAudio() } |
| - .onAppear { screenShare.listen(for: call, leave: leave) } |
| - .onDisappear { |
| - screenShare.stop() |
| - call.stopSharingScreen() |
| - } |
| - .onChange(of: call.screens) { before, after in |
| - // A new screen takes the stage, as it does on the Mac. |
| - if after.contains(where: { !before.contains($0) }) { chosen = nil } |
| - } |
| - .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 } |
| - } |
| } |
| - private var unread: Int { max(0, call.receivedChatCount - readChatCount) } |
| - private var chatButtonTitle: String { unread == 0 ? "Chat" : "Chat (\(unread) new)" } |
| + var unread: Int { max(0, call.receivedChatCount - readChatCount) } |
| + var chatButtonTitle: String { unread == 0 ? "Chat" : "Chat (\(unread) new)" } |
| } |
| struct PhoneVideoTile: View { |
| @@ -318,9 +383,19 @@ struct PhoneVideoTile: View { |
| /// 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 |
| var body: some View { |
| ZStack { |
| - Color.black |
| + backdrop |
| if let track, !cameraOff, placeholder == nil { |
| PhoneVideo(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) |
| } else { |
| @@ -337,12 +412,16 @@ struct PhoneVideoTile: View { |
| } |
| } |
| .overlay(alignment: .bottomLeading) { |
| - Text(label).font(.callout.bold()).foregroundStyle(.white).lineLimit(1) |
| - .padding(.horizontal, 10).padding(.vertical, 6) |
| - .background(.black.opacity(0.65), in: Capsule()).padding(8) |
| + if compact { |
| + TileNameLabel(label: label, muted: muted).padding(6).padding(labelInsets) |
| + } else { |
| + Text(label).font(.callout.bold()).foregroundStyle(.white).lineLimit(1) |
| + .padding(.horizontal, 10).padding(.vertical, 6) |
| + .background(.black.opacity(0.65), in: Capsule()).padding(8) |
| + } |
| } |
| - .overlay(alignment: .topLeading) { if muted { MutedBadge().padding(8) } } |
| - .clipShape(RoundedRectangle(cornerRadius: 18)) |
| + .overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(8) } } |
| + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) |
| .accessibilityElement(children: .ignore) |
| .accessibilityLabel(tileAccessibilityLabel(label, muted: muted)) |
| .accessibilityValue( |
diff --git a/native/ios/Sources/PhoneCallFixture.swift b/native/ios/Sources/PhoneCallFixture.swift| new file mode 100644 |
| index 0000000..1bcffdc |
| --- /dev/null |
| +++ b/native/ios/Sources/PhoneCallFixture.swift |
| @@ -0,0 +1,39 @@ |
| +#if DEBUG |
| + @_spi(Fixtures) import CallsKit |
| + import SwiftUI |
| + |
| + /// The call screen with no server behind it, for the UI test of its |
| + /// controls and the snapshots taken there. Debug builds only; launched |
| + /// with `-CallsCallFixture focus` (or grid, screen, notices), and |
| + /// optionally `-CallsFixtureControls shown|hidden` to pin the controls, |
| + /// `-CallsFixtureMore YES` to open the controls menu, `-CallsFixtureDark |
| + /// YES` and `-CallsFixtureClassic YES`. |
| + struct PhoneCallFixture: View { |
| + static var requested: CallFixture? { |
| + UserDefaults.standard.string(forKey: "CallsCallFixture").flatMap(CallFixture.init(rawValue:)) |
| + } |
| + |
| + let fixture: CallFixture |
| + @StateObject private var call = Call(playAudio: false) |
| + @State private var applied = false |
| + |
| + var body: some View { |
| + let defaults = UserDefaults.standard |
| + let pinned = defaults.string(forKey: "CallsFixtureControls").map { $0 == "shown" } |
| + NavigationStack { |
| + PhoneCall( |
| + call: call, leave: {}, |
| + style: defaults.bool(forKey: "CallsFixtureClassic") ? .classic : .fullWindow, |
| + controlsShown: pinned, showingMore: defaults.bool(forKey: "CallsFixtureMore"), |
| + grid: fixture == .grid) |
| + } |
| + .tint(CallsAppearance.accent) |
| + .preferredColorScheme(defaults.bool(forKey: "CallsFixtureDark") ? .dark : .light) |
| + .onAppear { |
| + guard !applied else { return } |
| + applied = true |
| + fixture.apply(to: call) |
| + } |
| + } |
| + } |
| +#endif |
diff --git a/native/ios/Sources/PhoneFullScreenCall.swift b/native/ios/Sources/PhoneFullScreenCall.swift| new file mode 100644 |
| index 0000000..d904ca1 |
| --- /dev/null |
| +++ b/native/ios/Sources/PhoneFullScreenCall.swift |
| @@ -0,0 +1,507 @@ |
| +import CallsKit |
| +import SwiftUI |
| + |
| +/// What the controls sheet was closed in order to open. |
| +enum MoreAction { case devices, chat } |
| + |
| +/// 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 2.5 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) |
| + .accessibilityIdentifier("callStage") |
| + if !grid { 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 nil: break |
| + } |
| + afterMore = nil |
| + } |
| + ) { |
| + MoreControls( |
| + call: call, grid: $grid, chosen: $chosen, peers: peers, unread: unread, |
| + open: { action in |
| + afterMore = action |
| + showingMore = false |
| + }, |
| + close: { showingMore = false } |
| + ) |
| + .presentationDetents([.medium, .large]) |
| + } |
| + } |
| + |
| + private var fade: Animation { |
| + .easeInOut( |
| + duration: ControlsVisibility.fadeDuration(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 { |
| + let lift = EdgeInsets( |
| + top: 0, leading: safe.leading, bottom: safe.bottom + (chrome.visible ? Self.barHeight : 0), |
| + trailing: 0) |
| + if grid { |
| + grid(in: size, safe: safe) |
| + } else { |
| + switch focused { |
| + case .screen(let peer): |
| + PhoneVideoTile( |
| + track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), |
| + screen: true, cornerRadius: 0, compact: true, labelInsets: lift) |
| + case .camera(let peer): |
| + PhoneVideoTile( |
| + track: call.remoteTracks[peer], label: call.name(of: peer), |
| + muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer), |
| + cornerRadius: 0, compact: true, labelInsets: lift) |
| + case nil: |
| + 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) |
| + } |
| + } |
| + } |
| + |
| + /// 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 count = call.screens.count + 1 + peers.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(call.screens, id: \.self) { peer in |
| + PhoneVideoTile( |
| + track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)), |
| + screen: true, cornerRadius: 8, compact: true, backdrop: Self.tileBackdrop |
| + ) |
| + .frame(width: tile.width, height: tile.height) |
| + } |
| + PhoneVideoTile( |
| + track: call.localTrack, label: "You", muted: call.muted, mirrored: true, |
| + cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: 8, |
| + compact: true, backdrop: Self.tileBackdrop |
| + ) |
| + .frame(width: tile.width, height: tile.height) |
| + ForEach(peers) { peer in |
| + PhoneVideoTile( |
| + track: call.remoteTracks[peer.peer], label: peer.name, |
| + muted: call.isMuted(peer.peer), cameraOff: call.isCameraOff(peer.peer), |
| + cornerRadius: 8, compact: true, backdrop: Self.tileBackdrop |
| + ) |
| + .frame(width: tile.width, height: tile.height) |
| + } |
| + } |
| + .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 |
| + ) |
| + .frame(width: pip.width, height: pip.height) |
| + .overlay(RoundedRectangle(cornerRadius: 14).strokeBorder(.white.opacity(0.2))) |
| + .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.state?.phase, status: call.status, sharingScreen: call.sharingScreen, |
| + screenNote: call.screenNote, floor: call.floor) |
| + } |
| + |
| + private func topOverlay(safe: EdgeInsets) -> some View { |
| + VStack(spacing: 8) { |
| + // The title goes with the controls; the notices under it do not. |
| + VStack(alignment: .leading, spacing: 2) { |
| + Text(call.title).font(.headline).lineLimit(2) |
| + Text(call.status).font(.caption).foregroundStyle(.secondary) |
| + .accessibilityAddTraits(.updatesFrequently) |
| + ForEach(notices.filter { !$0.important }) { notice in |
| + Text(notice.text).font(.caption).foregroundStyle(.secondary) |
| + .fixedSize(horizontal: false, vertical: true) |
| + } |
| + } |
| + .padding(.horizontal, 12).padding(.vertical, 8) |
| + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) |
| + .frame(maxWidth: .infinity, alignment: .leading) |
| + .opacity(chrome.visible ? 1 : 0) |
| + .allowsHitTesting(chrome.visible) |
| + .accessibilityHidden(!chrome.visible) |
| + .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()) |
| + } |
| + } |
| + .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(.orange).bold() |
| + case .reconnecting, .waitingToJoin: |
| + ProgressView() |
| + 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 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("More controls", systemImage: "ellipsis") |
| + .labelStyle(.iconOnly).rotationEffect(.degrees(90)) |
| + } |
| + .buttonStyle(RoundCallButtonStyle(tint: nil)) |
| + .overlay(alignment: .topTrailing) { |
| + if unread > 0 { |
| + Text("\(unread)").font(.caption.bold()).foregroundStyle(.white) |
| + .padding(5).background(.purple, 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", |
| + systemImage: call.muted ? "mic.slash.fill" : "mic.fill" |
| + ) { call.mute() } |
| + .labelStyle(.iconOnly) |
| + .buttonStyle(RoundCallButtonStyle(tint: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : nil)) |
| + .accessibilityIdentifier("mute") |
| + } |
| + Spacer(minLength: 0) |
| + Button("End call", systemImage: "phone.down.fill", role: .destructive, action: leave) |
| + .labelStyle(.iconOnly) |
| + .buttonStyle(RoundCallButtonStyle(tint: .red)) |
| + .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: material with a white symbol, or filled with |
| +/// `tint` (End call's red, a muted microphone's amber). |
| +struct RoundCallButtonStyle: ButtonStyle { |
| + let tint: Color? |
| + func makeBody(configuration: Configuration) -> some View { |
| + configuration.label.font(.title2.weight(.semibold)).foregroundStyle(.white) |
| + .frame(width: PhoneCall.button, height: PhoneCall.button) |
| + .background { |
| + if let tint { |
| + Circle().fill(tint) |
| + } else { |
| + Circle().fill(.ultraThinMaterial).environment(\.colorScheme, .dark) |
| + } |
| + } |
| + .opacity(configuration.isPressed ? 0.7 : 1) |
| + .contentShape(Circle()) |
| + } |
| +} |
| + |
| +struct PhoneNoticeSurface: ViewModifier { |
| + func body(content: Content) -> some View { |
| + content.font(.callout) |
| + .padding(.horizontal, 14).padding(.vertical, 8) |
| + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) |
| + .shadow(color: .black.opacity(0.2), 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 |
| + @Binding var grid: Bool |
| + @Binding var chosen: StageItem? |
| + let peers: [RosterPeer] |
| + 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", |
| + systemImage: 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", |
| + systemImage: call.cameraOff ? "video.slash.fill" : "video.fill" |
| + ) { call.toggleCamera() } |
| + .buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff)) |
| + .accessibilityIdentifier("camera") |
| + } |
| + tile("Flip") { |
| + Button("Flip camera", systemImage: "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", systemImage: "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", systemImage: "slider.horizontal.3") { open(.devices) } |
| + .buttonStyle(CallActionStyle(color: .teal)) |
| + .accessibilityIdentifier("devices") |
| + } |
| + tile("Mic Mode") { |
| + Button("Mic Mode", systemImage: "waveform") { micModes.showPicker() } |
| + .buttonStyle(CallActionStyle(color: .teal)) |
| + .accessibilityIdentifier("micModeQuick") |
| + .accessibilityHint(micModes.summary) |
| + } |
| + tile(grid ? "Grid" : "Focus") { |
| + Menu { |
| + Picker("Video layout", selection: $grid) { |
| + Label("Focus", systemImage: "pip").tag(false) |
| + Label("Grid", systemImage: "square.grid.2x2").tag(true) |
| + } |
| + if peers.count > 1 || !call.screens.isEmpty { |
| + Section("Focus on") { |
| + ForEach(call.screens, id: \.self) { peer in |
| + Button(screenCaption(call.name(of: peer))) { |
| + chosen = .screen(peer) |
| + grid = false |
| + } |
| + } |
| + ForEach(peers) { peer in |
| + Button(peer.name) { |
| + chosen = .camera(peer.peer) |
| + grid = false |
| + } |
| + } |
| + } |
| + } |
| + } label: { |
| + Label( |
| + "Video layout", |
| + systemImage: grid ? "square.grid.2x2" : "pip") |
| + } |
| + .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)", systemImage: "bubble.left.and.bubble.right.fill") { |
| + open(.chat) |
| + } |
| + .buttonStyle(CallActionStyle(color: .purple, emphasized: unread > 0)) |
| + .accessibilityIdentifier("openChat") |
| + } |
| + } |
| + 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) |
| + } |
| +} |
diff --git a/native/ios/Tests/CallsPhoneTests.swift b/native/ios/Tests/CallsPhoneTests.swift| index b9bece0..1567d6c 100644 |
| --- a/native/ios/Tests/CallsPhoneTests.swift |
| +++ b/native/ios/Tests/CallsPhoneTests.swift |
| @@ -23,11 +23,18 @@ final class CallsPhoneTests: XCTestCase { |
| a.close() |
| b.close() |
| } |
| + // Synthetic cameras behind the app's own camera management, as the Mac |
| + // gate has them; each call its own defaults, never the simulator's. |
| + func synthetic() -> ManagedCamera { |
| + ManagedCamera( |
| + backend: SyntheticCameras(), |
| + defaults: UserDefaults(suiteName: "calls-phone-test-\(UUID().uuidString)")!) |
| + } |
| try await a.join( |
| - origin: input.origin, room: input.room, cookies: input.cookiesA, capture: SyntheticVideo()) |
| + origin: input.origin, room: input.room, cookies: input.cookiesA, capture: synthetic()) |
| a.mute() |
| try await b.join( |
| - origin: input.origin, room: input.room, cookies: input.cookiesB, capture: SyntheticVideo()) |
| + origin: input.origin, room: input.room, cookies: input.cookiesB, capture: synthetic()) |
| b.mute() |
| var decodedA: UInt64 = 0 |
| var decodedB: UInt64 = 0 |
diff --git a/native/ios/UITests/CallsPhoneUITests.swift b/native/ios/UITests/CallsPhoneUITests.swift| index 7a78762..8fde901 100644 |
| --- a/native/ios/UITests/CallsPhoneUITests.swift |
| +++ b/native/ios/UITests/CallsPhoneUITests.swift |
| @@ -119,4 +119,139 @@ final class CallsPhoneUITests: XCTestCase { |
| attachment.lifetime = .keepAlways |
| add(attachment) |
| } |
| + |
| + // MARK: The full-screen call |
| + |
| + /// Launches the debug build's described call (see PhoneCallFixture). |
| + @MainActor private func launchCall( |
| + _ fixture: String = "focus", _ extra: [String] = [], dark: Bool = false |
| + ) -> XCUIApplication { |
| + let app = XCUIApplication() |
| + app.launchArguments += ["-CallsCallFixture", fixture, "-CallsFixtureDark", dark ? "YES" : "NO"] |
| + app.launchArguments += extra |
| + app.launch() |
| + return app |
| + } |
| + |
| + /// Where the snapshots go: CALLS_SNAPSHOT_DIR (TEST_RUNNER_CALLS_SNAPSHOT_DIR |
| + /// to xcodebuild), or /tmp/immersive-call. The simulator writes the Mac's |
| + /// own /tmp. |
| + private var snapshotDirectory: URL { |
| + URL( |
| + fileURLWithPath: ProcessInfo.processInfo.environment["CALLS_SNAPSHOT_DIR"] |
| + ?? "/tmp/immersive-call") |
| + } |
| + |
| + /// The screen as the device draws it: upright for portrait, and turned |
| + /// a quarter for landscape (straightened afterwards with `sips -r 270`; |
| + /// the app's own screenshot crops landscape wrongly on iOS 26). |
| + @MainActor private func snapshot(_ app: XCUIApplication, _ name: String) throws { |
| + let shot = XCUIScreen.main.screenshot() |
| + let attachment = XCTAttachment(screenshot: shot) |
| + attachment.name = name |
| + attachment.lifetime = .keepAlways |
| + add(attachment) |
| + try FileManager.default.createDirectory( |
| + at: snapshotDirectory, withIntermediateDirectories: true) |
| + try shot.pngRepresentation.write( |
| + to: snapshotDirectory.appendingPathComponent("iphone-\(name).png")) |
| + } |
| + |
| + /// Paul's layout: the menu on the left, End call on the right, fading |
| + /// 2.5 s after the last touch, back with a tap, and every other control |
| + /// in the menu. |
| + @MainActor func testFullScreenCallControls() throws { |
| + XCUIDevice.shared.orientation = .portrait |
| + let app = launchCall() |
| + let more = app.buttons["moreControls"] |
| + let end = app.buttons["leave"] |
| + XCTAssertTrue(more.waitForExistence(timeout: 15)) |
| + XCTAssertTrue(end.exists) |
| + XCTAssertLessThan(more.frame.midX, end.frame.midX, "the menu is left of End call") |
| + XCTAssertFalse(app.buttons["mute"].exists, "Mute is in the menu, not the bar") |
| + |
| + // They go on their own, and a tap on the picture brings them back. |
| + let gone = NSPredicate(format: "exists == false") |
| + expectation(for: gone, evaluatedWith: more) |
| + waitForExpectations(timeout: 6) |
| + app.descendants(matching: .any)["callStage"].firstMatch.tap() |
| + XCTAssertTrue(more.waitForExistence(timeout: 3)) |
| + // A second tap hides them again at once. |
| + app.descendants(matching: .any)["callStage"].firstMatch.tap() |
| + expectation(for: gone, evaluatedWith: more) |
| + waitForExpectations(timeout: 2) |
| + app.descendants(matching: .any)["callStage"].firstMatch.tap() |
| + XCTAssertTrue(more.waitForExistence(timeout: 3)) |
| + |
| + more.tap() |
| + let mute = app.buttons["mute"] |
| + XCTAssertTrue(mute.waitForExistence(timeout: 5)) |
| + for control in ["camera", "flipCamera", "devices", "micModeQuick", "videoLayout", "openChat"] { |
| + XCTAssertTrue(app.buttons[control].exists, "\(control) is in the menu") |
| + } |
| + XCTAssertTrue(app.switches["noiseReduction"].exists) |
| + XCTAssertTrue(app.descendants(matching: .any)["shareScreen"].exists) |
| + XCTAssertTrue(app.buttons["Share meeting link"].exists, "a member can share the link") |
| + mute.tap() |
| + XCTAssertEqual(mute.label, "Unmute microphone") |
| + // The controls stay while the menu is open, however long that is. |
| + Thread.sleep(forTimeInterval: 3.5) |
| + app.buttons["Done"].tap() |
| + XCTAssertTrue(more.waitForExistence(timeout: 3), "still up just after the menu closes") |
| + let selfView = app.descendants(matching: .any)["selfView"] |
| + XCTAssertTrue(selfView.exists) |
| + XCTAssertTrue(selfView.label.contains("muted"), "muted says so on the self view") |
| + |
| + // Chat from the menu opens once the menu has gone. |
| + more.tap() |
| + XCTAssertTrue(app.buttons["openChat"].waitForExistence(timeout: 5)) |
| + app.buttons["openChat"].tap() |
| + XCTAssertTrue(app.textFields["chatComposer"].waitForExistence(timeout: 5)) |
| + } |
| + |
| + /// The snapshots of the full-screen call and the classic one, light and |
| + /// dark, portrait and landscape. |
| + @MainActor func testFullScreenCallSnapshots() throws { |
| + defer { XCUIDevice.shared.orientation = .portrait } |
| + for dark in [false, true] { |
| + let scheme = dark ? "dark" : "light" |
| + for orientation in [UIDeviceOrientation.portrait, .landscapeLeft] { |
| + XCUIDevice.shared.orientation = orientation |
| + let turn = orientation == .portrait ? "portrait" : "landscape" |
| + for (fixture, shown) in [("focus", true), ("focus", false), ("grid", true), ("screen", false)] { |
| + let app = launchCall( |
| + fixture, ["-CallsFixtureControls", shown ? "shown" : "hidden"], dark: dark) |
| + XCTAssertTrue(app.descendants(matching: .any)["callStage"].firstMatch.waitForExistence(timeout: 15)) |
| + Thread.sleep(forTimeInterval: 1) |
| + try snapshot(app, "\(fixture)-\(shown ? "controls" : "hidden")-\(turn)-\(scheme)") |
| + } |
| + let menu = launchCall( |
| + "focus", ["-CallsFixtureControls", "shown", "-CallsFixtureMore", "YES"], dark: dark) |
| + XCTAssertTrue(menu.buttons["mute"].waitForExistence(timeout: 15)) |
| + Thread.sleep(forTimeInterval: 1) |
| + try snapshot(menu, "menu-\(turn)-\(scheme)") |
| + } |
| + XCUIDevice.shared.orientation = .portrait |
| + let notices = launchCall("notices", ["-CallsFixtureControls", "hidden"], dark: dark) |
| + XCTAssertTrue(notices.buttons["Let in"].waitForExistence(timeout: 15)) |
| + Thread.sleep(forTimeInterval: 1) |
| + try snapshot(notices, "notices-hidden-portrait-\(scheme)") |
| + let classic = launchCall("focus", ["-CallsFixtureClassic", "YES"], dark: dark) |
| + XCTAssertTrue(classic.buttons["leave"].waitForExistence(timeout: 15)) |
| + Thread.sleep(forTimeInterval: 1) |
| + try snapshot(classic, "classic-portrait-\(scheme)") |
| + } |
| + } |
| + |
| + /// Landscape keeps both bar buttons on screen, clear of the Dynamic |
| + /// Island's side. |
| + @MainActor func testLandscapeKeepsTheBarOnScreen() throws { |
| + XCUIDevice.shared.orientation = .landscapeLeft |
| + defer { XCUIDevice.shared.orientation = .portrait } |
| + let app = launchCall("focus", ["-CallsFixtureControls", "shown"]) |
| + XCTAssertTrue(app.buttons["leave"].waitForExistence(timeout: 15)) |
| + XCTAssertTrue(app.buttons["leave"].isHittable) |
| + XCTAssertTrue(app.buttons["moreControls"].isHittable) |
| + XCTAssertLessThan(app.buttons["moreControls"].frame.midX, app.buttons["leave"].frame.midX) |
| + } |
| } |
diff --git a/native/ios/project.yml b/native/ios/project.yml| index c8affd5..9f6ecec 100644 |
| --- a/native/ios/project.yml |
| +++ b/native/ios/project.yml |
| @@ -10,7 +10,7 @@ settings: |
| base: |
| SWIFT_VERSION: '5.0' |
| CODE_SIGN_STYLE: Automatic |
| - MARKETING_VERSION: '0.3.3' |
| + MARKETING_VERSION: '0.3.4' |
| CURRENT_PROJECT_VERSION: '1' |
| # The App Group the app and its broadcast extension share: the socket |
| # the iPhone's screen crosses lives in its container. One setting |
diff --git a/test/browser/native.mjs b/test/browser/native.mjs| index e7a19b5..e64991e 100644 |
| --- a/test/browser/native.mjs |
| +++ b/test/browser/native.mjs |
| @@ -171,12 +171,36 @@ try { |
| assert.equal(sample.stats.peerConnections, 1, 'recovering the camera keeps the same peer connection'); |
| console.log('PASS a stalled camera was restarted and the far side kept its picture'); |
| } |
| - await page.locator('[data-chat-text]').fill('browser chat probe'); |
| - await page.locator('[data-chat-send]').click(); |
| - await wait(() => sample?.receivedChatProbe === true, 'native displays browser chat'); |
| - native.stdin.write('chat\n'); |
| - await wait(() => chatCommandHandled, 'native handles send-chat command'); |
| - await page.locator('[data-chat-log]').getByText('native chat probe', { exact: true }).waitFor(); |
| + // Chat is not replayed to a recipient whose stream is renewing (§6), and |
| + // in this mode the stream renews every ten seconds, so a line that lands |
| + // in a renewal's gap is lost by design. Since the camera steps above went |
| + // in, chat runs well after the first renewal, and under load the gap is |
| + // wide enough to catch it (2026-09-25: both subtests failed here on an |
| + // unchanged tree, then passed alone and in a full run). Each direction |
| + // gets three tries; a line that never arrives still fails. |
| + for (let attempt = 1; ; attempt++) { |
| + await page.locator('[data-chat-text]').fill('browser chat probe'); |
| + await page.locator('[data-chat-send]').click(); |
| + try { |
| + await wait(() => sample?.receivedChatProbe === true, 'native displays browser chat', 8); |
| + break; |
| + } catch (err) { |
| + if (attempt === 3) throw err; |
| + console.log('RETRY browser chat probe, attempt', attempt + 1); |
| + } |
| + } |
| + for (let attempt = 1; ; attempt++) { |
| + native.stdin.write('chat\n'); |
| + await wait(() => chatCommandHandled, 'native handles send-chat command'); |
| + try { |
| + await page.locator('[data-chat-log]').getByText('native chat probe', { exact: true }).first() |
| + .waitFor({ timeout: 8000 }); |
| + break; |
| + } catch (err) { |
| + if (attempt === 3) throw err; |
| + console.log('RETRY native chat probe, attempt', attempt + 1); |
| + } |
| + } |
| await wait(() => sample?.sentChatProbe === true, 'native displays accepted outgoing chat'); |
| console.log('PASS chat delivered from browser to native and native to browser'); |
| // Spec §15.19 across the two clients: the app's mute reaches the |