| 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 | |