oficina / meet Public

Full-window call on Mac and iPhone: controls that fade, floating self view

A visibility state machine shared by both apps (pointer 1.5 s, touch
2.5 s, held by menus, focus, VoiceOver and alerts), a draggable self view
that snaps to corners, notices that outlive the controls, a Call layout
setting with Classic kept, and offline fixtures for both.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev cf7f652cf5af7a323cf75330bbac3da5df82455a parent b672c3d
19 files changed, +2498 −131
  • 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/Call.swift +25 −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/MacCallView.swift +150 −45
  • native/apple/Sources/CallsKit/MacFullWindowCall.swift +557 −0
  • native/apple/Sources/CallsKit/MacVideoTile.swift +20 −6
  • native/apple/Sources/CallsKit/MutedBadge.swift +31 −0
  • native/apple/Sources/CallsKit/ServerSettings.swift +20 −1
  • native/apple/Sources/CallsMac/CallsMac.swift +3 −1
  • native/apple/Tests/CallsKitTests/CallChromeTests.swift +347 −0
  • native/ios/Sources/CallsPhone.swift +140 −75
  • native/ios/Sources/PhoneCallFixture.swift +38 −0
  • native/ios/Sources/PhoneFullScreenCall.swift +506 −0
  • native/ios/Tests/CallsPhoneTests.swift +9 −2
  • native/ios/UITests/CallsPhoneUITests.swift +120 −0
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/Call.swift b/native/apple/Sources/CallsKit/Call.swift
index 3f32290..49e8365 100644
--- a/native/apple/Sources/CallsKit/Call.swift
+++ b/native/apple/Sources/CallsKit/Call.swift
@@ -629,3 +629,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 fa2791e..b8e7680 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/MacCallView.swift b/native/apple/Sources/CallsKit/MacCallView.swift
index cff43bf..59feee2 100644
--- a/native/apple/Sources/CallsKit/MacCallView.swift
+++ b/native/apple/Sources/CallsKit/MacCallView.swift
@@ -1,29 +1,88 @@
#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
+ }
+ }
+ .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 +114,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 +169,17 @@
Button("Join again", action: rejoin).buttonStyle(.borderedProminent)
.accessibilityIdentifier("joinAgain")
}
- controls
+ controls(fullWindow: false)
}
- .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() }
}
- 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 +256,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 +277,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 +289,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 +305,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 +342,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/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 ca5c22d..c3124be 100644
--- a/native/apple/Sources/CallsMac/CallsMac.swift
+++ b/native/apple/Sources/CallsMac/CallsMac.swift
@@ -109,7 +109,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 {
CallsLobby(model: model)
}
diff --git a/native/apple/Tests/CallsKitTests/CallChromeTests.swift b/native/apple/Tests/CallsKitTests/CallChromeTests.swift
new file mode 100644
index 0000000..71342d3
--- /dev/null
+++ b/native/apple/Tests/CallsKitTests/CallChromeTests.swift
@@ -0,0 +1,347 @@
+import SwiftUI
+import XCTest
+
+@testable 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: []))
+ }
+}
diff --git a/native/ios/Sources/CallsPhone.swift b/native/ios/Sources/CallsPhone.swift
index 11651d7..07012c1 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,94 @@ 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
+ ) {
+ self.call = call
+ self.leave = leave
+ self.style = style
+ _showingMore = State(initialValue: showingMore)
+ _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
+ }
+ }
+ .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 +203,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 +224,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 +350,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,6 +372,13 @@ 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()
var body: some View {
ZStack {
Color.black
@@ -337,12 +398,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..a128b46
--- /dev/null
+++ b/native/ios/Sources/PhoneCallFixture.swift
@@ -0,0 +1,38 @@
+#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"))
+ }
+ .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..8f0f41c
--- /dev/null
+++ b/native/ios/Sources/PhoneFullScreenCall.swift
@@ -0,0 +1,506 @@
+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
+
+ 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)
+ bar(safe: safe)
+ // 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
+ )
+ .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
+ )
+ .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
+ )
+ .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
+ )
+ .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)
+
+ 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)
+ }
+ .opacity(chrome.visible ? 1 : 0)
+ .allowsHitTesting(chrome.visible)
+ .accessibilityHidden(!chrome.visible)
+ }
+}
+
+/// 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..536da5e 100644
--- a/native/ios/UITests/CallsPhoneUITests.swift
+++ b/native/ios/UITests/CallsPhoneUITests.swift
@@ -119,4 +119,124 @@ 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")
+ }
+
+ @MainActor private func snapshot(_ app: XCUIApplication, _ name: String) throws {
+ let shot = app.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)")
+ }
+ }
}