| 1 | #if os(macOS) |
| 2 | import AppKit |
| 3 | import SwiftUI |
| 4 | |
| 5 | /// The controls in the call bar, for keyboard focus: while one has it, |
| 6 | /// the bar stays up. |
| 7 | enum CallControlID: Hashable { |
| 8 | case mute, camera, share, devices, layout, link, chat, leave |
| 9 | } |
| 10 | |
| 11 | /// The full-window call: the picture edge to edge, to the very top of |
| 12 | /// the window, with the traffic lights, the controls and the panels over |
| 13 | /// it, and — for a meeting someone named — its title, centred where the |
| 14 | /// window's title would be (CallTitle). There is no title bar: a strip across the top drags |
| 15 | /// the window instead. The controls and the traffic lights come with the |
| 16 | /// pointer and go 3 s after it stops, or 1.5 s after it leaves the |
| 17 | /// window (see ControlsVisibility for what keeps them up). Notices that |
| 18 | /// matter stay whatever the controls do. |
| 19 | extension MacCallView { |
| 20 | /// Room kept clear at the top for the title bar's traffic lights. |
| 21 | static let titleBarHeight: CGFloat = 32 |
| 22 | /// Height of the floating bar with its margin below: what the self |
| 23 | /// view and a tile's name move up by while the bar shows. |
| 24 | static let barReserve: CGFloat = 44 + 20 + 16 |
| 25 | static let panelWidth: CGFloat = 320 |
| 26 | static let tileBackdrop = Color(white: 0.13) |
| 27 | /// Where the traffic lights sit, from the window's top-left corner: |
| 28 | /// the pointer over them holds the controls (and them) up, and the |
| 29 | /// title keeps to the right of it. |
| 30 | static let windowButtonsArea = CGRect(x: 0, y: 0, width: 80, height: titleBarHeight) |
| 31 | |
| 32 | var immersive: some View { |
| 33 | GeometryReader { geometry in |
| 34 | let size = geometry.size |
| 35 | ZStack { |
| 36 | Color.black |
| 37 | if let fixtureBackdrop { |
| 38 | fixtureBackdrop.view.accessibilityHidden(true).movesWindow() |
| 39 | } else { |
| 40 | fullWindowStage(in: size).movesWindow() |
| 41 | } |
| 42 | if !grid, selfFloats { selfView(in: size) } |
| 43 | topShade |
| 44 | // Above the picture, below the title and everything else: what |
| 45 | // is left of the strip once they have their clicks drags. |
| 46 | WindowDragStrip() |
| 47 | .frame(height: Self.titleBarHeight) |
| 48 | .frame(maxHeight: .infinity, alignment: .top) |
| 49 | topOverlay |
| 50 | panels(in: size) |
| 51 | bar(in: size) |
| 52 | } |
| 53 | .coordinateSpace(name: "call") |
| 54 | .frame(width: size.width, height: size.height) |
| 55 | } |
| 56 | .ignoresSafeArea() |
| 57 | .background( |
| 58 | CallWindowChrome( |
| 59 | title: CallTitle.window(title: call.title), |
| 60 | buttonsVisible: chrome.visible, |
| 61 | fade: ControlsVisibility.fadeDuration(showing: chrome.visible, reduceMotion: reduceMotion))) |
| 62 | .background( |
| 63 | CallWindowActivity( |
| 64 | moved: { point in |
| 65 | chrome.pointerMoved() |
| 66 | chrome.set( |
| 67 | .pointerOverControls, |
| 68 | chrome.visible |
| 69 | && (barFrame.contains(point) || Self.windowButtonsArea.contains(point))) |
| 70 | }, |
| 71 | exited: { chrome.pointerExited() }, |
| 72 | activity: { chrome.activity() }, |
| 73 | menuTracking: { menuTracking = $0 }, |
| 74 | sheet: { sheetShowing = $0 }) |
| 75 | ) |
| 76 | .animation(fade, value: chrome.visible) |
| 77 | .onAppear { updateHolds() } |
| 78 | .onChange(of: showingDevices) { updateHolds() } |
| 79 | .onChange(of: showingChat) { updateHolds() } |
| 80 | .onChange(of: showingLayout) { updateHolds() } |
| 81 | .onChange(of: showingMore) { updateHolds() } |
| 82 | .onChange(of: menuTracking) { updateHolds() } |
| 83 | .onChange(of: sheetShowing) { updateHolds() } |
| 84 | .onChange(of: voiceOver) { updateHolds() } |
| 85 | .onChange(of: focusedControl) { _, now in |
| 86 | chrome.set(.keyboardFocus, now != nil) |
| 87 | } |
| 88 | .onChange(of: chrome.state.hidesPointer) { _, hide in |
| 89 | // Hidden until it moves, which is also what brings the controls |
| 90 | // back: AppKit shows it again on the first movement by itself. |
| 91 | if hide, chrome.pinned == nil { NSCursor.setHiddenUntilMouseMoves(true) } |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | private var fade: Animation { |
| 96 | ControlsVisibility.fade(showing: chrome.visible, reduceMotion: reduceMotion) |
| 97 | } |
| 98 | |
| 99 | /// A shade down from the top edge while the controls show, so the |
| 100 | /// traffic lights and the title read over a bright picture. |
| 101 | private var topShade: some View { |
| 102 | // Dark enough through the title bar's strip for a named meeting's |
| 103 | // title, white, over a white picture (4.5:1), then fading. |
| 104 | LinearGradient( |
| 105 | stops: [ |
| 106 | .init(color: .black.opacity(0.72), location: 0), |
| 107 | .init(color: .black.opacity(0.62), location: Self.titleBarHeight / 112), |
| 108 | .init(color: .black.opacity(0), location: 1), |
| 109 | ], startPoint: .top, endPoint: .bottom |
| 110 | ) |
| 111 | .frame(height: 112) |
| 112 | .frame(maxHeight: .infinity, alignment: .top) |
| 113 | .opacity(chrome.visible ? 1 : 0) |
| 114 | .allowsHitTesting(false) |
| 115 | .accessibilityHidden(true) |
| 116 | } |
| 117 | |
| 118 | private func updateHolds() { |
| 119 | chrome.set( |
| 120 | .menuOpen, showingDevices || showingChat || showingLayout || showingMore || menuTracking) |
| 121 | chrome.set(.alert, sheetShowing) |
| 122 | chrome.set(.voiceOver, voiceOver) |
| 123 | } |
| 124 | |
| 125 | /// How far up a name at the bottom of the stage sits, clear of the bar. |
| 126 | private var lift: CGFloat { chrome.visible ? Self.barReserve - 8 : 0 } |
| 127 | |
| 128 | // MARK: Stage |
| 129 | |
| 130 | @ViewBuilder private func fullWindowStage(in size: CGSize) -> some View { |
| 131 | Group { |
| 132 | if grid { |
| 133 | fullWindowGrid(in: size) |
| 134 | } else { |
| 135 | speakerStage(in: size) |
| 136 | } |
| 137 | } |
| 138 | .coordinateSpace(name: arrangeSpace) |
| 139 | .onPreferenceChange(TileFramesKey.self) { tileFrames = $0 } |
| 140 | } |
| 141 | |
| 142 | /// Speaker view: the stage fills the whole window, edge to edge, as |
| 143 | /// FaceTime's does, and everyone else is a filmstrip floating over its |
| 144 | /// bottom, in this viewer's order (2026-10-05: until then the stage |
| 145 | /// stopped above the filmstrip, leaving a black band across the |
| 146 | /// window behind it and the bar). The filmstrip rises clear of the bar |
| 147 | /// while the controls show, as the self view does, and settles to the |
| 148 | /// window's edge when they go. The stage's name sits at the window's |
| 149 | /// bottom left, just above the filmstrip (`StageLabel`). In a call of |
| 150 | /// two there is no filmstrip: the other person fills the window and |
| 151 | /// you float in a corner, as since 0.3.4. |
| 152 | private func speakerStage(in size: CGSize) -> some View { |
| 153 | let strip = selfFloats ? [] : filmstrip |
| 154 | let thumb = Self.filmstripTile(in: size) |
| 155 | let band = strip.isEmpty ? 0 : thumb.height + 2 * Self.filmstripPadding |
| 156 | let stageLift = StageLabel.lift(bar: lift, band: band) |
| 157 | return ZStack(alignment: .bottom) { |
| 158 | Group { |
| 159 | if let focused { |
| 160 | macTile(focused, cornerRadius: 0, compact: true, labelLift: stageLift, onStage: true) |
| 161 | .overlay(alignment: .bottomTrailing) { |
| 162 | if arrangement.pinned == focused { |
| 163 | PinBadge(title: itemTitle(focused)) { |
| 164 | withAnimation(panelAnimation) { arrangement.pin(nil) } |
| 165 | } |
| 166 | .padding(8).padding(.bottom, stageLift) |
| 167 | } |
| 168 | } |
| 169 | } else { |
| 170 | ContentUnavailableView( |
| 171 | "Waiting for others", systemImage: "person.2", |
| 172 | description: Text("Your meeting is ready. Others will appear here when they join.") |
| 173 | ) |
| 174 | .foregroundStyle(.white.opacity(0.85)) |
| 175 | .environment(\.colorScheme, .dark) |
| 176 | .frame(maxWidth: .infinity, maxHeight: .infinity) |
| 177 | } |
| 178 | } |
| 179 | .frame(maxWidth: .infinity, maxHeight: .infinity) |
| 180 | .animation(fade, value: chrome.visible) |
| 181 | if !strip.isEmpty { |
| 182 | filmstripView(strip, tile: thumb, width: size.width) |
| 183 | .padding(.bottom, chrome.visible ? Self.barReserve - 8 : 0) |
| 184 | .animation(fade, value: chrome.visible) |
| 185 | } |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | /// Room above and below the filmstrip's tiles. |
| 190 | static let filmstripPadding: CGFloat = 8 |
| 191 | |
| 192 | /// A filmstrip tile's size: a sixth of the window's height, between |
| 193 | /// 72 and 120 pt tall, at 16:9. At the smallest window (760 × 540) |
| 194 | /// that is 86 pt, and five of them fit across without scrolling. |
| 195 | static func filmstripTile(in size: CGSize) -> CGSize { |
| 196 | let height = min(120, max(72, (size.height / 6).rounded())) |
| 197 | return CGSize(width: (height * 16 / 9).rounded(), height: height) |
| 198 | } |
| 199 | |
| 200 | /// The filmstrip: centred while it fits, scrolling sideways once it |
| 201 | /// does not. |
| 202 | private func filmstripView(_ strip: [StageItem], tile: CGSize, width: CGFloat) -> some View { |
| 203 | ScrollView(.horizontal) { |
| 204 | HStack(spacing: 8) { |
| 205 | ForEach(strip, id: \.self) { item in |
| 206 | macTile(item, cornerRadius: 8, compact: true, backdrop: Self.tileBackdrop, iconOnly: true) |
| 207 | .frame(width: tile.width, height: tile.height) |
| 208 | .modifier(arrangeable(item, tapToPin: true)) |
| 209 | } |
| 210 | } |
| 211 | .padding(.horizontal, 12).padding(.vertical, Self.filmstripPadding) |
| 212 | .frame(minWidth: width) |
| 213 | // The click that brings the window forward pins too, as it does |
| 214 | // on the picture; it does not reach through a scroll view unless |
| 215 | // the content asks again. |
| 216 | .takesActivatingClicks() |
| 217 | } |
| 218 | .scrollIndicators(.automatic) |
| 219 | .accessibilityElement(children: .contain) |
| 220 | .accessibilityLabel("Filmstrip") |
| 221 | .accessibilityIdentifier("filmstrip") |
| 222 | } |
| 223 | |
| 224 | /// Every tile as large as it can be without scrolling, until there are |
| 225 | /// so many that they would be too small to see, when it scrolls. |
| 226 | @ViewBuilder private func fullWindowGrid(in size: CGSize) -> some View { |
| 227 | let items = gridItems |
| 228 | let count = items.count |
| 229 | let area = CGSize(width: size.width - 8, height: size.height - 8) |
| 230 | let columns = GridFit.columns(count: count, in: area, aspect: 16 / 9) |
| 231 | let fitted = GridFit.tile(count: count, columns: columns, in: area, aspect: 16 / 9) |
| 232 | let tile = fitted.width >= 200 ? fitted : CGSize(width: 200, height: 200 * 9 / 16) |
| 233 | let across = |
| 234 | fitted.width >= 200 |
| 235 | ? columns : max(1, Int((area.width + 4) / (tile.width + 4))) |
| 236 | let rows = (count + across - 1) / across |
| 237 | let gridHeight = CGFloat(rows) * tile.height + CGFloat(max(rows - 1, 0)) * 4 |
| 238 | // The last row's names clear the bar while it shows: lifted by as |
| 239 | // much of the bar as reaches over them (the grid is centred). |
| 240 | let below = max(0, (size.height - 8 - gridHeight) / 2) + 4 |
| 241 | let lastLift = max(0, lift - below) |
| 242 | let firstOfLastRow = (rows - 1) * across |
| 243 | ScrollView { |
| 244 | LazyVGrid( |
| 245 | columns: Array(repeating: GridItem(.fixed(tile.width), spacing: 4), count: across), |
| 246 | spacing: 4 |
| 247 | ) { |
| 248 | ForEach(Array(items.enumerated()), id: \.element) { index, item in |
| 249 | macTile( |
| 250 | item, cornerRadius: 6, compact: true, |
| 251 | labelLift: index >= firstOfLastRow ? lastLift : 0, backdrop: Self.tileBackdrop |
| 252 | ) |
| 253 | .frame(width: tile.width, height: tile.height) |
| 254 | .modifier(arrangeable(item, tapToPin: false)) |
| 255 | } |
| 256 | } |
| 257 | .padding(4) |
| 258 | .frame(minHeight: size.height) |
| 259 | } |
| 260 | .scrollIndicators(.never) |
| 261 | } |
| 262 | |
| 263 | // MARK: Self view |
| 264 | |
| 265 | /// You, small and floating: dragged anywhere, settling in the nearest |
| 266 | /// corner, kept clear of the bar while it shows and of an open chat. |
| 267 | /// Muted shows on it whether or not the controls do. |
| 268 | private func selfView(in size: CGSize) -> some View { |
| 269 | let corner = PiPCorner(rawValue: pipCorner) ?? .bottomTrailing |
| 270 | let pip = PiPLayout.size(in: size, touch: false) |
| 271 | let frame = PiPLayout.frame(corner: corner, size: pip, in: size, insets: selfInsets(in: size)) |
| 272 | return MacVideoTile( |
| 273 | track: call.localTrack, label: "You", muted: call.muted, mirrored: true, |
| 274 | cameraOff: call.cameraOff, placeholder: call.selfPlaceholder, cornerRadius: 10, |
| 275 | compact: true, backdrop: Self.tileBackdrop, audio: call.selfAudio |
| 276 | ) |
| 277 | .frame(width: pip.width, height: pip.height) |
| 278 | .shadow(color: .black.opacity(0.35), radius: 8, y: 3) |
| 279 | .position(x: frame.midX + pipDrag.width, y: frame.midY + pipDrag.height) |
| 280 | .gesture( |
| 281 | DragGesture() |
| 282 | .onChanged { pipDrag = $0.translation } |
| 283 | .onEnded { drag in |
| 284 | let dropped = CGPoint( |
| 285 | x: frame.midX + drag.translation.width, y: frame.midY + drag.translation.height) |
| 286 | let headed = CGPoint( |
| 287 | x: frame.midX + drag.predictedEndTranslation.width, |
| 288 | y: frame.midY + drag.predictedEndTranslation.height) |
| 289 | withAnimation(reduceMotion ? nil : .spring(duration: 0.3)) { |
| 290 | pipCorner = PiPLayout.corner(droppedAt: dropped, predicted: headed, in: size).rawValue |
| 291 | pipDrag = .zero |
| 292 | } |
| 293 | } |
| 294 | ) |
| 295 | .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: frame) |
| 296 | .accessibilityActions { |
| 297 | ForEach(PiPCorner.allCases, id: \.self) { place in |
| 298 | Button(place.moveTitle) { pipCorner = place.rawValue } |
| 299 | } |
| 300 | } |
| 301 | } |
| 302 | |
| 303 | private func selfInsets(in size: CGSize) -> EdgeInsets { |
| 304 | var trailing: CGFloat = 0 |
| 305 | if showingChat { trailing += Self.panelWidth + 12 } |
| 306 | // The title bar's strip, with the traffic lights and any title in |
| 307 | // it, is the only thing at the top: a self view goes below it. |
| 308 | return EdgeInsets( |
| 309 | top: Self.titleBarHeight, leading: 0, |
| 310 | bottom: chrome.visible ? Self.barReserve - 12 : 0, trailing: trailing) |
| 311 | } |
| 312 | |
| 313 | // MARK: Title and notices |
| 314 | |
| 315 | private var notices: [CallNotice] { |
| 316 | CallNotices.notices( |
| 317 | phase: call.noticePhase, status: call.status, sharingScreen: call.sharingScreen, |
| 318 | screenNote: call.screenNote, floor: call.floor, screenRecovery: call.screenRecovery) |
| 319 | } |
| 320 | |
| 321 | private var topOverlay: some View { |
| 322 | // No pill (2026-10-02, Paul approved): how the call is going is the |
| 323 | // notices' to say, and the title — only one someone chose — is |
| 324 | // plain text centred in the title bar's strip, level with the |
| 325 | // traffic lights, as a window's title is. The strip's dark shade |
| 326 | // (topShade) keeps it legible over a bright picture, and it fades |
| 327 | // with the controls and the traffic lights. |
| 328 | ZStack(alignment: .top) { |
| 329 | if let shown = CallTitle.shown(title: call.title, named: call.named) { |
| 330 | Text(shown) |
| 331 | .font(.system(size: 13, weight: .semibold)) |
| 332 | .foregroundStyle(.white) |
| 333 | .shadow(color: .black.opacity(0.45), radius: 2, y: 1) |
| 334 | .lineLimit(1).truncationMode(.tail) |
| 335 | // Clear of the traffic lights on the left and the same on the |
| 336 | // right, so it stays centred on the window. |
| 337 | .padding(.horizontal, Self.windowButtonsArea.maxX + 8) |
| 338 | .frame(maxWidth: .infinity) |
| 339 | .frame(height: Self.titleBarHeight) |
| 340 | .opacity(chrome.visible ? 1 : 0) |
| 341 | .allowsHitTesting(false) |
| 342 | .accessibilityHidden(!chrome.visible) |
| 343 | .accessibilityAddTraits(.isHeader) |
| 344 | .accessibilityIdentifier("callTitle") |
| 345 | } |
| 346 | |
| 347 | VStack(spacing: 8) { |
| 348 | ForEach(notices.filter { $0.important && !dismissedNotices.contains($0.id) }) { |
| 349 | notice in |
| 350 | noticeView(notice) |
| 351 | } |
| 352 | ForEach(call.state?.waiting ?? []) { peer in |
| 353 | HStack(spacing: 12) { |
| 354 | Label("\(peer.name) is waiting", systemImage: "person.badge.clock") |
| 355 | Button("Let in") { call.admit(peer.peer) }.buttonStyle(.borderedProminent) |
| 356 | } |
| 357 | .modifier(NoticeSurface()) |
| 358 | } |
| 359 | // A notice that is not urgent — the relay warning — goes with |
| 360 | // the controls; it lived in the pill until the pill went. |
| 361 | ForEach(notices.filter { !$0.important }) { notice in |
| 362 | noticeView(notice) |
| 363 | .opacity(chrome.visible ? 1 : 0) |
| 364 | .allowsHitTesting(chrome.visible) |
| 365 | .accessibilityHidden(!chrome.visible) |
| 366 | } |
| 367 | } |
| 368 | .padding(.horizontal, 90) |
| 369 | .padding(.top, Self.titleBarHeight + 6) |
| 370 | } |
| 371 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) |
| 372 | } |
| 373 | |
| 374 | @ViewBuilder private func noticeView(_ notice: CallNotice) -> some View { |
| 375 | HStack(spacing: 12) { |
| 376 | switch notice.kind { |
| 377 | case .sharing: |
| 378 | Label(notice.text, systemImage: "rectangle.on.rectangle") |
| 379 | .foregroundStyle(CallOverlay.alertText).bold() |
| 380 | case .screenRecovering: |
| 381 | Label(notice.text, systemImage: "arrow.clockwise") |
| 382 | case .connecting, .reconnecting, .waitingToJoin: |
| 383 | // Drawn rather than AppKit's spinner, which stays dark-on-dark |
| 384 | // on this surface in a light-appearance app. |
| 385 | Image(systemName: "arrow.triangle.2.circlepath") |
| 386 | .symbolEffect(.pulse, isActive: !reduceMotion) |
| 387 | .accessibilityHidden(true) |
| 388 | Text(notice.text) |
| 389 | case .full: |
| 390 | Label(notice.text, systemImage: "person.3.fill") |
| 391 | Button("Try joining again") { call.retryJoin() } |
| 392 | case .ended: |
| 393 | Label(notice.text, systemImage: "phone.down") |
| 394 | if let rejoin { |
| 395 | Button("Join again", action: rejoin).buttonStyle(.borderedProminent) |
| 396 | .accessibilityIdentifier("joinAgain") |
| 397 | } |
| 398 | case .problem, .floor: |
| 399 | Label(notice.text, systemImage: "exclamationmark.triangle.fill") |
| 400 | } |
| 401 | if let action = notice.action { |
| 402 | NoticeActionButton(action) { call.perform(action) } |
| 403 | } |
| 404 | if notice.dismissible { |
| 405 | Button("Dismiss", systemImage: "xmark") { dismissedNotices.insert(notice.id) } |
| 406 | .labelStyle(.iconOnly).buttonStyle(.borderless) |
| 407 | .help("Dismiss") |
| 408 | } |
| 409 | } |
| 410 | .modifier(NoticeSurface()) |
| 411 | .accessibilityElement(children: .contain) |
| 412 | } |
| 413 | |
| 414 | // MARK: Panels |
| 415 | |
| 416 | @ViewBuilder private func panels(in size: CGSize) -> some View { |
| 417 | let top = Self.titleBarHeight + 8 |
| 418 | // Below the title, which shows while a panel is open. |
| 419 | let devicesTop = Self.titleBarHeight + 40 |
| 420 | let bottom = Self.barReserve + 8 |
| 421 | if showingDevices { |
| 422 | VStack(spacing: 0) { |
| 423 | HStack { |
| 424 | Text("Devices").font(.headline) |
| 425 | Spacer() |
| 426 | Button("Hide devices", systemImage: "xmark") { |
| 427 | withAnimation(panelAnimation) { showingDevices = false } |
| 428 | } |
| 429 | .labelStyle(.iconOnly).buttonStyle(.borderless).help("Hide devices") |
| 430 | }.padding(.horizontal, 16).padding(.top, 14) |
| 431 | // Its own height when that fits, scrolling when it does not. |
| 432 | ScrollView { |
| 433 | DevicePanel(call: call) |
| 434 | .background( |
| 435 | GeometryReader { panel in |
| 436 | Color.clear |
| 437 | .onAppear { devicesHeight = panel.size.height } |
| 438 | .onChange(of: panel.size.height) { _, now in devicesHeight = now } |
| 439 | }) |
| 440 | } |
| 441 | .frame(height: max(80, min(devicesHeight, size.height - devicesTop - bottom - 44))) |
| 442 | } |
| 443 | .frame(width: Self.panelWidth) |
| 444 | // Read, not glanced at, and over video: opaque and dark, with the |
| 445 | // dark appearance's full-contrast labels, whatever the system's |
| 446 | // appearance (a light material over a picture was grey on grey). |
| 447 | // It is not part of the controls' fade: it holds them up while |
| 448 | // open (`.menuOpen`) and carries no opacity of its own. |
| 449 | .foregroundStyle(.white) |
| 450 | .background(CallOverlay.panelFill, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) |
| 451 | .environment(\.colorScheme, .dark) |
| 452 | .shadow(color: .black.opacity(0.35), radius: 14, y: 4) |
| 453 | .accessibilityIdentifier("devicesPanel") |
| 454 | .padding(.leading, 16).padding(.bottom, bottom) |
| 455 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomLeading) |
| 456 | .transition(panelTransition(edge: .bottom)) |
| 457 | } |
| 458 | if showingChat { |
| 459 | VStack(spacing: 14) { |
| 460 | HStack { |
| 461 | Text("Chat").font(.headline) |
| 462 | Spacer() |
| 463 | Button("Open chat in a window", systemImage: "arrow.up.forward.app") { |
| 464 | showingChat = false |
| 465 | chatWindow.open(call: call) |
| 466 | }.help("Open chat in a separate window") |
| 467 | Button("Hide chat", systemImage: "xmark") { |
| 468 | withAnimation(panelAnimation) { showingChat = false } |
| 469 | } |
| 470 | .help("Hide chat") |
| 471 | }.labelStyle(.iconOnly).buttonStyle(.borderless) |
| 472 | ChatView(call: call, draft: chatWindow.draft) |
| 473 | } |
| 474 | .padding(16).frame(width: Self.panelWidth) |
| 475 | .frame(maxHeight: .infinity) |
| 476 | .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 16)) |
| 477 | .shadow(color: .black.opacity(0.25), radius: 12, y: 4) |
| 478 | .padding(.top, top).padding(.trailing, 12).padding(.bottom, bottom) |
| 479 | .frame(maxWidth: .infinity, alignment: .trailing) |
| 480 | .transition(panelTransition(edge: .trailing)) |
| 481 | } |
| 482 | } |
| 483 | |
| 484 | /// Panels slide in from their edge; with Reduce Motion they only fade. |
| 485 | private func panelTransition(edge: Edge) -> AnyTransition { |
| 486 | reduceMotion ? .opacity : .move(edge: edge).combined(with: .opacity) |
| 487 | } |
| 488 | |
| 489 | // MARK: Bar |
| 490 | |
| 491 | /// The bar: centred on the window and whole at every width |
| 492 | /// (CallBarFit), its width set from the window's rather than left to |
| 493 | /// whatever its row asks for — tightening, then moving the least used |
| 494 | /// controls into ⋯, before anything could reach past an edge. |
| 495 | private func bar(in size: CGSize) -> some View { |
| 496 | let fit = CallBarFit.fit(windowWidth: size.width, controls: barControls) |
| 497 | return controls(fullWindow: true, fit: fit) |
| 498 | .padding(fit.spacing.padding) |
| 499 | .frame(width: fit.width) |
| 500 | // One glass pane, no edge, the buttons sitting on it. |
| 501 | .callOverlaySurface(Capsule()) |
| 502 | .shadow(color: .black.opacity(0.25), radius: 12, y: 4) |
| 503 | .background( |
| 504 | GeometryReader { bar in |
| 505 | Color.clear |
| 506 | .onAppear { |
| 507 | barFrame = bar.frame(in: .named("call")) |
| 508 | CallBarProbe.pane = bar.frame(in: .global) |
| 509 | } |
| 510 | .onChange(of: bar.frame(in: .named("call"))) { _, now in barFrame = now } |
| 511 | .onChange(of: bar.frame(in: .global)) { _, now in CallBarProbe.pane = now } |
| 512 | } |
| 513 | ) |
| 514 | .padding(.bottom, CallBarFit.margin) |
| 515 | .frame(width: size.width, height: size.height, alignment: .bottom) |
| 516 | .opacity(chrome.visible ? 1 : 0) |
| 517 | .allowsHitTesting(chrome.visible) |
| 518 | // Hidden from sight but never from VoiceOver, which holds the bar |
| 519 | // up anyway; hidden from the keyboard's Tab, which brings it back. |
| 520 | .accessibilityHidden(!chrome.visible) |
| 521 | } |
| 522 | } |
| 523 | |
| 524 | extension PiPCorner { |
| 525 | var moveTitle: String { |
| 526 | switch self { |
| 527 | case .topLeading: "Move self view to top left" |
| 528 | case .topTrailing: "Move self view to top right" |
| 529 | case .bottomLeading: "Move self view to bottom left" |
| 530 | case .bottomTrailing: "Move self view to bottom right" |
| 531 | } |
| 532 | } |
| 533 | } |
| 534 | |
| 535 | /// A notice over the picture: a capsule of the overlay's borderless |
| 536 | /// glass, with white text and dark-appearance controls, whatever the |
| 537 | /// system's. |
| 538 | struct NoticeSurface: ViewModifier { |
| 539 | func body(content: Content) -> some View { |
| 540 | content.font(.callout).foregroundStyle(.white) |
| 541 | .environment(\.colorScheme, .dark) |
| 542 | .padding(.horizontal, 14).padding(.vertical, 8) |
| 543 | // Without Liquid Glass: its lit rim is the faint light edge Paul |
| 544 | // saw round the sharing notice (2026-09-28), and no overlay has |
| 545 | // an edge since 0.3.7. |
| 546 | .callOverlaySurface(Capsule(), rim: false) |
| 547 | .shadow(color: .black.opacity(0.25), radius: 8, y: 2) |
| 548 | } |
| 549 | } |
| 550 | |
| 551 | /// The call window's own chrome while the full-window call shows: no |
| 552 | /// title bar — transparent, no title, the content under it to the very |
| 553 | /// top — and the traffic lights over the picture, fading with the |
| 554 | /// controls. Everything is put back when the call leaves the window or |
| 555 | /// changes to Classic, and none of it touches full screen or a panel. |
| 556 | /// |
| 557 | /// SwiftUI keeps its own idea of the window's title bar and can put it |
| 558 | /// back while the call is up (0.3.4 showed an opaque bar over the call); |
| 559 | /// so while this view is in the window it watches those properties and |
| 560 | /// sets them again whenever something else changes them. |
| 561 | /// |
| 562 | /// The window's title is the meeting's while the call is in it, named |
| 563 | /// or not (CallTitle.window): hidden on screen with the rest of the |
| 564 | /// title bar, it is still what the Window menu, Mission Control and |
| 565 | /// VoiceOver read. It is put back with everything else. |
| 566 | struct CallWindowChrome: NSViewRepresentable { |
| 567 | var title: String |
| 568 | var buttonsVisible: Bool |
| 569 | var fade: TimeInterval |
| 570 | |
| 571 | func makeNSView(context: Context) -> ChromeView { ChromeView() } |
| 572 | func updateNSView(_ view: ChromeView, context: Context) { |
| 573 | view.setTitle(title) |
| 574 | view.setButtons(visible: buttonsVisible, fade: fade) |
| 575 | } |
| 576 | static func dismantleNSView(_ view: ChromeView, coordinator: ()) { view.release() } |
| 577 | |
| 578 | final class ChromeView: NSView { |
| 579 | private weak var styled: NSWindow? |
| 580 | private var saved: (transparent: Bool, title: NSWindow.TitleVisibility, full: Bool, separator: NSTitlebarSeparatorStyle, text: String)? |
| 581 | private var title = "" |
| 582 | private var watches: [NSKeyValueObservation] = [] |
| 583 | private var observers: [NSObjectProtocol] = [] |
| 584 | private var buttonsVisible = true |
| 585 | |
| 586 | override func viewDidMoveToWindow() { |
| 587 | super.viewDidMoveToWindow() |
| 588 | guard let window, !(window is NSPanel) else { |
| 589 | release() |
| 590 | return |
| 591 | } |
| 592 | guard styled !== window else { return } |
| 593 | release() |
| 594 | styled = window |
| 595 | saved = ( |
| 596 | window.titlebarAppearsTransparent, window.titleVisibility, |
| 597 | window.styleMask.contains(.fullSizeContentView), window.titlebarSeparatorStyle, |
| 598 | window.title |
| 599 | ) |
| 600 | apply(to: window) |
| 601 | if !title.isEmpty { window.title = title } |
| 602 | showButtons(animated: false) |
| 603 | watches = [ |
| 604 | window.observe(\.titlebarAppearsTransparent) { [weak self] window, _ in |
| 605 | MainActor.assumeIsolated { self?.apply(to: window) } |
| 606 | }, |
| 607 | window.observe(\.styleMask) { [weak self] window, _ in |
| 608 | MainActor.assumeIsolated { self?.apply(to: window) } |
| 609 | }, |
| 610 | // SwiftUI writes the scene's own title ("Calls") back now and |
| 611 | // then; the meeting's goes back over it. |
| 612 | window.observe(\.title) { [weak self] window, _ in |
| 613 | MainActor.assumeIsolated { |
| 614 | guard let self, window === self.styled, !self.title.isEmpty, window.title != self.title |
| 615 | else { return } |
| 616 | window.title = self.title |
| 617 | } |
| 618 | }, |
| 619 | ] |
| 620 | let center = NotificationCenter.default |
| 621 | observers = [ |
| 622 | NSWindow.didEnterFullScreenNotification, NSWindow.didExitFullScreenNotification, |
| 623 | NSWindow.didBecomeKeyNotification, NSWindow.didResizeNotification, |
| 624 | ].map { name in |
| 625 | center.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in |
| 626 | MainActor.assumeIsolated { self?.showButtons(animated: false) } |
| 627 | } |
| 628 | } |
| 629 | } |
| 630 | |
| 631 | /// Hidden title bar, content to the top. Only what differs is set, |
| 632 | /// so the watches that call this do not call it again. |
| 633 | private func apply(to window: NSWindow) { |
| 634 | guard window === styled else { return } |
| 635 | if !window.titlebarAppearsTransparent { window.titlebarAppearsTransparent = true } |
| 636 | if window.titleVisibility != .hidden { window.titleVisibility = .hidden } |
| 637 | if !window.styleMask.contains(.fullSizeContentView) { |
| 638 | window.styleMask.insert(.fullSizeContentView) |
| 639 | } |
| 640 | if window.titlebarSeparatorStyle != .none { window.titlebarSeparatorStyle = .none } |
| 641 | } |
| 642 | |
| 643 | func setTitle(_ text: String) { |
| 644 | title = text |
| 645 | if let window = styled, window.title != text { window.title = text } |
| 646 | } |
| 647 | |
| 648 | func setButtons(visible: Bool, fade: TimeInterval) { |
| 649 | guard visible != buttonsVisible else { return } |
| 650 | buttonsVisible = visible |
| 651 | NSAnimationContext.runAnimationGroup { context in |
| 652 | context.duration = fade |
| 653 | context.timingFunction = CAMediaTimingFunction(name: .easeOut) |
| 654 | showButtons(animated: true) |
| 655 | } |
| 656 | } |
| 657 | |
| 658 | /// The traffic lights, shown or hidden with the controls; always |
| 659 | /// shown in full screen, where the system reveals them with the menu |
| 660 | /// bar, and when the call lets the window go. |
| 661 | private func showButtons(animated: Bool) { |
| 662 | guard let window = styled else { return } |
| 663 | let fullScreen = window.styleMask.contains(.fullScreen) |
| 664 | let alpha: CGFloat = buttonsVisible || fullScreen ? 1 : 0 |
| 665 | for kind in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { |
| 666 | guard let button = window.standardWindowButton(kind) else { continue } |
| 667 | if animated { button.animator().alphaValue = alpha } else { button.alphaValue = alpha } |
| 668 | } |
| 669 | } |
| 670 | |
| 671 | /// Puts the window back as it was found. |
| 672 | func release() { |
| 673 | watches.forEach { $0.invalidate() } |
| 674 | watches = [] |
| 675 | observers.forEach(NotificationCenter.default.removeObserver) |
| 676 | observers = [] |
| 677 | guard let window = styled, let saved else { return } |
| 678 | styled = nil |
| 679 | self.saved = nil |
| 680 | for kind in [NSWindow.ButtonType.closeButton, .miniaturizeButton, .zoomButton] { |
| 681 | window.standardWindowButton(kind)?.alphaValue = 1 |
| 682 | } |
| 683 | window.titlebarAppearsTransparent = saved.transparent |
| 684 | window.titleVisibility = saved.title |
| 685 | window.titlebarSeparatorStyle = saved.separator |
| 686 | window.title = saved.text |
| 687 | if !saved.full { window.styleMask.remove(.fullSizeContentView) } |
| 688 | } |
| 689 | |
| 690 | override func viewWillMove(toWindow next: NSWindow?) { |
| 691 | super.viewWillMove(toWindow: next) |
| 692 | if next !== styled { release() } |
| 693 | } |
| 694 | |
| 695 | override func hitTest(_ point: NSPoint) -> NSView? { nil } |
| 696 | } |
| 697 | } |
| 698 | |
| 699 | /// The strip across the top of the full-window call that stands in for |
| 700 | /// the title bar it replaces: dragging it moves the window, and a double |
| 701 | /// click does what the person's "double-click a window's title bar" |
| 702 | /// setting says (zoom, minimise, or nothing). |
| 703 | struct WindowDragStrip: NSViewRepresentable { |
| 704 | func makeNSView(context: Context) -> StripView { StripView() } |
| 705 | func updateNSView(_ view: StripView, context: Context) {} |
| 706 | |
| 707 | final class StripView: NSView { |
| 708 | override var mouseDownCanMoveWindow: Bool { true } |
| 709 | override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } |
| 710 | |
| 711 | override func mouseDown(with event: NSEvent) { |
| 712 | guard let window else { return } |
| 713 | if event.clickCount == 2 { |
| 714 | TitleBarDoubleClick.current().perform(on: window) |
| 715 | } else { |
| 716 | window.performDrag(with: event) |
| 717 | } |
| 718 | } |
| 719 | } |
| 720 | } |
| 721 | |
| 722 | /// What a double click on a title bar does, from System Settings › |
| 723 | /// Desktop & Dock: "Double-click a window's title bar to…". |
| 724 | enum TitleBarDoubleClick: Equatable { |
| 725 | case zoom, minimize, none |
| 726 | |
| 727 | static func current(_ defaults: UserDefaults = .standard) -> TitleBarDoubleClick { |
| 728 | from(action: defaults.string(forKey: "AppleActionOnDoubleClick"), |
| 729 | miniaturize: defaults.object(forKey: "AppleMiniaturizeOnDoubleClick") as? Bool) |
| 730 | } |
| 731 | |
| 732 | /// "Maximize" (zoom) is the default, and "Fill" zooms too; the older |
| 733 | /// boolean says minimise when the newer key is absent. |
| 734 | static func from(action: String?, miniaturize: Bool?) -> TitleBarDoubleClick { |
| 735 | switch action { |
| 736 | case "Minimize": return .minimize |
| 737 | case "None": return .none |
| 738 | case nil: return miniaturize == true ? .minimize : .zoom |
| 739 | default: return .zoom |
| 740 | } |
| 741 | } |
| 742 | } |
| 743 | |
| 744 | /// What the call window's pointer and keyboard are doing, for the |
| 745 | /// controls' visibility: the pointer moving (with where, in the view's |
| 746 | /// own top-left coordinates) and leaving, Tab pressed, a menu tracking |
| 747 | /// anywhere in the app, a sheet on the window. Tracks the pointer even |
| 748 | /// while the app is not active, as a video player does. |
| 749 | struct CallWindowActivity: NSViewRepresentable { |
| 750 | var moved: (CGPoint) -> Void |
| 751 | var exited: () -> Void |
| 752 | /// Tab pressed, or a click: the person is here. |
| 753 | var activity: () -> Void |
| 754 | var menuTracking: (Bool) -> Void |
| 755 | var sheet: (Bool) -> Void |
| 756 | |
| 757 | func makeNSView(context: Context) -> ActivityView { ActivityView() } |
| 758 | func updateNSView(_ view: ActivityView, context: Context) { view.handlers = self } |
| 759 | static func dismantleNSView(_ view: ActivityView, coordinator: ()) { view.stop() } |
| 760 | |
| 761 | final class ActivityView: NSView { |
| 762 | var handlers: CallWindowActivity? |
| 763 | private var tracking: NSTrackingArea? |
| 764 | private var monitor: Any? |
| 765 | private var observers: [NSObjectProtocol] = [] |
| 766 | |
| 767 | override var isFlipped: Bool { true } |
| 768 | |
| 769 | override func updateTrackingAreas() { |
| 770 | super.updateTrackingAreas() |
| 771 | if let tracking { removeTrackingArea(tracking) } |
| 772 | let area = NSTrackingArea( |
| 773 | rect: .zero, |
| 774 | options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], |
| 775 | owner: self) |
| 776 | addTrackingArea(area) |
| 777 | tracking = area |
| 778 | } |
| 779 | |
| 780 | override func mouseEntered(with event: NSEvent) { report(event) } |
| 781 | override func mouseMoved(with event: NSEvent) { report(event) } |
| 782 | override func mouseExited(with event: NSEvent) { handlers?.exited() } |
| 783 | |
| 784 | private func report(_ event: NSEvent) { |
| 785 | handlers?.moved(convert(event.locationInWindow, from: nil)) |
| 786 | } |
| 787 | |
| 788 | override func viewDidMoveToWindow() { |
| 789 | super.viewDidMoveToWindow() |
| 790 | stop() |
| 791 | guard let window else { return } |
| 792 | window.acceptsMouseMovedEvents = true |
| 793 | // Tab is keyboard navigation starting: bring the controls to it. |
| 794 | monitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .leftMouseDown]) { |
| 795 | [weak self] event in |
| 796 | nonisolated(unsafe) let event = event |
| 797 | MainActor.assumeIsolated { |
| 798 | guard let self, event.window === self.window else { return } |
| 799 | if event.type == .leftMouseDown || event.keyCode == 48 { self.handlers?.activity() } |
| 800 | } |
| 801 | return event |
| 802 | } |
| 803 | let center = NotificationCenter.default |
| 804 | observers = [ |
| 805 | center.addObserver(forName: NSMenu.didBeginTrackingNotification, object: nil, queue: .main) |
| 806 | { [weak self] _ in MainActor.assumeIsolated { self?.handlers?.menuTracking(true) } }, |
| 807 | center.addObserver(forName: NSMenu.didEndTrackingNotification, object: nil, queue: .main) |
| 808 | { [weak self] _ in MainActor.assumeIsolated { self?.handlers?.menuTracking(false) } }, |
| 809 | center.addObserver( |
| 810 | forName: NSWindow.willBeginSheetNotification, object: window, queue: .main |
| 811 | ) { [weak self] _ in MainActor.assumeIsolated { self?.handlers?.sheet(true) } }, |
| 812 | center.addObserver( |
| 813 | forName: NSWindow.didEndSheetNotification, object: window, queue: .main |
| 814 | ) { [weak self] _ in MainActor.assumeIsolated { self?.handlers?.sheet(false) } }, |
| 815 | ] |
| 816 | } |
| 817 | |
| 818 | func stop() { |
| 819 | if let monitor { NSEvent.removeMonitor(monitor) } |
| 820 | monitor = nil |
| 821 | observers.forEach(NotificationCenter.default.removeObserver) |
| 822 | observers = [] |
| 823 | } |
| 824 | |
| 825 | deinit { |
| 826 | if let monitor { NSEvent.removeMonitor(monitor) } |
| 827 | observers.forEach(NotificationCenter.default.removeObserver) |
| 828 | } |
| 829 | |
| 830 | // A layer of bookkeeping, not of content: clicks go through it. |
| 831 | override func hitTest(_ point: NSPoint) -> NSView? { nil } |
| 832 | } |
| 833 | } |
| 834 | #endif |
| 835 | |