oficina / meet Public

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

Plain git — no account needed to clone.

Download

Download this file

1#if os(macOS)
2 import AppKit
3 import SwiftUI
4 import LiveKitWebRTC
5
6 struct MacVideoTile: View {
7 let track: RTCVideoTrack?
8 let label: String
9 /// §15.19: the peer has said it is muted. False for unknown.
10 var muted = false
11 var mirrored = false
12 var cameraOff = false
13 /// A shared screen (§5.4). Shown whole rather than cropped to fill:
14 /// the edge of a screen is where its menu bar and its scroll bars are,
15 /// and a face survives losing its margins where a document does not.
16 var screen = false
17 /// Your own tile: what the camera is doing, in place of a picture that
18 /// is not arriving (see `Call.selfPlaceholder`). Nil shows the video.
19 var placeholder: String? = nil
20 /// The full-window call's look: square to the window's edges (or as
21 /// rounded as `cornerRadius` says), a smaller name and muted mark, and
22 /// the name lifted by `labelLift` clear of the controls.
23 var cornerRadius: CGFloat = 16
24 var compact = false
25 var labelLift: CGFloat = 0
26 /// What shows behind a picture that is not there, or around one that
27 /// does not fill the tile. A tile among others on the full-window
28 /// stage is a shade lighter than the stage, so it reads as a tile.
29 var backdrop = Color.black
30 /// The sound on this tile (spec §15.20): your microphone's glow, or a
31 /// remote person's with "No sound from" and their notices. Nil for a
32 /// screen.
33 var audio: TileAudio? = nil
34 /// Speaker view's stage: VoiceOver hears "On stage: Ana", and whether
35 /// it was pinned there.
36 var onStage = false
37 var pinned = false
38 /// A filmstrip thumbnail: a picture that is not there is its symbol
39 /// alone, since the words would sit under the name. VoiceOver still
40 /// hears them as the tile's value.
41 var iconOnly = false
42
43 /// The height of the name label and what is over it, lift included,
44 /// which the placeholder keeps clear of.
45 @State private var labelBlock: CGFloat = 0
46 @Environment(\.fixtureStagePicture) private var fixturePicture
47
48 var body: some View {
49 TileAudioReader(audio) { info in tile(info) }
50 }
51
52 private func tile(_ info: TileAudioInfo?) -> some View {
53 ZStack {
54 backdrop
55 if let fixturePicture, onStage, track == nil, !cameraOff, placeholder == nil {
56 FixtureVideo(picture: fixturePicture, screen: screen)
57 } else if let track, !cameraOff, placeholder == nil {
58 // A screen on the stage is fitted whole; around it, the same
59 // picture soft and dimmed rather than black bars.
60 if screen, onStage { ScreenBackdrop(track: track) }
61 MacVideoView(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1)
62 } else {
63 TilePlaceholder(
64 symbol: cameraOff
65 ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle",
66 words: iconOnly ? nil : waiting, iconFont: iconOnly ? .title2 : .largeTitle,
67 reserve: labelBlock)
68 }
69 }
70 .overlay(alignment: .bottomLeading) {
71 VStack(alignment: .leading, spacing: 6) {
72 TileAudioNotices(info)
73 HStack(spacing: 6) {
74 TileSpeakingName(label: label, muted: muted, compact: compact, glow: audio?.glow)
75 TileAudioStatus(info)
76 }
77 }
78 .padding(compact ? 8 : 10).padding(.bottom, compact ? labelLift : 0)
79 // Room past the label's end for the rim's outer breath, when a long
80 // name has it truncating.
81 .padding(.trailing, 6)
82 .tileLabelBlock($labelBlock)
83 }
84 .overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(10) } }
85 .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
86 .accessibilityElement(children: .ignore)
87 .accessibilityLabel(stageLabel(tileAccessibilityLabel(label, muted: muted, audio: info), onStage: onStage, pinned: pinned))
88 .accessibilityHint(info?.detail ?? "")
89 .accessibilityValue(cameraOff ? "Camera off" : track == nil || placeholder != nil ? waiting : "Video on")
90 }
91 private var waiting: String {
92 cameraOff
93 ? "Camera off" : placeholder ?? (screen ? "Waiting for the screen" : "Waiting for video")
94 }
95 }
96
97 private struct MacVideoView: NSViewRepresentable {
98 let track: RTCVideoTrack
99 var fit = false
100 func makeNSView(context: Context) -> FillVideoView { FillVideoView() }
101 func updateNSView(_ view: FillVideoView, context: Context) {
102 view.fit = fit
103 view.attach(track)
104 }
105 static func dismantleNSView(_ view: FillVideoView, coordinator: ()) { view.detach() }
106 }
107
108 /// The renderer keeps its source aspect ratio while the container crops
109 /// overflow — or, for a screen, letterboxes it.
110 private final class FillVideoView: NSView, RTCVideoViewDelegate {
111 private let renderer = RTCMTLNSVideoView(frame: .zero)
112 private var track: RTCVideoTrack?
113 private var videoSize = NSSize(width: 16, height: 9)
114 var fit = false {
115 didSet { if fit != oldValue { needsLayout = true } }
116 }
117
118 override init(frame: NSRect) {
119 super.init(frame: frame)
120 wantsLayer = true
121 layer?.masksToBounds = true
122 renderer.delegate = self
123 addSubview(renderer)
124 }
125 required init?(coder: NSCoder) { nil }
126
127 // A picture takes no clicks: they go to the SwiftUI view it is in, so
128 // a drag on it moves the window (WindowMove) or the self view, as a
129 // drag on a tile with no picture does, the click that brings the
130 // window forward included.
131 override func hitTest(_ point: NSPoint) -> NSView? { nil }
132
133 func attach(_ next: RTCVideoTrack) {
134 guard track !== next else { return }
135 detach()
136 track = next
137 next.add(renderer)
138 }
139 func detach() {
140 track?.remove(renderer)
141 track = nil
142 }
143
144 nonisolated func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
145 DispatchQueue.main.async { [weak self] in
146 guard let self, size.width > 0, size.height > 0 else { return }
147 self.videoSize = size
148 self.needsLayout = true
149 }
150 }
151
152 override func layout() {
153 super.layout()
154 // Bottom-left origin: VideoFrame works top down.
155 let placed = VideoFrame.place(video: videoSize, in: bounds.size, fit: fit)
156 renderer.frame = NSRect(
157 x: placed.minX, y: bounds.height - placed.maxY, width: placed.width, height: placed.height)
158 }
159 }
160#endif
161