oficina / meet Public

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>
claude agent, operated by paul@keymail.dev 1d0c4d8be89e9a9767bdd76a6acdd5150a8a8bf1 parent 4e8f83bparent 4363759
27 files changed, +2934 −155
  • native/DESIGN.md +55 −0
  • native/README.md +15 −3
  • native/apple/Sources/CallsInterop/CallSnapshots.swift +44 −0
  • native/apple/Sources/CallsInterop/Interop.swift +7 −1
  • native/apple/Sources/CallsInterop/LobbySnapshots.swift +1 −0
  • native/apple/Sources/CallsKit/AudioHealth.swift +29 −0
  • native/apple/Sources/CallsKit/Call.swift +28 −0
  • native/apple/Sources/CallsKit/CallChrome.swift +421 −0
  • native/apple/Sources/CallsKit/CallFixtures.swift +45 −0
  • native/apple/Sources/CallsKit/CallsSettingsView.swift +14 −0
  • native/apple/Sources/CallsKit/DevicePanel.swift +39 −7
  • native/apple/Sources/CallsKit/InviteLinkCopy.swift +121 −0
  • native/apple/Sources/CallsKit/MacCallView.swift +153 −45
  • native/apple/Sources/CallsKit/MacFullWindowCall.swift +557 −0
  • native/apple/Sources/CallsKit/MacVideoTile.swift +20 −6
  • native/apple/Sources/CallsKit/MiniWindow.swift +2 −0
  • native/apple/Sources/CallsKit/MutedBadge.swift +31 −0
  • native/apple/Sources/CallsKit/ServerSettings.swift +20 −1
  • native/apple/Sources/CallsMac/CallsMac.swift +24 −7
  • native/apple/Tests/CallsKitTests/CallChromeTests.swift +432 −0
  • native/ios/Sources/CallsPhone.swift +155 −76
  • native/ios/Sources/PhoneCallFixture.swift +39 −0
  • native/ios/Sources/PhoneFullScreenCall.swift +507 −0
  • native/ios/Tests/CallsPhoneTests.swift +9 −2
  • native/ios/UITests/CallsPhoneUITests.swift +135 −0
  • native/ios/project.yml +1 −1
  • test/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