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 Combine
4 import OSLog
5 import SwiftUI
6
7 /// The mini window's decisions, for a "sometimes" (2026-10-05, Paul:
8 /// "Sometimes I can't click the mini window"): every press, what was
9 /// under it, what it was taken as, and whether bringing the call forward
10 /// worked. `log show --predicate 'subsystem == "net.amadan.oficina.calls"
11 /// && category == "mini"' --info`.
12 enum MiniLog {
13 static let logger = Logger(subsystem: "net.amadan.oficina.calls", category: "mini")
14 }
15
16 extension View {
17 /// Puts the call into a small floating window while the person is in
18 /// another app, and back into this view when they return. Apply it to
19 /// the root of the call window: that window is the one it hides.
20 public func miniWindow(for model: CallsModel) -> some View {
21 modifier(MiniWindowModifier(model: model))
22 }
23 }
24
25 private struct MiniWindowModifier: ViewModifier {
26 @ObservedObject var model: CallsModel
27 @StateObject private var controller = MiniWindowController()
28
29 func body(content: Content) -> some View {
30 content.background(
31 WindowReader { window in controller.attach(model: model, mainWindow: window) })
32 }
33 }
34
35 extension NSScreen {
36 /// The key a mini window position is remembered under on this
37 /// display: its CGDirectDisplayID, which stays the same across
38 /// launches and rearrangements for the same monitor.
39 var callsDisplayKey: String {
40 (deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)
41 .map { "\($0.uint32Value)" } ?? "main"
42 }
43 }
44
45 /// Watches the app come and go, asks MiniWindowPolicy what that means,
46 /// and moves the windows it says to. It holds no rules of its own:
47 /// where the window goes is MiniWindowCallMemory's, and how big it is
48 /// MiniWindowRules'.
49 @MainActor final class MiniWindowController: ObservableObject {
50 private(set) var policy = MiniWindowPolicy()
51 private let preferences: MiniWindowPreferences
52 /// Collapsed or not, and ⌥-dropped positions: this call's alone.
53 var memory = MiniWindowCallMemory()
54 private weak var model: CallsModel?
55 private weak var mainWindow: NSWindow?
56 private var observers: [NSObjectProtocol] = []
57 private var joining: AnyCancellable?
58 private(set) var panel: MiniWindowPanel?
59
60 init(preferences: MiniWindowPreferences = MiniWindowPreferences()) {
61 self.preferences = preferences
62 let center = NotificationCenter.default
63 let events: [(Notification.Name, @MainActor (MiniWindowController) -> Void)] = [
64 (NSApplication.didResignActiveNotification, { $0.resignedActive() }),
65 (NSApplication.didBecomeActiveNotification, { $0.apply($0.policy.becameActive()) }),
66 (NSApplication.willHideNotification, { $0.apply($0.policy.willHide()) }),
67 (NSApplication.didUnhideNotification, { $0.policy.didUnhide() }),
68 // A display unplugged, rearranged or changed resolution.
69 (NSApplication.didChangeScreenParametersNotification, { $0.relayout() }),
70 ]
71 observers = events.map { name, handle in
72 center.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
73 MainActor.assumeIsolated {
74 guard let self else { return }
75 handle(self)
76 }
77 }
78 }
79 // Another app coming forward can bring its own floating windows
80 // over this one, and a Space change or a full-screen app can leave
81 // it behind: put it back in front, taking clicks, each time.
82 let workspace = NSWorkspace.shared.notificationCenter
83 for name in [
84 NSWorkspace.activeSpaceDidChangeNotification, NSWorkspace.didActivateApplicationNotification,
85 ] {
86 observers.append(
87 workspace.addObserver(forName: name, object: nil, queue: .main) { [weak self] note in
88 MainActor.assumeIsolated { self?.panel?.reassert(because: note.name.rawValue) }
89 })
90 }
91 }
92
93 deinit {
94 for observer in observers {
95 NotificationCenter.default.removeObserver(observer)
96 NSWorkspace.shared.notificationCenter.removeObserver(observer)
97 }
98 }
99
100 func attach(model: CallsModel, mainWindow: NSWindow?) {
101 if let mainWindow { self.mainWindow = mainWindow }
102 guard self.model !== model else { return }
103 self.model = model
104 joining = model.$joining.removeDuplicates().sink { [weak self] joining in
105 guard let self else { return }
106 // A call starting or ending is a new call as far as the
107 // mini window's memory goes.
108 self.memory = MiniWindowCallMemory()
109 if !joining { self.apply(self.policy.callEnded()) }
110 }
111 }
112
113 private func resignedActive() {
114 // One turn of the run loop first: a ⌘H or a quick switch straight
115 // back should not flash a window up and down again.
116 DispatchQueue.main.async { [weak self] in
117 guard let self, !NSApp.isActive, !NSApp.isHidden else { return }
118 let window = self.mainWindow
119 self.apply(
120 self.policy.resignedActive(
121 .init(
122 enabled: self.preferences.enabled,
123 inCall: self.model?.joining ?? false,
124 mainWindowVisible: window?.isVisible ?? false,
125 mainWindowMiniaturized: window?.isMiniaturized ?? false,
126 mainWindowFullScreen: window?.styleMask.contains(.fullScreen) ?? false)))
127 }
128 }
129
130 func apply(_ action: MiniWindowPolicy.Action) {
131 switch action {
132 case .none: break
133 case .show(let hideMain):
134 guard let model else { return }
135 // Never two: a panel left behind by a show without a restore would
136 // sit over the new one, its clicks going to a window nobody sees.
137 if let stale = panel {
138 MiniLog.logger.info("show: dismissing a panel still up")
139 stale.dismiss()
140 }
141 let panel = MiniWindowPanel(
142 call: model.call,
143 actions: MiniWindowActions(
144 leave: { [weak model] in model?.leave() },
145 open: { [weak self] in self?.bringCallForward() },
146 moved: { [weak self] option in self?.dropped(option: option) },
147 resized: { [weak self] in self?.resized() },
148 collapse: { [weak self] in self?.collapse() },
149 expand: { [weak self] in self?.expand(pulled: false) },
150 tabReleased: { [weak self] in self?.tabReleased() }))
151 self.panel = panel
152 layout(panel, on: mainWindow?.screen ?? NSScreen.main)
153 panel.reassert(because: "show")
154 if hideMain, let mainWindow { Self.conceal(mainWindow, true) }
155 case .restore(let activate):
156 panel?.dismiss()
157 panel = nil
158 guard let mainWindow else { return }
159 Self.conceal(mainWindow, false)
160 // Forward unless the person came back by clicking another of this
161 // app's windows, such as chat in its own window: that one keeps focus.
162 if activate, NSApp.keyWindow == nil || NSApp.keyWindow === mainWindow {
163 mainWindow.makeKeyAndOrderFront(nil)
164 }
165 }
166 }
167
168 /// A click on the mini window: the call, forward. Since macOS 14 an
169 /// app's request to activate itself is a request the system may turn
170 /// down, and a click in a non-activating panel does not activate the
171 /// app by itself, so a refusal left the person clicking a window that
172 /// did nothing. Asked for twice, the second time as the running
173 /// application, and if the app is still not active the call window is
174 /// put back and brought in front anyway, so a click always shows the
175 /// call. Every step is logged.
176 func bringCallForward() {
177 MiniLog.logger.info(
178 "open: activating (active=\(NSApp.isActive), frontmost=\(NSWorkspace.shared.frontmostApplication?.bundleIdentifier ?? "none", privacy: .public))"
179 )
180 if #available(macOS 14, *) { NSApp.activate() } else { NSApp.activate(ignoringOtherApps: true) }
181 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
182 guard let self, self.panel != nil else { return }
183 if NSApp.isActive { return }
184 MiniLog.logger.info("open: not active after 0.25 s; asking again as the running application")
185 NSRunningApplication.current.activate(options: [.activateAllWindows])
186 DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in
187 guard let self, self.panel != nil else { return }
188 if NSApp.isActive { return }
189 MiniLog.logger.error("open: activation refused twice; showing the call window in front regardless")
190 self.apply(self.policy.becameActive())
191 self.mainWindow?.orderFrontRegardless()
192 }
193 }
194 }
195
196 /// Hides the call window by making it transparent and letting clicks
197 /// through, not by ordering it out. SwiftUI treats an ordered-out
198 /// `Window` as closed: the view's onDisappear runs, which leaves the
199 /// call, and the app then quits for want of a window. Transparent, the
200 /// window keeps its frame, its Space, its place in the stacking order
201 /// and all of its state, so putting it back is exactly as it was.
202 private static func conceal(_ window: NSWindow, _ hidden: Bool) {
203 window.alphaValue = hidden ? 0 : 1
204 window.ignoresMouseEvents = hidden
205 }
206
207 /// Puts the window where this call's memory and the preferences say,
208 /// at the remembered size, on `screen` — as a tab if it was collapsed.
209 private func layout(_ panel: MiniWindowPanel, on screen: NSScreen?) {
210 guard let screen else { return }
211 let visible = screen.visibleFrame
212 let size = MiniWindowRules.size(width: preferences.width, in: visible)
213 let frame = memory.frame(
214 size: size, in: visible, display: screen.callsDisplayKey, preferences: preferences)
215 if memory.collapsed {
216 panel.collapse(to: MiniWindowTab.dock(frame, in: visible).1, expanded: frame)
217 } else {
218 panel.expand(to: frame, animated: false)
219 }
220 }
221
222 /// The displays changed under a window that is up: put it back where
223 /// its placement says on whichever screen it is now on, clamped.
224 private func relayout() {
225 guard let panel else { return }
226 layout(panel, on: panel.screen ?? NSScreen.main)
227 }
228
229 private var screen: NSScreen? { panel?.screen ?? NSScreen.main }
230
231 /// A drag of the window ended. Where it settles is the call memory's
232 /// decision: a corner, where it was dropped, or where it was dropped
233 /// for this call only when ⌥ was held.
234 private func dropped(option: Bool) {
235 guard let panel, let screen else { return }
236 let frame = memory.dropped(
237 panel.frame, in: screen.visibleFrame, display: screen.callsDisplayKey, option: option,
238 preferences: preferences)
239 panel.expand(to: frame, animated: frame != panel.frame)
240 }
241
242 private func resized() {
243 guard let panel, !panel.collapsed, let screen else { return }
244 preferences.width = panel.frame.width
245 let frame = memory.resized(
246 panel.frame, in: screen.visibleFrame, display: screen.callsDisplayKey,
247 preferences: preferences)
248 panel.expand(to: frame, animated: frame != panel.frame)
249 }
250
251 private func collapse() {
252 guard let panel, let screen else { return }
253 memory.collapsed = true
254 panel.collapse(
255 to: MiniWindowTab.dock(panel.frame, in: screen.visibleFrame).1, expanded: panel.frame)
256 }
257
258 /// Back out of the tab: clicked, to where it was; pulled out, to where
259 /// it was let go, settling as any other drag does.
260 private func expand(pulled: Bool) {
261 guard let panel, let screen else { return }
262 memory.collapsed = false
263 let visible = screen.visibleFrame
264 let size = MiniWindowRules.size(width: panel.expandedFrame.width, in: visible)
265 guard pulled else {
266 panel.expand(
267 to: MiniWindowRules.clamp(
268 CGRect(origin: panel.expandedFrame.origin, size: size), into: visible),
269 animated: false)
270 return
271 }
272 let out = MiniWindowTab.expanded(from: panel.frame, size: size, in: visible)
273 let frame = memory.dropped(
274 out, in: visible, display: screen.callsDisplayKey,
275 option: NSEvent.modifierFlags.contains(.option), preferences: preferences)
276 panel.expand(to: frame, animated: false)
277 }
278
279 private func tabReleased() {
280 guard let panel, let screen else { return }
281 switch MiniWindowTab.release(panel.frame, in: screen.visibleFrame) {
282 case .dock(_, let frame): panel.move(to: frame, animated: true)
283 case .expand: expand(pulled: true)
284 }
285 }
286 }
287
288 /// What the window's controls and surface ask the controller to do.
289 struct MiniWindowActions {
290 var leave: () -> Void = {}
291 var open: () -> Void = {}
292 /// A drag of the expanded window ended; whether ⌥ was held.
293 var moved: (_ option: Bool) -> Void = { _ in }
294 var resized: () -> Void = {}
295 var collapse: () -> Void = {}
296 /// The tab was clicked.
297 var expand: () -> Void = {}
298 /// A drag of the tab ended.
299 var tabReleased: () -> Void = {}
300 }
301
302 /// The floating window, expanded or collapsed to a tab. Non-activating,
303 /// so clicking its controls does not drag the whole app forward;
304 /// clicking anywhere else does, on purpose, because that is how you get
305 /// back to the call. The tab is the same window at a different size, so
306 /// it floats, follows Spaces and stays out of screen captures by the
307 /// very same settings.
308 final class MiniWindowPanel: NSPanel {
309 let content: MiniWindowContent
310 /// Where the expanded window was, while it is a tab.
311 private(set) var expandedFrame: CGRect
312 var collapsed: Bool { content.collapsed }
313
314 init(call: Call, actions: MiniWindowActions) {
315 content = MiniWindowContent(call: call)
316 expandedFrame = CGRect(origin: .zero, size: MiniWindowRules.size)
317 super.init(
318 contentRect: NSRect(origin: .zero, size: MiniWindowRules.size),
319 styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
320 isReleasedWhenClosed = false
321 isFloatingPanel = true
322 level = .floating
323 hidesOnDeactivate = false
324 // Key only when something in it needs the keyboard (VoiceOver, or
325 // Full Keyboard Access). Clicking Mute must not take the keyboard
326 // from the app the person is typing in.
327 becomesKeyOnlyIfNeeded = true
328 // Every Space, and over a full-screen app, because the whole point
329 // is that it follows the person to wherever they went.
330 collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle]
331 // Never in anyone's screen capture, including this app's own shared
332 // screen: nobody in the call needs to see themselves in a corner.
333 sharingType = .none
334 backgroundColor = .clear
335 isOpaque = false
336 hasShadow = true
337 title = "Calls mini window"
338 let hosting = MiniWindowHostingView(
339 rootView: MiniCallView(content: content, actions: actions))
340 // The window's size is the controller's to set; SwiftUI's idea of
341 // an ideal size must not pin it.
342 hosting.sizingOptions = []
343 hosting.hover = content
344 contentView = hosting
345 }
346
347 /// Borderless windows cannot be key by default, which would leave the
348 /// window's controls out of reach of the keyboard altogether.
349 override var canBecomeKey: Bool { true }
350
351 /// In front, at its level, visible and taking the mouse: what a click
352 /// on it needs, put back whenever something may have changed it.
353 func reassert(because reason: String) {
354 if ignoresMouseEvents || alphaValue < 1 || level != .floating {
355 MiniLog.logger.info(
356 "reassert (\(reason, privacy: .public)): mouse ignored=\(self.ignoresMouseEvents), alpha=\(self.alphaValue), level=\(self.level.rawValue)"
357 )
358 }
359 ignoresMouseEvents = false
360 alphaValue = 1
361 level = .floating
362 orderFrontRegardless()
363 (contentView as? MiniWindowHostingView)?.syncHover()
364 }
365
366 /// Every press, logged with what it landed on, before AppKit sends it
367 /// there.
368 override func sendEvent(_ event: NSEvent) {
369 if event.type == .leftMouseDown, let content = contentView {
370 let hit = content.hitTest(content.convert(event.locationInWindow, from: nil))
371 MiniLog.logger.info(
372 "mouse down at \(Int(event.locationInWindow.x)),\(Int(event.locationInWindow.y)) in \(Int(self.frame.width))x\(Int(self.frame.height)) (\(self.collapsed ? "tab" : "window", privacy: .public)) on \(hit.map { String(describing: type(of: $0)) } ?? "nothing", privacy: .public); app active=\(NSApp.isActive), key=\(self.isKeyWindow), clicks=\(event.clickCount)"
373 )
374 }
375 super.sendEvent(event)
376 }
377
378 func expand(to frame: CGRect, animated: Bool) {
379 content.collapsed = false
380 expandedFrame = frame
381 move(to: frame, animated: animated)
382 }
383
384 func collapse(to tab: CGRect, expanded: CGRect) {
385 expandedFrame = expanded
386 content.hovering = false
387 content.collapsed = true
388 move(to: tab, animated: false)
389 }
390
391 func move(to frame: CGRect, animated: Bool) {
392 if animated, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion {
393 NSAnimationContext.runAnimationGroup { context in
394 context.duration = 0.2
395 animator().setFrame(frame, display: true)
396 }
397 } else {
398 setFrame(frame, display: true)
399 }
400 }
401
402 /// Takes the video out before the window goes, so every renderer this
403 /// window added to a track is removed from it now and not whenever the
404 /// hosting view happens to be released. The call window's own
405 /// renderers are separate objects and are untouched.
406 func dismiss() {
407 content.call = nil
408 contentView?.layoutSubtreeIfNeeded()
409 contentView?.displayIfNeeded()
410 orderOut(nil)
411 contentView = nil
412 close()
413 }
414 }
415
416 @MainActor final class MiniWindowContent: ObservableObject {
417 @Published var call: Call?
418 @Published var hovering = false
419 @Published var collapsed = false
420 init(call: Call) { self.call = call }
421 }
422
423 /// Takes the first click even though the app is not active — otherwise
424 /// the first click on Mute would only wake the window — and tracks the
425 /// pointer the same way, since SwiftUI's hover follows the key window.
426 private final class MiniWindowHostingView: NSHostingView<MiniCallView> {
427 weak var hover: MiniWindowContent?
428 private var tracking: NSTrackingArea?
429
430 override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
431
432 override func updateTrackingAreas() {
433 super.updateTrackingAreas()
434 if let tracking { removeTrackingArea(tracking) }
435 let area = NSTrackingArea(
436 rect: .zero, options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect],
437 owner: self)
438 addTrackingArea(area)
439 tracking = area
440 }
441
442 /// A window that appears under the pointer, or moves under it, gets no
443 /// mouseEntered, and its controls would stay hidden until the pointer
444 /// left and came back: hover follows where the pointer is instead.
445 func syncHover() {
446 guard let window else { return }
447 let inside = window.frame.contains(NSEvent.mouseLocation)
448 if hover?.hovering != inside { hover?.hovering = inside }
449 }
450
451 override func mouseMoved(with event: NSEvent) {
452 super.mouseMoved(with: event)
453 if hover?.hovering == false { hover?.hovering = true }
454 }
455
456 override func mouseEntered(with event: NSEvent) {
457 super.mouseEntered(with: event)
458 hover?.hovering = true
459 }
460
461 override func mouseExited(with event: NSEvent) {
462 super.mouseExited(with: event)
463 hover?.hovering = false
464 }
465 }
466
467 struct MiniCallView: View {
468 @ObservedObject var content: MiniWindowContent
469 let actions: MiniWindowActions
470 @Environment(\.accessibilityReduceMotion) private var reduceMotion
471
472 var body: some View {
473 Group {
474 if content.collapsed, let call = content.call {
475 MiniTabView(call: call, actions: actions)
476 } else {
477 ZStack {
478 if let call = content.call {
479 MiniCallStage(
480 call: call, arrangement: call.arrangement, speakerNow: call.levels.speaker,
481 showingControls: showingControls, actions: actions)
482 CallToast(call: call).padding(8)
483 .frame(maxHeight: .infinity, alignment: .top)
484 } else {
485 Color.black
486 }
487 }
488 .frame(maxWidth: .infinity, maxHeight: .infinity)
489 .background(
490 MiniWindowSurface(
491 mode: .window, click: { _ in actions.open() }, dropped: actions.moved,
492 resized: actions.resized)
493 )
494 .clipShape(RoundedRectangle(cornerRadius: 16))
495 }
496 }
497 .animation(reduceMotion ? nil : .easeOut(duration: 0.15), value: showingControls)
498 .tint(CallsAppearance.accent)
499 }
500
501 /// VoiceOver users get the controls all the time: hover is not a thing
502 /// a screen reader does.
503 private var showingControls: Bool {
504 content.hovering || NSWorkspace.shared.isVoiceOverEnabled
505 }
506 }
507
508 private struct MiniCallStage: View {
509 @ObservedObject var call: Call
510 @ObservedObject var arrangement: CallArrangement
511 @ObservedObject var speakerNow: SpeakerNow
512 let showingControls: Bool
513 let actions: MiniWindowActions
514
515 /// What Speaker view's stage shows (0.4.0): what the person pinned,
516 /// or else the newest shared screen, since a person who shares is
517 /// asking to be looked at, or else whoever is speaking, or before
518 /// anyone has, the first person in the viewer's order. Nobody else
519 /// there: your own camera, since a window of nothing reads as a
520 /// broken one. The same whichever layout the call window is in.
521 private var subject: StageItem? {
522 let item = arrangement.stage(active: speakerNow.peer, screens: call.screens)
523 if case .camera(let peer) = item, peer == arrangement.me { return nil }
524 return item
525 }
526
527 /// The people still connecting who are not the one on show.
528 private var offStage: [String] {
529 var shown: String?
530 if case .camera(let peer) = subject { shown = peer }
531 return call.connectingPeers.filter { $0 != shown }.sorted()
532 }
533
534 var body: some View {
535 ZStack {
536 Group {
537 switch subject {
538 case .screen(let peer):
539 MacVideoTile(
540 track: call.remoteScreens[peer], label: screenCaption(call.name(of: peer)),
541 screen: true)
542 case .camera(let peer):
543 MacVideoTile(
544 track: call.remoteTracks[peer], label: call.name(of: peer),
545 muted: call.isMuted(peer), cameraOff: call.isCameraOff(peer),
546 audio: call.tileAudio(peer))
547 case nil:
548 MacVideoTile(
549 track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff,
550 placeholder: call.selfPlaceholder, audio: call.selfAudio)
551 }
552 }
553 // The tile is a picture, not a target: clicks and drags go through
554 // to the surface underneath.
555 .allowsHitTesting(false)
556 VStack {
557 HStack(spacing: 8) {
558 // With the controls up, the Mute button says it; at the
559 // smallest width there is no room for both.
560 if call.muted, !showingControls {
561 Label("Muted", systemImage: "mic.slash.fill").labelStyle(.iconOnly)
562 .font(.callout.bold()).foregroundStyle(.white)
563 .padding(8).callOverlaySurface(Circle())
564 .accessibilityLabel("Your microphone is muted")
565 // A mark, not a control: a press on it is a press on the
566 // window (MiniWindowGesture), not swallowed by SwiftUI.
567 .allowsHitTesting(false)
568 }
569 Spacer()
570 // Spec §15.24, for the people this window is not showing: the
571 // one it shows says it on its own tile. Top right, clear of
572 // the tile's Muted badge.
573 ConnectingDot(names: offStage.map { call.name(of: $0) })
574 // Its names are in VoiceOver's label; a press goes through.
575 .allowsHitTesting(false)
576 if showingControls {
577 Button("Collapse to a tab", glyph: "arrow.down.right.and.arrow.up.left") {
578 actions.collapse()
579 }
580 .buttonStyle(CallActionStyle(color: Color(white: 0.3), emphasized: true))
581 .help("Collapse to a tab at the edge of the screen")
582 Button(
583 call.muted ? "Unmute microphone" : "Mute microphone",
584 glyph: call.muted ? "mic.slash.fill" : "mic.fill"
585 ) { call.mute() }
586 .buttonStyle(
587 CallActionStyle(
588 color: call.muted
589 ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent,
590 emphasized: true)
591 )
592 .help(call.muted ? "Unmute microphone" : "Mute microphone")
593 Button(
594 "Leave meeting", glyph: "phone.down.fill", role: .destructive,
595 action: actions.leave
596 )
597 .buttonStyle(CallActionStyle(color: .red, emphasized: true))
598 .help("Leave meeting")
599 }
600 }
601 Spacer()
602 }.padding(10)
603 }
604 }
605 }
606
607 /// The mini window collapsed: whether your microphone is on, and who
608 /// the call is with. Click it to open the mini window again, drag it out
609 /// to open it there, double-click it for the whole call.
610 ///
611 /// A small waveform shows while someone else is speaking, with the
612 /// speaking glow behind it following the loudest of them, from the
613 /// receive meters (spec §15.20), which read five times a second — quick
614 /// enough to follow speech, where the five-second audio readout was not.
615 struct MiniTabView: View {
616 @ObservedObject var call: Call
617 let actions: MiniWindowActions
618
619 private var others: [String] {
620 let me = call.state?.peer
621 return (call.state?.peers ?? []).filter { $0.peer != me }.map(\.name)
622 }
623 private var caption: String { MiniWindowTab.caption(others: others) }
624
625 var body: some View {
626 HStack(spacing: 8) {
627 Image(systemName: call.muted ? "mic.slash.fill" : "mic.fill")
628 .font(.callout.bold())
629 .foregroundStyle(.white)
630 .frame(width: 26, height: 26)
631 .background(
632 call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : CallsAppearance.accent,
633 in: Circle())
634 // Spec §15.24: someone is still connecting and cannot hear you
635 // yet. A dot only — the tab has no room for words; its
636 // accessibility label and the window, opened, say who.
637 .overlay(alignment: .topTrailing) {
638 if !call.connectingPeers.isEmpty {
639 Circle().fill(ConnectingDot.colour).frame(width: 8, height: 8)
640 .overlay(Circle().strokeBorder(Color.black.opacity(0.35), lineWidth: 1))
641 .offset(x: 2, y: -2)
642 .help(call.connectingPeers.sorted().map { ConnectingWords.connecting(call.name(of: $0)) }.joined(separator: "\n"))
643 }
644 }
645 Text(caption).font(.callout.weight(.medium)).lineLimit(1).truncationMode(.tail)
646 .foregroundStyle(.white)
647 Spacer(minLength: 0)
648 SpeakingGlyph(speaking: call.levels.anyoneSpeaking, glow: call.levels.othersGlow)
649 }
650 // Nothing in the tab is a control of its own: every press on it,
651 // on the microphone, the names or the waveform included, is the
652 // surface's, so a click always opens and a drag always moves. Until
653 // 2026-10-05 a press on the names or a mark went to SwiftUI, which
654 // had nothing to do with it, and was lost (CallsInterop
655 // --mini-clicks).
656 .allowsHitTesting(false)
657 .padding(.horizontal, 7)
658 .frame(width: MiniWindowTab.size.width, height: MiniWindowTab.size.height)
659 .background(
660 MiniWindowSurface(
661 mode: .tab,
662 click: { count in count >= 2 ? actions.open() : actions.expand() },
663 dropped: { _ in actions.tabReleased() }, resized: {})
664 )
665 // The call's glass, borderless, as over the picture.
666 .callOverlaySurface(Capsule())
667 .clipShape(Capsule())
668 .accessibilityElement(children: .ignore)
669 .accessibilityAddTraits(.isButton)
670 .accessibilityLabel(
671 (["Calls, \(caption)"] + call.connectingPeers.sorted().map { ConnectingWords.connecting(call.name(of: $0)) })
672 .joined(separator: ". "))
673 .accessibilityValue(call.muted ? "Microphone muted" : "Microphone on")
674 .accessibilityHint("Shows the mini window.")
675 .accessibilityAction { actions.expand() }
676 .accessibilityAction(named: "Return to the call window") { actions.open() }
677 .accessibilityAction(named: call.muted ? "Unmute microphone" : "Mute microphone") {
678 call.mute()
679 }
680 .accessibilityAction(named: "Leave meeting") { actions.leave() }
681 }
682 }
683
684 /// The mini window's whole say on spec §15.24: a small ear on the glass
685 /// while someone it is not showing is connecting and cannot hear you yet,
686 /// with their names in its tooltip and for VoiceOver. Nothing at all
687 /// otherwise.
688 struct ConnectingDot: View {
689 /// A warm amber, light enough to tell from the muted microphone's
690 /// brown on the tab.
691 static let colour = Color(red: 1, green: 0.78, blue: 0.42)
692 let names: [String]
693 var body: some View {
694 if !names.isEmpty {
695 let words = names.map(ConnectingWords.connecting).joined(separator: "\n")
696 Image(systemName: "ear")
697 .font(.callout.bold()).foregroundStyle(.white)
698 .frame(width: 32, height: 32)
699 .callOverlaySurface(Circle(), rim: false)
700 .overlay(alignment: .topTrailing) {
701 Circle().fill(Self.colour).frame(width: 7, height: 7)
702 }
703 .help(words)
704 .accessibilityElement(children: .ignore)
705 .accessibilityLabel(words)
706 .accessibilityIdentifier("connectingDot")
707 }
708 }
709 }
710
711 /// The window's background: a click, a drag that moves the window, and
712 /// — for the expanded window — a drag from an edge or corner that
713 /// resizes it. Handled here rather than with isMovableByWindowBackground
714 /// so the three can be told apart.
715 private struct MiniWindowSurface: NSViewRepresentable {
716 enum Mode { case window, tab }
717 let mode: Mode
718 /// A click, with its click count.
719 let click: (Int) -> Void
720 /// A move ended; whether ⌥ was held when it did.
721 let dropped: (Bool) -> Void
722 let resized: () -> Void
723
724 func makeNSView(context: Context) -> SurfaceView { SurfaceView() }
725 func updateNSView(_ view: SurfaceView, context: Context) {
726 view.mode = mode
727 view.click = click
728 view.dropped = dropped
729 view.resized = resized
730 }
731
732 final class SurfaceView: NSView {
733 var mode = Mode.window
734 var click: (Int) -> Void = { _ in }
735 var dropped: (Bool) -> Void = { _ in }
736 var resized: () -> Void = {}
737 private var tracking: NSTrackingArea?
738
739 override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
740 // The tab is described by its SwiftUI view, which has the actions.
741 override func isAccessibilityElement() -> Bool { mode == .window }
742 override func accessibilityRole() -> NSAccessibility.Role? { .button }
743 override func accessibilityLabel() -> String? { "Return to the call window" }
744 override func accessibilityPerformPress() -> Bool {
745 click(1)
746 return true
747 }
748
749 private func handle(at point: NSPoint) -> MiniWindowResize.Handle {
750 guard mode == .window else { return [] }
751 return MiniWindowResize.handle(at: point, in: bounds.size)
752 }
753
754 // The resize cursor at the edges, whether or not Calls is the
755 // active app — it usually is not, while this window is up.
756 override func updateTrackingAreas() {
757 super.updateTrackingAreas()
758 if let tracking { removeTrackingArea(tracking) }
759 let area = NSTrackingArea(
760 rect: .zero, options: [.mouseMoved, .mouseEnteredAndExited, .activeAlways, .inVisibleRect],
761 owner: self)
762 addTrackingArea(area)
763 tracking = area
764 }
765
766 override func mouseMoved(with event: NSEvent) {
767 Self.cursor(for: handle(at: convert(event.locationInWindow, from: nil))).set()
768 }
769
770 override func mouseExited(with event: NSEvent) { NSCursor.arrow.set() }
771
772 private static func cursor(for handle: MiniWindowResize.Handle) -> NSCursor {
773 let horizontal = !handle.isDisjoint(with: [.left, .right])
774 let vertical = !handle.isDisjoint(with: [.top, .bottom])
775 if #available(macOS 15, *) {
776 let position: NSCursor.FrameResizePosition? =
777 switch (handle.contains(.top), handle.contains(.bottom), handle.contains(.left), handle.contains(.right)) {
778 case (true, _, true, _): .topLeft
779 case (true, _, _, true): .topRight
780 case (_, true, true, _): .bottomLeft
781 case (_, true, _, true): .bottomRight
782 case (true, _, _, _): .top
783 case (_, true, _, _): .bottom
784 case (_, _, true, _): .left
785 case (_, _, _, true): .right
786 default: nil
787 }
788 if let position { return .frameResize(position: position, directions: .all) }
789 return .arrow
790 }
791 switch (horizontal, vertical) {
792 case (true, false): return .resizeLeftRight
793 case (false, true): return .resizeUpDown
794 case (true, true): return .crosshair
795 default: return .arrow
796 }
797 }
798
799 override func mouseDown(with event: NSEvent) {
800 guard let window else { return }
801 let surface: MiniWindowGesture.Surface = mode == .window ? .window : .tab
802 let start = window.convertPoint(toScreen: event.locationInWindow)
803 let startFrame = window.frame
804 let grabbed = MiniWindowGesture.grab(
805 at: convert(event.locationInWindow, from: nil), in: bounds.size, surface: surface)
806 let visible = window.screen?.visibleFrame ?? startFrame
807 var dragging = false
808 while let next = window.nextEvent(matching: [.leftMouseDragged, .leftMouseUp]) {
809 let now = Self.screenLocation(of: next)
810 let dx = now.x - start.x
811 let dy = now.y - start.y
812 if !dragging, MiniWindowGesture.dragged(dx: dx, dy: dy) { dragging = true }
813 if dragging {
814 if grabbed.isEmpty {
815 window.setFrameOrigin(NSPoint(x: startFrame.minX + dx, y: startFrame.minY + dy))
816 } else {
817 window.setFrame(
818 MiniWindowResize.frame(
819 from: startFrame, dragging: grabbed, dx: dx, dy: dy, in: visible),
820 display: true)
821 }
822 }
823 if next.type == .leftMouseUp { break }
824 }
825 let outcome = MiniWindowGesture.outcome(
826 grabbed: grabbed, dragged: dragging, clickCount: event.clickCount,
827 option: NSEvent.modifierFlags.contains(.option) || event.modifierFlags.contains(.option))
828 MiniLog.logger.info("\(surface.rawValue, privacy: .public) press: \(String(describing: outcome), privacy: .public)")
829 switch outcome {
830 case .click(let count): click(count)
831 case .move(let option): dropped(option)
832 case .resize: resized()
833 }
834 }
835
836 /// Where the pointer is for an event of a drag. The window moves
837 /// under the pointer as it is dragged, so a real event's position in
838 /// the window is stale by the time it is read: the pointer's own
839 /// location is used. An event made with no window (CallsInterop's
840 /// --mini-clicks) carries screen coordinates and is read as it is.
841 static func screenLocation(of event: NSEvent) -> CGPoint {
842 event.windowNumber == 0 && event.window == nil ? event.locationInWindow : NSEvent.mouseLocation
843 }
844 }
845 }
846
847 /// The mini window's two faces, for CallsInterop's offline snapshots.
848 /// The views themselves stay internal; this is the only way in.
849 @MainActor public enum MiniWindowSnapshot {
850 public static func window(call: Call, hovering: Bool) -> some View {
851 let content = MiniWindowContent(call: call)
852 content.hovering = hovering
853 return MiniCallView(content: content, actions: MiniWindowActions())
854 }
855
856 public static func tab(call: Call) -> some View {
857 let content = MiniWindowContent(call: call)
858 content.collapsed = true
859 return MiniCallView(content: content, actions: MiniWindowActions())
860 }
861 }
862
863 /// Finds the NSWindow a SwiftUI view is in, once it is in one.
864 private struct WindowReader: NSViewRepresentable {
865 let found: (NSWindow?) -> Void
866
867 func makeNSView(context: Context) -> ReaderView {
868 let view = ReaderView()
869 view.found = found
870 return view
871 }
872 func updateNSView(_ view: ReaderView, context: Context) {
873 view.found = found
874 if let window = view.window { found(window) }
875 }
876
877 final class ReaderView: NSView {
878 var found: (NSWindow?) -> Void = { _ in }
879 override func viewDidMoveToWindow() {
880 super.viewDidMoveToWindow()
881 if let window { found(window) }
882 }
883 }
884 }
885#endif
886