oficina / meet Public

Refine Meet native calls for Mac, iPhone and iPad

Add remote focus with local thumbnail, grid switching, compact colored controls, persistent chat drafts and a separate Mac chat window. Derive padded rounded Mac artwork.
Co-Authored-By: Codex <codex~paul@keymail.dev>
codex agent, operated by paul@keymail.dev b53f18473f773786a02e359c86e2224526efef59 parent 95829cd
22 files changed, +904 −258
  • native/DESIGN.md +70 −0
  • native/README.md +8 −1
  • native/apple/Package.swift +2 −1
  • native/apple/Sources/MeetInterop/Interop.swift +55 −8
  • native/apple/Sources/MeetInterop/LobbySnapshots.swift +50 −0
  • native/apple/Sources/MeetKit/CallActionStyle.swift +22 −0
  • native/apple/Sources/MeetKit/CallControl.swift +20 −0
  • native/apple/Sources/MeetKit/ChatDraft.swift +7 −0
  • native/apple/Sources/MeetKit/ChatView.swift +12 −6
  • native/apple/Sources/MeetKit/MacCallView.swift +194 −0
  • native/apple/Sources/MeetKit/MacChatWindow.swift +39 −0
  • native/apple/Sources/MeetKit/MacVideoTile.swift +89 −0
  • native/apple/Sources/MeetKit/MeetAppearance.swift +7 −0
  • native/apple/Sources/MeetKit/MeetLobby.swift +105 −0
  • native/apple/Sources/MeetKit/MeetingShareButton.swift +1 −1
  • native/apple/Sources/MeetMac/MeetMac.swift +8 −125
  • native/apple/Sources/MeetMac/Resources/MacIcon.png binary
  • native/apple/Tests/MeetKitTests/ChatWindowTests.swift +36 −0
  • native/ios/Sources/MeetPhone.swift +149 −112
  • native/scripts/build-mac.sh +1 −0
  • native/scripts/bundle-mac.py +5 −4
  • native/scripts/generate-mac-icon.swift +24 −0
diff --git a/native/DESIGN.md b/native/DESIGN.md
new file mode 100644
index 0000000..59ea5c9
--- /dev/null
+++ b/native/DESIGN.md
@@ -0,0 +1,70 @@
+# Meet native design
+
+The first Oficina native design pass uses Meet's existing teal identity,
+quiet surfaces, clear type hierarchy and native controls. Apple's communication
+and productivity apps are references for interaction quality; Oficina retains
+its own artwork, wording and colour. Mac and phone share the joining ceremony
+while their call layouts suit the available space.
+
+## First pass
+
+- `MeetLobby` renders welcome, browser approval, ready-to-join and error states
+ on Mac, iPhone and iPad. The confirmation code and browser approval remain
+ part of the existing authentication flow.
+- `MacCallView` defaults to a remote participant filling the stage with a local
+ thumbnail. Focus/Grid switches layouts; a participant menu selects the focus
+ in group calls. Video crops to fill while preserving its aspect ratio. Chat
+ starts hidden and can open as a sidebar or a separate window; its draft survives
+ moving and closing. The compact, coloured icon controls remain below the stage. On iPhone, chat remains a sheet and controls remain
+ in the bottom safe area.
+- Video names sit on an opaque dark label over the image. Microphone and camera
+ actions use both text and changing symbols, so colour is never the only cue.
+- `MeetAppearance` and `CallControl` live in MeetKit for both Apple clients.
+ These are Meet presentation choices. General native infrastructure belongs
+ in Rastrillo Native; Oficina's shared visual components belong in Estilo as
+ subsequent apps establish what they need in common.
+
+## Visual review
+
+After `swift build --package-path native/apple`, render the shipping lobby:
+
+```sh
+native/apple/.build/debug/MeetInterop --lobby-snapshots native/build/polish
+```
+
+This generates light and dark welcome, approval, ready and error fixtures
+without a server, real account or device permissions. The fixture entry point
+is only in MeetInterop, never the application.
+
+`TestNativeMacChat` also captures `native/build/call-focus.png`, `call-chat.png` and `call-grid.png` using the
+shipping Mac call view and messages received from a real browser participant.
+The off-screen bitmap capture does not capture Metal video frames; media
+continuity is verified separately by the existing decoded-frame/audio counters.
+
+The first pass was checked with Mac and iOS Simulator builds, the iPhone
+invalid-link UI test, and native/browser authentication, media, chat and sharing
+checks. Lobby review includes light/dark Mac states and iPhone at standard and
+accessibility text sizes. Physical-device call controls, VoiceOver, camera and
+microphone behaviour still need hands-on acceptance.
+
+## Next iterations
+
+Camera preview and device selection before joining, remote camera-off signalling,
+automatic speaker selection, and keyboard commands are follow-up product work.
+Those should be designed alongside the relevant media and lifecycle behaviour.
+
+Run `swift test --package-path native/apple` for chat-window lifecycle and draft
+retention checks. Window closure never invokes the meeting leave action.
+
+## iPhone and iPad call treatment · 2026-09-12
+
+The iOS call stage now defaults to remote focus with a local thumbnail, offers
+Focus/Grid and participant selection, and uses the same compact colored icon
+control vocabulary. Chat is a native sheet with unread count and a retained
+draft. iOS continues to end calls when backgrounded; native screen sharing and
+background-call continuity remain follow-up media work.
+
+Safari member/guest sign-in and the real iPhone simulator native-media tests
+passed after this change. The signed development app was built and installed on
+Paul's paired iPhone. That installation does not substitute for a physical
+camera/microphone and interruption acceptance call.
diff --git a/native/README.md b/native/README.md
index c096b7c..b69787a 100644
--- a/native/README.md
+++ b/native/README.md
@@ -96,5 +96,12 @@ See [VERIFICATION.md](VERIFICATION.md) for the completed gate record and accepta
The iPhone asset catalog and Mac application resource use the approved Oficina
Meet icon. Both are generated copies of Estilo's `brand/png/meet-1024.png`;
update them with `python3 brand/distribute.py meet /path/to/meet` from Estilo.
-The Mac sets its Dock icon from the packaged resource on launch. The Lucide
+The Mac derives `MacIcon.png` with rounded corners and transparent padding using
+`native/scripts/generate-mac-icon.swift`. Its Finder ICNS and runtime Dock icon
+both use that Mac variant; the square master remains unchanged. The Lucide
notice is retained in `native/LICENSE.lucide`.
+
+### Native interface
+
+See [DESIGN.md](DESIGN.md) for the first Oficina native visual direction, shared
+views, screenshot workflow and remaining interaction work.
diff --git a/native/apple/Package.swift b/native/apple/Package.swift
index a933498..546f1d8 100644
--- a/native/apple/Package.swift
+++ b/native/apple/Package.swift
@@ -16,7 +16,8 @@ let package = Package(
.binaryTarget(name: "MeetBridge", path: "../build/MeetBridge.xcframework"),
.target(
name: "MeetKit", dependencies: ["MeetBridge", .product(name: "WebRTC", package: "WebRTC")]),
- .executableTarget(name: "MeetMac", dependencies: ["MeetKit"], resources: [.copy("Resources/AppIcon.png")]),
+ .executableTarget(name: "MeetMac", dependencies: ["MeetKit"], resources: [.copy("Resources/AppIcon.png"), .copy("Resources/MacIcon.png")]),
.executableTarget(name: "MeetInterop", dependencies: ["MeetKit"]),
+ .testTarget(name: "MeetKitTests", dependencies: ["MeetKit"]),
]
)
diff --git a/native/apple/Sources/MeetInterop/Interop.swift b/native/apple/Sources/MeetInterop/Interop.swift
index 465a6a6..ec757d2 100644
--- a/native/apple/Sources/MeetInterop/Interop.swift
+++ b/native/apple/Sources/MeetInterop/Interop.swift
@@ -1,6 +1,6 @@
+import AppKit
import Foundation
import MeetKit
-import AppKit
import SwiftUI
// A separate test executable: synthetic video and fixture credentials are never
@@ -14,6 +14,10 @@ struct Input: Decodable {
}
@main struct Interop {
@MainActor static func main() async {
+ if CommandLine.arguments.count == 3, CommandLine.arguments[1] == "--lobby-snapshots" {
+ do { try await LobbySnapshots.save(to: CommandLine.arguments[2]) } catch { exit(1) }
+ return
+ }
guard let line = readLine(),
let input = try? JSONDecoder().decode(Input.self, from: Data(line.utf8)),
let url = URL(string: input.origin), url.scheme == "http", url.host == "127.0.0.1"
@@ -40,6 +44,7 @@ struct Input: Decodable {
}
if command == "snapshot" {
let saved = await preview?.save() ?? false
+ _ = await preview?.saveCall(call: call)
print("{\"chatSnapshotSaved\":\(saved)}")
fflush(stdout)
}
@@ -85,8 +90,10 @@ struct Input: Decodable {
init(call: Call, path: String) {
NSApplication.shared.setActivationPolicy(.prohibited)
self.path = path
- view = NSHostingView(rootView: AnyView(
- ChatView(call: call).padding(12).background(Color.white).environment(\.colorScheme, .light)))
+ view = NSHostingView(
+ rootView: AnyView(
+ ChatView(call: call).padding(12).background(Color.white).environment(\.colorScheme, .light))
+ )
window = NSWindow(
contentRect: NSRect(x: -2000, y: -2000, width: 600, height: 200),
styleMask: [.borderless], backing: .buffered, defer: false)
@@ -99,18 +106,58 @@ struct Input: Decodable {
guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false }
view.cacheDisplay(in: view.bounds, to: bitmap)
guard let data = bitmap.representation(using: .png, properties: [:]) else { return false }
- do { try data.write(to: URL(fileURLWithPath: path)); return true } catch { return false }
+ do {
+ try data.write(to: URL(fileURLWithPath: path))
+ return true
+ } catch { return false }
+ }
+ func saveCall(call: Call) async -> Bool {
+ var saved = true
+ for (name, grid, chat) in [
+ ("call-focus.png", false, false),
+ ("call-chat.png", false, true),
+ ("call-grid.png", true, false),
+ ] {
+ view.rootView = AnyView(
+ MacCallView(call: call, grid: grid, showingChat: chat, leave: {}).id(name)
+ .padding(20).tint(MeetAppearance.accent).background(Color.white)
+ .environment(\.colorScheme, .light))
+ window.setContentSize(NSSize(width: 900, height: 540))
+ try? await Task.sleep(for: .milliseconds(300))
+ saved = saveImage(named: name) && saved
+ }
+ view.rootView = AnyView(
+ ChatView(call: call).padding(12)
+ .background(Color.white).environment(\.colorScheme, .light))
+ window.setContentSize(NSSize(width: 600, height: 200))
+ return saved
+ }
+ private func saveImage(named name: String) -> Bool {
+ view.layoutSubtreeIfNeeded()
+ guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false }
+ view.cacheDisplay(in: view.bounds, to: bitmap)
+ guard let data = bitmap.representation(using: .png, properties: [:]) else { return false }
+ let target = URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent(name)
+ do {
+ try data.write(to: target)
+ return true
+ } catch { return false }
}
func saveInvitation(call: Call) async -> Bool {
- view.rootView = AnyView(MeetingInvitationView(call: call, close: {})
- .background(Color.white).environment(\.colorScheme, .light))
+ view.rootView = AnyView(
+ MeetingInvitationView(call: call, close: {})
+ .background(Color.white).environment(\.colorScheme, .light))
window.setContentSize(NSSize(width: 440, height: 340))
try? await Task.sleep(for: .milliseconds(250))
view.layoutSubtreeIfNeeded()
guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false }
view.cacheDisplay(in: view.bounds, to: bitmap)
guard let data = bitmap.representation(using: .png, properties: [:]) else { return false }
- let target = URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent("share-meeting.png")
- do { try data.write(to: target); return true } catch { return false }
+ let target = URL(fileURLWithPath: path).deletingLastPathComponent().appendingPathComponent(
+ "share-meeting.png")
+ do {
+ try data.write(to: target)
+ return true
+ } catch { return false }
}
}
diff --git a/native/apple/Sources/MeetInterop/LobbySnapshots.swift b/native/apple/Sources/MeetInterop/LobbySnapshots.swift
new file mode 100644
index 0000000..e9a5d5f
--- /dev/null
+++ b/native/apple/Sources/MeetInterop/LobbySnapshots.swift
@@ -0,0 +1,50 @@
+import AppKit
+import MeetKit
+import SwiftUI
+
+/// Offline visual fixtures of the shipping lobby. Only built into the test executable.
+@MainActor enum LobbySnapshots {
+ static func save(to directory: String) async throws {
+ NSApplication.shared.setActivationPolicy(.prohibited)
+ let folder = URL(fileURLWithPath: directory)
+ try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
+ for scheme in [ColorScheme.light, .dark] {
+ for state in ["welcome", "signin", "ready", "error"] {
+ let model = MeetModel()
+ if state == "signin" {
+ model.authenticating = true
+ model.confirmationCode = "A1B2-C3D4"
+ model.signin = URL(string: "https://example.invalid")
+ }
+ if state == "ready" {
+ model.signedIn = true
+ model.room = "team-catchup"
+ }
+ if state == "error" { model.error = "Enter a full meeting or invitation link." }
+ let view = NSHostingView(
+ rootView: MeetLobby(model: model).background(
+ scheme == .dark ? Color(white: 0.12) : Color(white: 0.98)
+ ).environment(\.colorScheme, scheme))
+ let window = NSWindow(
+ contentRect: NSRect(x: -2000, y: -2000, width: 900, height: 720),
+ styleMask: [.borderless], backing: .buffered, defer: false)
+ window.isReleasedWhenClosed = false
+ window.appearance = NSAppearance(named: scheme == .dark ? .darkAqua : .aqua)
+ window.contentView = view
+ window.orderFront(nil)
+ try await Task.sleep(for: .milliseconds(300))
+ view.layoutSubtreeIfNeeded()
+ guard let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) else {
+ throw CocoaError(.fileWriteUnknown)
+ }
+ view.cacheDisplay(in: view.bounds, to: bitmap)
+ guard let data = bitmap.representation(using: .png, properties: [:]) else {
+ throw CocoaError(.fileWriteUnknown)
+ }
+ try data.write(
+ to: folder.appendingPathComponent("\(state)-\(scheme == .dark ? "dark" : "light").png"))
+ window.close()
+ }
+ }
+ }
+}
diff --git a/native/apple/Sources/MeetKit/CallActionStyle.swift b/native/apple/Sources/MeetKit/CallActionStyle.swift
new file mode 100644
index 0000000..3a1bd4e
--- /dev/null
+++ b/native/apple/Sources/MeetKit/CallActionStyle.swift
@@ -0,0 +1,22 @@
+import SwiftUI
+
+/// Compact call controls retain their Button labels for VoiceOver and tooltips.
+public struct CallActionStyle: ButtonStyle {
+ public var color: Color
+ public var emphasized: Bool
+ @Environment(\.isEnabled) private var isEnabled
+
+ public init(color: Color = MeetAppearance.accent, emphasized: Bool = false) {
+ self.color = color
+ self.emphasized = emphasized
+ }
+
+ public func makeBody(configuration: Configuration) -> some View {
+ configuration.label.labelStyle(.iconOnly).font(.title3)
+ .frame(width: 44, height: 44)
+ .foregroundStyle(emphasized ? .white : color)
+ .background(emphasized ? color : color.opacity(0.12), in: RoundedRectangle(cornerRadius: 14))
+ .opacity(!isEnabled ? 0.4 : configuration.isPressed ? 0.65 : 1)
+ .contentShape(RoundedRectangle(cornerRadius: 14))
+ }
+}
diff --git a/native/apple/Sources/MeetKit/CallControl.swift b/native/apple/Sources/MeetKit/CallControl.swift
new file mode 100644
index 0000000..139bb51
--- /dev/null
+++ b/native/apple/Sources/MeetKit/CallControl.swift
@@ -0,0 +1,20 @@
+import SwiftUI
+
+/// A labelled call action with a stable target and a visible on/off state.
+public struct CallControl: View {
+ private let title: LocalizedStringKey
+ private let symbol: String
+ private let action: () -> Void
+
+ public init(_ title: LocalizedStringKey, systemImage: String, action: @escaping () -> Void) {
+ self.title = title
+ self.symbol = systemImage
+ self.action = action
+ }
+
+ public var body: some View {
+ Button(action: action) {
+ Label(title, systemImage: symbol).frame(minHeight: 32)
+ }.buttonStyle(.bordered).controlSize(.large)
+ }
+}
diff --git a/native/apple/Sources/MeetKit/ChatDraft.swift b/native/apple/Sources/MeetKit/ChatDraft.swift
new file mode 100644
index 0000000..738e445
--- /dev/null
+++ b/native/apple/Sources/MeetKit/ChatDraft.swift
@@ -0,0 +1,7 @@
+import Combine
+
+/// One draft survives moving chat between the sidebar and its own window.
+@MainActor public final class ChatDraft: ObservableObject {
+ @Published public var text = ""
+ public init() {}
+}
diff --git a/native/apple/Sources/MeetKit/ChatView.swift b/native/apple/Sources/MeetKit/ChatView.swift
index dde36d3..8f99bd6 100644
--- a/native/apple/Sources/MeetKit/ChatView.swift
+++ b/native/apple/Sources/MeetKit/ChatView.swift
@@ -4,9 +4,12 @@ import SwiftUI
/// signaling stream reconnects; only sending depends on current admission.
@MainActor public struct ChatView: View {
@ObservedObject private var call: Call
- @State private var message = ""
+ @StateObject private var draft: ChatDraft
- public init(call: Call) { self.call = call }
+ public init(call: Call, draft: ChatDraft? = nil) {
+ self.call = call
+ _draft = StateObject(wrappedValue: draft ?? ChatDraft())
+ }
public var body: some View {
VStack(alignment: .leading, spacing: 12) {
@@ -19,6 +22,8 @@ import SwiftUI
}
ForEach(Array(call.chat.enumerated()), id: \.offset) { _, line in
Text(line).textSelection(.enabled)
+ .padding(10).frame(maxWidth: .infinity, alignment: .leading)
+ .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
}
Color.clear.frame(height: 1).id("chat-bottom")
}.frame(maxWidth: .infinity, alignment: .leading)
@@ -35,18 +40,19 @@ import SwiftUI
.font(.footnote).foregroundStyle(.secondary)
}
HStack {
- TextField("Message to everyone", text: $message)
+ TextField("Message to everyone", text: $draft.text)
.textFieldStyle(.roundedBorder)
.accessibilityIdentifier("chatComposer")
.onSubmit(send)
- Button("Send", action: send)
- .disabled(message.isEmpty || call.state?.phase != "admitted")
+ Button("Send", systemImage: "arrow.up.circle.fill", action: send)
+ .buttonStyle(.borderedProminent)
+ .disabled(draft.text.isEmpty || call.state?.phase != "admitted")
}
}
}
private func send() {
guard call.state?.phase == "admitted" else { return }
- if call.sendChat(message) { message = "" }
+ if call.sendChat(draft.text) { draft.text = "" }
}
}
diff --git a/native/apple/Sources/MeetKit/MacCallView.swift b/native/apple/Sources/MeetKit/MacCallView.swift
new file mode 100644
index 0000000..66dcb4b
--- /dev/null
+++ b/native/apple/Sources/MeetKit/MacCallView.swift
@@ -0,0 +1,194 @@
+#if os(macOS)
+ import SwiftUI
+
+ public struct MacCallView: View {
+ @ObservedObject private var call: Call
+ private let leave: () -> Void
+ @State private var grid: Bool
+ @State private var showingChat: Bool
+ @State private var selectedPeer: String?
+ @State private var readChatCount = 0
+ @StateObject private var chatWindow = MacChatWindow()
+
+ public init(
+ call: Call, grid: Bool = false, showingChat: Bool = false, leave: @escaping () -> Void
+ ) {
+ self.call = call
+ self.leave = leave
+ _grid = State(initialValue: grid)
+ _showingChat = State(initialValue: showingChat)
+ }
+
+ public var body: some View {
+ VStack(spacing: 14) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(call.title).font(.title2.bold()).lineLimit(1)
+ Text(call.status).font(.callout).foregroundStyle(.secondary)
+ .accessibilityAddTraits(.updatesFrequently)
+ }
+ Spacer()
+ if !grid, peers.count > 1 {
+ Menu {
+ ForEach(peers) { peer in
+ Button(peer.name) { selectedPeer = peer.peer }
+ }
+ } label: {
+ Label("Choose participant", systemImage: "person.2")
+ }
+ .help("Choose the participant to show on the main stage")
+ }
+ Picker("Video layout", selection: $grid) {
+ Label("Focus", systemImage: "rectangle.inset.bottomtrailing").tag(false)
+ Label("Grid", systemImage: "square.grid.2x2").tag(true)
+ }.pickerStyle(.segmented).fixedSize()
+ }
+ if call.floor {
+ Text("Direct connections only. People on some networks may be unable to connect.")
+ .font(.callout).foregroundStyle(.secondary).frame(
+ maxWidth: .infinity, alignment: .leading)
+ }
+ HStack(alignment: .top, spacing: 16) {
+ stage.frame(maxWidth: .infinity, maxHeight: .infinity)
+ if showingChat {
+ VStack(spacing: 14) {
+ HStack {
+ Text("Chat").font(.headline)
+ Spacer()
+ Button("Open chat in a window", systemImage: "arrow.up.forward.app") {
+ showingChat = false
+ chatWindow.open(call: call)
+ }.help("Open chat in a separate window")
+ Button("Hide chat", systemImage: "xmark") { showingChat = false }
+ .help("Hide chat")
+ }.labelStyle(.iconOnly).buttonStyle(.borderless)
+ ChatView(call: call, draft: chatWindow.draft)
+ }.padding(16).frame(width: 280)
+ .background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16))
+ }
+ }.frame(maxWidth: .infinity, maxHeight: .infinity)
+ ForEach(call.state?.waiting ?? []) { peer in
+ HStack {
+ Text("\(peer.name) is waiting")
+ Spacer()
+ Button("Let in") { call.admit(peer.peer) }.buttonStyle(.borderedProminent)
+ }
+ }
+ if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } }
+ controls
+ }
+ .onAppear {
+ if showingChat { readChatCount = call.receivedChatCount }
+ }
+ .onChange(of: call.receivedChatCount) { _, count in
+ if showingChat || chatWindow.isOpen { readChatCount = count }
+ }
+ .onChange(of: showingChat) { _, visible in
+ if visible { readChatCount = call.receivedChatCount }
+ }
+ .onChange(of: chatWindow.isOpen) { _, visible in
+ if visible { readChatCount = call.receivedChatCount }
+ }
+ .onDisappear { chatWindow.close() }
+ }
+
+ private var peers: [RosterPeer] {
+ call.state?.peers.filter { $0.peer != call.state?.peer } ?? []
+ }
+ private var focusedPeer: RosterPeer? { peers.first { $0.peer == selectedPeer } ?? peers.first }
+
+ @ViewBuilder private var stage: some View {
+ if grid {
+ ScrollView {
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) {
+ MacVideoTile(
+ track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff
+ )
+ .aspectRatio(16 / 9, contentMode: .fit)
+ ForEach(peers) { peer in
+ MacVideoTile(track: call.remoteTracks[peer.peer], label: peer.name)
+ .aspectRatio(16 / 9, contentMode: .fit)
+ }
+ }
+ }
+ } else {
+ GeometryReader { geometry in
+ ZStack(alignment: .bottomTrailing) {
+ if let peer = focusedPeer {
+ MacVideoTile(track: call.remoteTracks[peer.peer], label: peer.name)
+ } else {
+ ContentUnavailableView(
+ "Waiting for others", systemImage: "person.2",
+ description: Text("Your meeting is ready. Others will appear here when they join.")
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(MeetAppearance.background, in: RoundedRectangle(cornerRadius: 16))
+ }
+ MacVideoTile(
+ track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff
+ )
+ .frame(
+ width: min(180, geometry.size.width * 0.3),
+ height: min(135, geometry.size.height * 0.3)
+ )
+ .shadow(color: .black.opacity(0.25), radius: 8, y: 3).padding(14)
+ }
+ }
+ }
+ }
+
+ private var controls: some View {
+ HStack(spacing: 12) {
+ Button(
+ call.muted ? "Unmute microphone" : "Mute microphone",
+ systemImage: call.muted ? "mic.slash.fill" : "mic.fill"
+ ) { call.mute() }
+ .buttonStyle(
+ CallActionStyle(
+ color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : MeetAppearance.accent,
+ emphasized: call.muted)
+ )
+ .help(call.muted ? "Unmute microphone" : "Mute microphone")
+ Button(
+ call.cameraOff ? "Turn camera on" : "Turn camera off",
+ systemImage: call.cameraOff ? "video.slash.fill" : "video.fill"
+ ) { call.toggleCamera() }
+ .buttonStyle(
+ CallActionStyle(
+ color: call.cameraOff ? Color(red: 0.65, green: 0.30, blue: 0.02) : .blue,
+ emphasized: call.cameraOff)
+ )
+ .help(call.cameraOff ? "Turn camera on" : "Turn camera off")
+ MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo))
+ .help("Share meeting link")
+ Spacer()
+ Button(chatTitle, systemImage: "bubble.left.and.bubble.right.fill") {
+ if chatWindow.isOpen { chatWindow.close() }
+ showingChat.toggle()
+ }.buttonStyle(CallActionStyle(color: .purple, emphasized: showingChat || chatWindow.isOpen))
+ .help(chatTitle)
+ .overlay(alignment: .topTrailing) {
+ if unread > 0 {
+ Text("\(unread)").font(.caption.bold()).foregroundStyle(.white)
+ .padding(4).background(.purple, in: Capsule()).offset(x: 5, y: -5)
+ .accessibilityHidden(true)
+ }
+ }
+ .contextMenu {
+ Button("Open chat in a window", systemImage: "arrow.up.forward.app") {
+ showingChat = false
+ chatWindow.open(call: call)
+ }
+ }
+ Button("Leave meeting", systemImage: "phone.down.fill", role: .destructive, action: leave)
+ .buttonStyle(CallActionStyle(color: .red, emphasized: true)).help("Leave meeting")
+ }.frame(maxWidth: .infinity)
+ }
+ private var unread: Int { max(0, call.receivedChatCount - readChatCount) }
+ private var chatTitle: String {
+ chatWindow.isOpen
+ ? "Move chat into the sidebar"
+ : unread > 0 ? "Chat (\(unread) new)" : showingChat ? "Hide chat" : "Show chat"
+ }
+ }
+#endif
diff --git a/native/apple/Sources/MeetKit/MacChatWindow.swift b/native/apple/Sources/MeetKit/MacChatWindow.swift
new file mode 100644
index 0000000..ff2e873
--- /dev/null
+++ b/native/apple/Sources/MeetKit/MacChatWindow.swift
@@ -0,0 +1,39 @@
+#if os(macOS)
+ import AppKit
+ import SwiftUI
+
+ @MainActor final class MacChatWindow: NSObject, ObservableObject, NSWindowDelegate {
+ @Published private(set) var isOpen = false
+ let draft = ChatDraft()
+ private var window: NSWindow?
+
+ func open(call: Call) {
+ if let window {
+ window.makeKeyAndOrderFront(nil)
+ return
+ }
+ let window = NSWindow(
+ contentRect: NSRect(x: 0, y: 0, width: 380, height: 520),
+ styleMask: [.titled, .closable, .miniaturizable, .resizable],
+ backing: .buffered, defer: false)
+ window.title = "Chat — \(call.title)"
+ window.minSize = NSSize(width: 300, height: 300)
+ window.isReleasedWhenClosed = false
+ window.contentView = NSHostingView(
+ rootView: ChatView(call: call, draft: draft)
+ .padding(20).tint(MeetAppearance.accent))
+ window.delegate = self
+ self.window = window
+ isOpen = true
+ window.center()
+ window.makeKeyAndOrderFront(nil)
+ }
+
+ func close() { window?.close() }
+
+ func windowWillClose(_ notification: Notification) {
+ window = nil
+ isOpen = false
+ }
+ }
+#endif
diff --git a/native/apple/Sources/MeetKit/MacVideoTile.swift b/native/apple/Sources/MeetKit/MacVideoTile.swift
new file mode 100644
index 0000000..061b5de
--- /dev/null
+++ b/native/apple/Sources/MeetKit/MacVideoTile.swift
@@ -0,0 +1,89 @@
+#if os(macOS)
+ import AppKit
+ import SwiftUI
+ import WebRTC
+
+ struct MacVideoTile: View {
+ let track: RTCVideoTrack?
+ let label: String
+ var mirrored = false
+ var cameraOff = false
+
+ var body: some View {
+ ZStack {
+ Color.black
+ if let track, !cameraOff {
+ MacVideoView(track: track).scaleEffect(x: mirrored ? -1 : 1, y: 1)
+ } else {
+ VStack(spacing: 12) {
+ Image(systemName: cameraOff ? "video.slash" : "person.crop.circle")
+ .font(.largeTitle)
+ Text(cameraOff ? "Camera off" : "Waiting for video").font(.callout)
+ }.foregroundStyle(.white.opacity(0.8))
+ }
+ }
+ .overlay(alignment: .bottomLeading) {
+ Text(label).font(.callout.bold()).foregroundStyle(.white)
+ .lineLimit(1).padding(.horizontal, 10).padding(.vertical, 6)
+ .background(.black.opacity(0.65), in: Capsule()).padding(10)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(label)
+ .accessibilityValue(
+ cameraOff ? "Camera off" : track == nil ? "Waiting for video" : "Video on")
+ }
+ }
+
+ private struct MacVideoView: NSViewRepresentable {
+ let track: RTCVideoTrack
+ func makeNSView(context: Context) -> FillVideoView { FillVideoView() }
+ func updateNSView(_ view: FillVideoView, context: Context) { view.attach(track) }
+ static func dismantleNSView(_ view: FillVideoView, coordinator: ()) { view.detach() }
+ }
+
+ /// The renderer keeps its source aspect ratio while the container crops overflow.
+ private final class FillVideoView: NSView, RTCVideoViewDelegate {
+ private let renderer = RTCMTLNSVideoView(frame: .zero)
+ private var track: RTCVideoTrack?
+ private var videoSize = NSSize(width: 16, height: 9)
+
+ override init(frame: NSRect) {
+ super.init(frame: frame)
+ wantsLayer = true
+ layer?.masksToBounds = true
+ renderer.delegate = self
+ addSubview(renderer)
+ }
+ required init?(coder: NSCoder) { nil }
+
+ func attach(_ next: RTCVideoTrack) {
+ guard track !== next else { return }
+ detach()
+ track = next
+ next.add(renderer)
+ }
+ func detach() {
+ track?.remove(renderer)
+ track = nil
+ }
+
+ nonisolated func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
+ DispatchQueue.main.async { [weak self] in
+ guard let self, size.width > 0, size.height > 0 else { return }
+ self.videoSize = size
+ self.needsLayout = true
+ }
+ }
+
+ override func layout() {
+ super.layout()
+ let scale = max(bounds.width / videoSize.width, bounds.height / videoSize.height)
+ let size = NSSize(width: videoSize.width * scale, height: videoSize.height * scale)
+ renderer.frame = NSRect(
+ x: (bounds.width - size.width) / 2,
+ y: (bounds.height - size.height) / 2,
+ width: size.width, height: size.height)
+ }
+ }
+#endif
diff --git a/native/apple/Sources/MeetKit/MeetAppearance.swift b/native/apple/Sources/MeetKit/MeetAppearance.swift
new file mode 100644
index 0000000..d23660c
--- /dev/null
+++ b/native/apple/Sources/MeetKit/MeetAppearance.swift
@@ -0,0 +1,7 @@
+import SwiftUI
+
+/// Meet's first native palette; app-specific until a second app adopts it.
+public enum MeetAppearance {
+ public static let accent = Color(red: 0.03, green: 0.49, blue: 0.54)
+ public static let background = Color.primary.opacity(0.035)
+}
diff --git a/native/apple/Sources/MeetKit/MeetLobby.swift b/native/apple/Sources/MeetKit/MeetLobby.swift
new file mode 100644
index 0000000..d824e8c
--- /dev/null
+++ b/native/apple/Sources/MeetKit/MeetLobby.swift
@@ -0,0 +1,105 @@
+import SwiftUI
+
+/// The same joining ceremony on Mac, iPhone and iPad. Authentication stays in the model.
+@MainActor public struct MeetLobby: View {
+ @ObservedObject private var model: MeetModel
+ @Environment(\.colorScheme) private var colorScheme
+
+ public init(model: MeetModel) { self.model = model }
+
+ public var body: some View {
+ ScrollView {
+ VStack(alignment: .leading, spacing: 28) {
+ VStack(alignment: .leading, spacing: 16) {
+ Image(systemName: "video.fill")
+ .font(.largeTitle).foregroundStyle(.tint)
+ .padding(18).background(.tint.opacity(0.1), in: RoundedRectangle(cornerRadius: 22))
+ .accessibilityHidden(true)
+ Text("OFICINA MEET").font(.caption.bold()).tracking(2).foregroundStyle(.secondary)
+ Text(heading).font(.largeTitle.bold()).fixedSize(horizontal: false, vertical: true)
+ Text(subheading).foregroundStyle(.secondary).fixedSize(horizontal: false, vertical: true)
+ }
+ VStack(alignment: .leading, spacing: 20) {
+ if model.authenticating {
+ ProgressView("Waiting for sign-in")
+ if !model.confirmationCode.isEmpty {
+ VStack(alignment: .leading, spacing: 10) {
+ Text(model.confirmationCode).font(.title.monospaced().bold())
+ .textSelection(.enabled)
+ Text("Check that your browser shows this same code before confirming.")
+ .font(.callout).foregroundStyle(.secondary)
+ }.frame(maxWidth: .infinity, alignment: .leading).padding(16)
+ .background(.quaternary, in: RoundedRectangle(cornerRadius: 12))
+ }
+ if let url = model.signin {
+ Button("Open browser again", systemImage: "arrow.up.forward.app") {
+ Task { _ = await model.openBrowser(url) }
+ }.buttonStyle(.borderedProminent)
+ }
+ Button("Cancel", action: model.cancelSignIn).buttonStyle(.bordered)
+ } else if model.signedIn {
+ Label("Meeting \(model.room)", systemImage: "video")
+ .font(.headline).textSelection(.enabled)
+ Label("Your camera and microphone will be requested when you join.", systemImage: "mic")
+ .font(.callout).foregroundStyle(.secondary)
+ Button("Join meeting", systemImage: "video.fill", action: model.join)
+ .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
+ Button("Choose another meeting") { model.signedIn = false }
+ .buttonStyle(.bordered)
+ #if os(iOS)
+ Text("Keep Meet open during your call. Switching to another app ends the call.")
+ .font(.footnote).foregroundStyle(.secondary)
+ #endif
+ } else {
+ VStack(alignment: .leading, spacing: 10) {
+ Text("Meeting link").font(.headline)
+ TextField("Meeting or invitation link", text: $model.address)
+ .textFieldStyle(.roundedBorder)
+ .accessibilityLabel("Meeting link").accessibilityIdentifier("meetingLink")
+ #if os(iOS)
+ .keyboardType(.URL).textInputAutocapitalization(.never).autocorrectionDisabled()
+ #endif
+ .onSubmit(model.connect)
+ }
+ Button("Continue", systemImage: "arrow.right", action: model.connect)
+ .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction)
+ .disabled(model.address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ Text("Use the meeting or invitation link shared with you.")
+ .font(.callout).foregroundStyle(.secondary)
+ }
+ if !model.error.isEmpty {
+ Label(model.error, systemImage: "exclamationmark.circle.fill")
+ .foregroundStyle(.red).fixedSize(horizontal: false, vertical: true)
+ .accessibilityLabel("Error: \(model.error)")
+ }
+ }
+ .controlSize(.large).frame(maxWidth: .infinity, alignment: .leading).padding(24)
+ .background(
+ colorScheme == .dark ? Color.white.opacity(0.045) : Color.white,
+ in: RoundedRectangle(cornerRadius: 20)
+ )
+ .overlay { RoundedRectangle(cornerRadius: 20).strokeBorder(.primary.opacity(0.08)) }
+ Label("A little closer, wherever you are.", systemImage: "sparkle")
+ .font(.callout).foregroundStyle(.secondary)
+ }.frame(maxWidth: 480, alignment: .leading).padding(32)
+ .frame(maxWidth: .infinity, minHeight: 540)
+ }
+ .background(MeetAppearance.background)
+ .tint(MeetAppearance.accent)
+ }
+
+ private var heading: LocalizedStringKey {
+ if model.authenticating { return "One more step." }
+ if model.signedIn { return "Ready to join" }
+ return "Meet face to face."
+ }
+
+ private var subheading: LocalizedStringKey {
+ if model.authenticating {
+ return
+ "Open the emailed link in your browser, then confirm “Sign in to Meet app”. We’ll continue here automatically."
+ }
+ if model.signedIn { return "You’re signed in. Join when you’re ready." }
+ return "A place for your team to come together."
+ }
+}
diff --git a/native/apple/Sources/MeetKit/MeetingShareButton.swift b/native/apple/Sources/MeetKit/MeetingShareButton.swift
index cefebe8..2038ef0 100644
--- a/native/apple/Sources/MeetKit/MeetingShareButton.swift
+++ b/native/apple/Sources/MeetKit/MeetingShareButton.swift
@@ -14,7 +14,7 @@ import SwiftUI
public var body: some View {
if call.standing == "member" {
- Button("Share") { showing = true }
+ Button("Share", systemImage: "square.and.arrow.up") { showing = true }
.accessibilityLabel("Share meeting link")
.sheet(isPresented: $showing) {
MeetingInvitationView(call: call, close: { showing = false })
diff --git a/native/apple/Sources/MeetMac/MeetMac.swift b/native/apple/Sources/MeetMac/MeetMac.swift
index 8967035..54d21c1 100644
--- a/native/apple/Sources/MeetMac/MeetMac.swift
+++ b/native/apple/Sources/MeetMac/MeetMac.swift
@@ -11,14 +11,16 @@ import WebRTC
Window("Meet", id: "main") {
ContentView(model: model).frame(minWidth: 760, minHeight: 540).onDisappear { model.leave() }
}
+ .defaultSize(width: 1000, height: 760)
.commands { CommandGroup(replacing: .newItem) {} }
}
}
final class MeetAppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ notification: Notification) {
- if let url = Bundle.main.url(forResource: "AppIcon", withExtension: "png")
- ?? Bundle.module.url(forResource: "AppIcon", withExtension: "png") {
+ if let url = Bundle.main.url(forResource: "MacIcon", withExtension: "png")
+ ?? Bundle.module.url(forResource: "MacIcon", withExtension: "png")
+ {
NSApp.applicationIconImage = NSImage(contentsOf: url)
}
}
@@ -27,131 +29,12 @@ final class MeetAppDelegate: NSObject, NSApplicationDelegate {
struct ContentView: View {
@ObservedObject var model: MeetModel
var body: some View {
- VStack(alignment: .leading, spacing: 20) {
+ Group {
if model.joining {
- CallView(call: model.call, leave: model.leave)
- } else if model.authenticating {
- Text("Finish signing in in your browser").font(.title2)
- Text(
- "Open the emailed link normally, then confirm “Sign in to Meet app”. Meet will continue here automatically."
- ).foregroundStyle(.secondary)
- ProgressView()
- if !model.confirmationCode.isEmpty {
- Text(model.confirmationCode).font(.title.monospaced()).textSelection(.enabled)
- Text("Check that your browser shows this same code before confirming.")
- }
- if let url = model.signin {
- Button("Open browser again") { NSWorkspace.shared.open(url) }
- }
- Button("Cancel", action: model.cancelSignIn)
- Spacer()
- } else if model.signedIn {
- Text("Ready to join").font(.largeTitle)
- Text("Your camera and microphone will be requested when you join.")
- Text("Meeting \(model.room)").foregroundStyle(.secondary)
- Button("Join meeting", action: model.join).buttonStyle(.borderedProminent)
- Button("Choose another meeting") { model.signedIn = false }
+ MacCallView(call: model.call, leave: model.leave).padding(24)
} else {
- Text("Meet").font(.largeTitle.bold())
- Text("Join your team’s meeting from your Mac.").foregroundStyle(.secondary)
- Text("Meeting link").font(.headline)
- TextField("Meeting or invitation link", text: $model.address).textFieldStyle(.roundedBorder)
- .onSubmit(model.connect)
- Button("Continue", action: model.connect).buttonStyle(.borderedProminent)
- Spacer()
+ MeetLobby(model: model)
}
- if !model.error.isEmpty {
- Text(model.error).foregroundStyle(.red).accessibilityLabel("Error: \(model.error)")
- }
- }.padding(24)
- }
-}
-struct CallView: View {
- @ObservedObject var call: Call
- let leave: () -> Void
- var body: some View {
- VStack(alignment: .leading, spacing: 16) {
- HStack {
- Text(call.title).font(.title.bold())
- Spacer()
- Text(call.status).accessibilityAddTraits(.updatesFrequently)
- }
- Text(call.standing.capitalized).font(.callout).foregroundStyle(.secondary)
- if call.floor {
- Text("Direct connections only. People on some networks may be unable to connect.").font(
- .callout
- ).foregroundStyle(.secondary)
- }
- ScrollView {
- LazyVGrid(columns: [GridItem(.adaptive(minimum: 260))], spacing: 16) {
- if let local = call.localTrack { VideoTile(track: local, label: "You", mirrored: true) }
- ForEach(call.state?.peers.filter { $0.peer != call.state?.peer } ?? []) { peer in
- if let track = call.remoteTracks[peer.peer] {
- VideoTile(track: track, label: peer.name)
- } else {
- VStack {
- ProgressView()
- Text(peer.name)
- }.frame(height: 180)
- }
- }
- }
- }
- ForEach(call.state?.waiting ?? []) { peer in
- HStack {
- Text("\(peer.name) is waiting")
- Button("Let in") { call.admit(peer.peer) }
- }
- }
- if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } }
- HStack {
- Button(call.muted ? "Unmute microphone" : "Mute microphone") { call.mute() }
- Button(call.cameraOff ? "Turn camera on" : "Turn camera off") { call.toggleCamera() }
- MeetingShareButton(call: call)
- Spacer()
- Button("Leave meeting", role: .destructive, action: leave)
- }
- Text("Chat").font(.headline)
- ChatView(call: call).frame(minHeight: 120, maxHeight: 180)
- }
- }
-}
-struct VideoTile: View {
- let track: RTCVideoTrack
- let label: String
- var mirrored = false
- var body: some View {
- VStack(alignment: .leading) {
- VideoView(track: track).scaleEffect(x: mirrored ? -1 : 1, y: 1).frame(height: 200).background(
- .black
- ).clipShape(
- RoundedRectangle(cornerRadius: 12))
- Text(label).font(.headline)
- }.accessibilityElement(children: .combine)
- }
-}
-struct VideoView: NSViewRepresentable {
- let track: RTCVideoTrack
- func makeCoordinator() -> Coordinator { Coordinator() }
- func makeNSView(context: Context) -> RTCMTLNSVideoView {
- let view = RTCMTLNSVideoView(frame: .zero)
- context.coordinator.attach(track, to: view)
- return view
- }
- func updateNSView(_ view: RTCMTLNSVideoView, context: Context) {
- context.coordinator.attach(track, to: view)
- }
- static func dismantleNSView(_ view: RTCMTLNSVideoView, coordinator: Coordinator) {
- coordinator.track?.remove(view)
- }
- final class Coordinator {
- var track: RTCVideoTrack?
- func attach(_ next: RTCVideoTrack, to view: RTCMTLNSVideoView) {
- if track !== next {
- track?.remove(view)
- track = next
- next.add(view)
- }
- }
+ }.tint(MeetAppearance.accent)
}
}
diff --git a/native/apple/Sources/MeetMac/Resources/MacIcon.png b/native/apple/Sources/MeetMac/Resources/MacIcon.png
new file mode 100644
index 0000000..a4b6202
Binary files /dev/null and b/native/apple/Sources/MeetMac/Resources/MacIcon.png differ
diff --git a/native/apple/Tests/MeetKitTests/ChatWindowTests.swift b/native/apple/Tests/MeetKitTests/ChatWindowTests.swift
new file mode 100644
index 0000000..e346caf
--- /dev/null
+++ b/native/apple/Tests/MeetKitTests/ChatWindowTests.swift
@@ -0,0 +1,36 @@
+import AppKit
+import XCTest
+
+@testable import MeetKit
+
+final class ChatWindowTests: XCTestCase {
+ @MainActor func testClosingAndReopeningRetainsDraftWithoutDuplicatingWindow() {
+ NSApplication.shared.setActivationPolicy(.prohibited)
+ let call = Call(playAudio: false)
+ let presentation = MacChatWindow()
+ presentation.draft.text = "Keep this unsent message"
+ presentation.open(call: call)
+ XCTAssertTrue(presentation.isOpen)
+ let first = NSApp.windows.first { $0.title == "Chat — Meet" }
+ XCTAssertNotNil(first)
+ presentation.open(call: call)
+ XCTAssertEqual(NSApp.windows.filter { $0.title == "Chat — Meet" }.count, 1)
+ presentation.close()
+ XCTAssertFalse(presentation.isOpen)
+ XCTAssertEqual(presentation.draft.text, "Keep this unsent message")
+ presentation.open(call: call)
+ XCTAssertTrue(presentation.isOpen)
+ XCTAssertEqual(presentation.draft.text, "Keep this unsent message")
+ presentation.close()
+ }
+
+ @MainActor func testWindowCloseButtonUpdatesPresentationState() {
+ NSApplication.shared.setActivationPolicy(.prohibited)
+ let presentation = MacChatWindow()
+ presentation.open(call: Call(playAudio: false))
+ let window = NSApp.windows.first { $0.title == "Chat — Meet" }
+ XCTAssertNotNil(window)
+ window?.performClose(nil)
+ XCTAssertFalse(presentation.isOpen)
+ }
+}
diff --git a/native/ios/Sources/MeetPhone.swift b/native/ios/Sources/MeetPhone.swift
index f273899..adf6cdb 100644
--- a/native/ios/Sources/MeetPhone.swift
+++ b/native/ios/Sources/MeetPhone.swift
@@ -29,49 +29,9 @@ struct PhoneHome: View {
if model.joining {
PhoneCall(call: model.call, leave: model.leave)
} else {
- ScrollView {
- VStack(alignment: .leading, spacing: 24) {
- if model.authenticating {
- Text("Finish signing in in your browser").font(.title2.bold())
- Text(
- "Open the emailed link normally. Compare this code with your browser before approving sign-in, then return to Meet."
- )
- if !model.confirmationCode.isEmpty {
- Text(model.confirmationCode).font(.largeTitle.monospaced()).textSelection(
- .enabled)
- }
- ProgressView("Waiting for sign-in")
- if let url = model.signin {
- Button("Open browser again") { Task { _ = await model.openBrowser(url) } }
- }
- Button("Cancel", action: model.cancelSignIn)
- } else if model.signedIn {
- Text("Ready to join").font(.largeTitle.bold())
- Text("Your front camera and microphone will be requested when you join.")
- Text("Meeting \(model.room)").foregroundStyle(.secondary)
- Button("Join meeting", action: model.join).buttonStyle(.borderedProminent)
- Button("Choose another meeting") { model.signedIn = false }
- Text("Keep Meet open during your call. Switching to another app ends the call.")
- .font(.footnote).foregroundStyle(.secondary)
- } else {
- Text("Meet face to face").font(.largeTitle.bold())
- Text("Join your team’s meeting from your iPhone.").foregroundStyle(.secondary)
- VStack(alignment: .leading, spacing: 8) {
- Text("Meeting link").font(.headline)
- TextField("Meeting or invitation link", text: $model.address)
- .textFieldStyle(.roundedBorder).keyboardType(.URL)
- .textInputAutocapitalization(.never).autocorrectionDisabled()
- .accessibilityIdentifier("meetingLink").onSubmit(model.connect)
- }
- Button("Continue", action: model.connect).buttonStyle(.borderedProminent)
- }
- if !model.error.isEmpty {
- Text(model.error).foregroundStyle(.red).accessibilityLabel("Error: \(model.error)")
- }
- }.frame(maxWidth: 600, alignment: .leading).padding(24)
- }
+ MeetLobby(model: model)
}
- }.navigationTitle("Meet").navigationBarTitleDisplayMode(.inline)
+ }.tint(MeetAppearance.accent).navigationTitle("Meet").navigationBarTitleDisplayMode(.inline)
}
}
}
@@ -81,91 +41,168 @@ struct PhoneCall: View {
let leave: () -> Void
@State private var showingChat = false
@State private var readChatCount = 0
+ @State private var grid = false
+ @State private var selectedPeer: String?
+ @StateObject private var draft = ChatDraft()
+
+ private var peers: [RosterPeer] { call.state?.peers.filter { $0.peer != call.state?.peer } ?? [] }
+ private var focused: RosterPeer? { peers.first { $0.peer == selectedPeer } ?? peers.first }
+
var body: some View {
- ScrollView {
- VStack(alignment: .leading, spacing: 16) {
- Text(call.title).font(.title2.bold())
- Text(call.status).accessibilityAddTraits(.updatesFrequently)
- Text(call.standing.capitalized).foregroundStyle(.secondary)
- if call.floor {
- Text("Direct connections only. Some networks may be unable to connect.").font(.footnote)
- }
- if let local = call.localTrack {
- PhoneVideoTile(track: local, label: "You", mirrored: true)
+ VStack(spacing: 12) {
+ HStack {
+ VStack(alignment: .leading, spacing: 4) {
+ Text(call.title).font(.headline).lineLimit(2)
+ Text(call.status).font(.caption).foregroundStyle(.secondary)
}
- ForEach(call.state?.peers.filter { $0.peer != call.state?.peer } ?? []) { peer in
- if let track = call.remoteTracks[peer.peer] {
- PhoneVideoTile(track: track, label: peer.name)
- } else {
- HStack {
- ProgressView()
- Text(peer.name)
- }.frame(minHeight: 80)
+ Spacer()
+ Menu {
+ Picker("Video layout", selection: $grid) {
+ Label("Focus", systemImage: "rectangle.inset.bottomtrailing").tag(false)
+ Label("Grid", systemImage: "square.grid.2x2").tag(true)
+ }
+ if peers.count > 1 {
+ Section("Focus on") {
+ ForEach(peers) { peer in
+ Button(peer.name) {
+ selectedPeer = peer.peer
+ grid = false
+ }
+ }
+ }
+ }
+ } label: {
+ Label(
+ "Video layout", systemImage: grid ? "square.grid.2x2" : "rectangle.inset.bottomtrailing"
+ )
+ .labelStyle(.iconOnly).frame(width: 44, height: 44)
+ }.accessibilityIdentifier("videoLayout")
+ }
+ if call.floor {
+ Text("Direct connections only. Some networks may be unable to connect.")
+ .font(.footnote).foregroundStyle(.secondary)
+ }
+ if grid {
+ ScrollView {
+ LazyVGrid(columns: [GridItem(.adaptive(minimum: 240))], spacing: 12) {
+ PhoneVideoTile(
+ track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff
+ )
+ .aspectRatio(4 / 3, contentMode: .fit)
+ ForEach(peers) { peer in
+ PhoneVideoTile(track: call.remoteTracks[peer.peer], label: peer.name)
+ .aspectRatio(4 / 3, contentMode: .fit)
+ }
}
}
- ForEach(call.state?.waiting ?? []) { person in
- HStack {
- Text("\(person.name) is waiting")
- Spacer()
- Button("Let in") { call.admit(person.peer) }
+ } else {
+ GeometryReader { geometry in
+ ZStack(alignment: .bottomTrailing) {
+ if let peer = focused {
+ PhoneVideoTile(track: call.remoteTracks[peer.peer], label: peer.name)
+ } else {
+ ContentUnavailableView(
+ "Waiting for others", systemImage: "person.2",
+ description: Text("Others will appear here when they join.")
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .background(MeetAppearance.background, in: RoundedRectangle(cornerRadius: 18))
+ }
+ PhoneVideoTile(
+ track: call.localTrack, label: "You", mirrored: true, cameraOff: call.cameraOff
+ )
+ .frame(
+ width: min(160, geometry.size.width * 0.32),
+ height: min(120, geometry.size.height * 0.28)
+ )
+ .shadow(color: .black.opacity(0.25), radius: 6, y: 2).padding(12)
}
}
- if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } }
- }.frame(maxWidth: 700).padding()
- }.safeAreaInset(edge: .bottom) {
- VStack(spacing: 12) {
- HStack {
- Button(chatButtonTitle) {
+ }
+ if !(call.state?.waiting.isEmpty ?? true) {
+ ScrollView {
+ ForEach(call.state?.waiting ?? []) { person in
+ HStack {
+ Text("\(person.name) is waiting")
+ Spacer()
+ Button("Let in") { call.admit(person.peer) }
+ }
+ }
+ }.frame(maxHeight: 90)
+ }
+ if call.state?.phase == "full" { Button("Try joining again") { call.retryJoin() } }
+ }.padding(.horizontal, 16).padding(.top, 8)
+ .safeAreaInset(edge: .bottom) {
+ HStack(spacing: 14) {
+ Button(
+ call.muted ? "Unmute microphone" : "Mute microphone",
+ systemImage: call.muted ? "mic.slash.fill" : "mic.fill"
+ ) { call.mute() }
+ .buttonStyle(
+ CallActionStyle(
+ color: call.muted ? Color(red: 0.65, green: 0.30, blue: 0.02) : MeetAppearance.accent,
+ emphasized: call.muted))
+ Button(
+ call.cameraOff ? "Turn camera on" : "Turn camera off",
+ systemImage: call.cameraOff ? "video.slash.fill" : "video.fill"
+ ) { call.toggleCamera() }
+ .buttonStyle(CallActionStyle(color: .blue, emphasized: call.cameraOff))
+ MeetingShareButton(call: call).buttonStyle(CallActionStyle(color: .indigo))
+ Spacer(minLength: 0)
+ Button(chatButtonTitle, systemImage: "bubble.left.and.bubble.right.fill") {
readChatCount = call.receivedChatCount
showingChat = true
- }.buttonStyle(.bordered).accessibilityIdentifier("openChat")
- MeetingShareButton(call: call).buttonStyle(.bordered)
- }
- ViewThatFits(in: .horizontal) {
- HStack { mediaControls }
- VStack(alignment: .leading) { mediaControls }
- }
- Button("Leave meeting", role: .destructive, action: leave).buttonStyle(.bordered)
- }.frame(maxWidth: .infinity).padding().background(.bar)
- }
- .sheet(isPresented: $showingChat, onDismiss: { readChatCount = call.receivedChatCount }) {
- NavigationStack {
- ChatView(call: call).padding()
- .navigationTitle("Chat").navigationBarTitleDisplayMode(.inline)
- .toolbar {
- ToolbarItem(placement: .confirmationAction) {
- Button("Done") { showingChat = false }
+ }.buttonStyle(CallActionStyle(color: .purple)).accessibilityIdentifier("openChat")
+ .overlay(alignment: .topTrailing) {
+ if unread > 0 {
+ Text("\(unread)").font(.caption.bold()).foregroundStyle(.white)
+ .padding(4).background(.purple, in: Capsule()).accessibilityHidden(true)
+ }
}
- }
- }.presentationDetents([.medium, .large])
- }
- .onChange(of: call.receivedChatCount) { _, count in
- if showingChat { readChatCount = count }
- }
- }
- @ViewBuilder private var mediaControls: some View {
- Button(call.muted ? "Unmute microphone" : "Mute microphone") { call.mute() }.buttonStyle(
- .bordered)
- Button(call.cameraOff ? "Turn camera on" : "Turn camera off") { call.toggleCamera() }
- .buttonStyle(.bordered)
- }
- private var chatButtonTitle: String {
- let unread = max(0, call.receivedChatCount - readChatCount)
- return unread == 0 ? "Chat" : "Chat (\(unread) new)"
+ Button("Leave meeting", systemImage: "phone.down.fill", role: .destructive, action: leave)
+ .buttonStyle(CallActionStyle(color: .red, emphasized: true))
+ }.padding(16).background(.bar)
+ }
+ .sheet(isPresented: $showingChat, onDismiss: { readChatCount = call.receivedChatCount }) {
+ NavigationStack {
+ ChatView(call: call, draft: draft).padding()
+ .navigationTitle("Chat").navigationBarTitleDisplayMode(.inline)
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) { Button("Done") { showingChat = false } }
+ }
+ }.presentationDetents([.medium, .large])
+ }
+ .onChange(of: call.receivedChatCount) { _, count in
+ if showingChat { readChatCount = count }
+ }
}
+ private var unread: Int { max(0, call.receivedChatCount - readChatCount) }
+ private var chatButtonTitle: String { unread == 0 ? "Chat" : "Chat (\(unread) new)" }
}
struct PhoneVideoTile: View {
- let track: RTCVideoTrack
+ let track: RTCVideoTrack?
let label: String
var mirrored = false
+ var cameraOff = false
var body: some View {
- VStack(alignment: .leading) {
- PhoneVideo(track: track)
- .scaleEffect(x: mirrored ? -1 : 1, y: 1)
- .frame(height: 220).background(.black).clipShape(RoundedRectangle(cornerRadius: 12))
- Text(label).font(.headline)
- }.accessibilityElement(children: .combine)
+ ZStack {
+ Color.black
+ if let track, !cameraOff {
+ PhoneVideo(track: track).scaleEffect(x: mirrored ? -1 : 1, y: 1)
+ } else {
+ Image(systemName: cameraOff ? "video.slash" : "person.crop.circle")
+ .font(.largeTitle).foregroundStyle(.white.opacity(0.8))
+ }
+ }
+ .overlay(alignment: .bottomLeading) {
+ Text(label).font(.callout.bold()).foregroundStyle(.white).lineLimit(1)
+ .padding(.horizontal, 10).padding(.vertical, 6)
+ .background(.black.opacity(0.65), in: Capsule()).padding(8)
+ }
+ .clipShape(RoundedRectangle(cornerRadius: 18))
+ .accessibilityElement(children: .ignore).accessibilityLabel(label)
+ .accessibilityValue(cameraOff ? "Camera off" : track == nil ? "Waiting for video" : "Video on")
}
}
struct PhoneVideo: UIViewRepresentable {
@@ -173,7 +210,7 @@ struct PhoneVideo: UIViewRepresentable {
func makeCoordinator() -> Coordinator { Coordinator() }
func makeUIView(context: Context) -> RTCMTLVideoView {
let view = RTCMTLVideoView(frame: .zero)
- view.videoContentMode = .scaleAspectFit
+ view.videoContentMode = .scaleAspectFill
context.coordinator.attach(track, to: view)
return view
}
diff --git a/native/scripts/build-mac.sh b/native/scripts/build-mac.sh
index 34b731e..841baed 100755
--- a/native/scripts/build-mac.sh
+++ b/native/scripts/build-mac.sh
@@ -2,6 +2,7 @@
set -eu
cd "$(dirname "$0")/.."
./scripts/build-bridge.sh
+swift scripts/generate-mac-icon.swift
swift build --package-path apple
python3 scripts/bundle-mac.py
codesign --force --deep --sign - build/Meet.app
diff --git a/native/scripts/bundle-mac.py b/native/scripts/bundle-mac.py
index 12eb118..52d2e6f 100644
--- a/native/scripts/bundle-mac.py
+++ b/native/scripts/bundle-mac.py
@@ -10,8 +10,9 @@ bundle = root / 'build/Meet.app/Contents'
(bundle / 'Frameworks').mkdir(parents=True, exist_ok=True)
(bundle / 'Resources').mkdir(parents=True, exist_ok=True)
shutil.copy2(root / 'apple/.build/debug/MeetMac', bundle / 'MacOS/MeetMac')
-icon = root / 'apple/Sources/MeetMac/Resources/AppIcon.png'
-shutil.copy2(icon, bundle / 'Resources/AppIcon.png')
+subprocess.run(['swift', str(root / 'scripts/generate-mac-icon.swift')], check=True)
+icon = root / 'apple/Sources/MeetMac/Resources/MacIcon.png'
+shutil.copy2(icon, bundle / 'Resources/MacIcon.png')
shutil.copy2(root / 'LICENSE.lucide', bundle / 'Resources/LICENSE.lucide')
# Finder reads the ICNS before the app launches; the PNG also supplies the
# runtime Dock icon. Generate each required representation from the master.
@@ -24,12 +25,12 @@ for size in (16, 32, 128, 256, 512):
str(icon), '--out', str(iconset / name)],
check=True, stdout=subprocess.DEVNULL)
subprocess.run(['iconutil', '-c', 'icns', str(iconset), '-o',
- str(bundle / 'Resources/AppIcon.icns')], check=True)
+ str(bundle / 'Resources/MeetMac.icns')], check=True)
info = {
'CFBundleName': 'Meet', 'CFBundleDisplayName': 'Meet',
'CFBundleIdentifier': 'net.amadan.oficina.meet',
'CFBundleExecutable': 'MeetMac', 'CFBundlePackageType': 'APPL',
- 'CFBundleIconFile': 'AppIcon.icns',
+ 'CFBundleIconFile': 'MeetMac.icns',
'CFBundleShortVersionString': '0.1.0', 'CFBundleVersion': '1',
'LSMinimumSystemVersion': '14.0', 'NSHighResolutionCapable': True,
'NSCameraUsageDescription': 'Meet uses your camera to share video in a meeting.',
diff --git a/native/scripts/generate-mac-icon.swift b/native/scripts/generate-mac-icon.swift
new file mode 100644
index 0000000..37de2dc
--- /dev/null
+++ b/native/scripts/generate-mac-icon.swift
@@ -0,0 +1,24 @@
+import AppKit
+
+// macOS displays the supplied silhouette; unlike iOS it does not mask an opaque master.
+// Keep the approved square master intact and derive a padded Mac icon from it.
+let native = URL(fileURLWithPath: #filePath).standardizedFileURL
+ .deletingLastPathComponent().deletingLastPathComponent()
+let resources = native.appendingPathComponent("apple/Sources/MeetMac/Resources")
+guard let source = NSImage(contentsOf: resources.appendingPathComponent("AppIcon.png")),
+ let bitmap = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: 1024, pixelsHigh: 1024,
+ bitsPerSample: 8, samplesPerPixel: 4, hasAlpha: true, isPlanar: false,
+ colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0),
+ let context = NSGraphicsContext(bitmapImageRep: bitmap)
+else { fatalError("Cannot load or render Meet's icon") }
+NSGraphicsContext.saveGraphicsState()
+NSGraphicsContext.current = context
+context.imageInterpolation = .high
+let tile = NSRect(x: 100, y: 100, width: 824, height: 824)
+NSBezierPath(roundedRect: tile, xRadius: 184, yRadius: 184).addClip()
+source.draw(in: tile, from: .zero, operation: .copy, fraction: 1)
+NSGraphicsContext.restoreGraphicsState()
+guard let png = bitmap.representation(using: .png, properties: [:]) else {
+ fatalError("Cannot encode Meet's icon")
+}
+try png.write(to: resources.appendingPathComponent("MacIcon.png"))