oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

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