#if os(macOS) import AppKit import SwiftUI import LiveKitWebRTC struct MacVideoTile: View { let track: RTCVideoTrack? let label: String /// §15.19: the peer has said it is muted. False for unknown. var muted = false var mirrored = false var cameraOff = false /// A shared screen (§5.4). Shown whole rather than cropped to fill: /// the edge of a screen is where its menu bar and its scroll bars are, /// and a face survives losing its margins where a document does not. var screen = false /// Your own tile: what the camera is doing, in place of a picture that /// is not arriving (see `Call.selfPlaceholder`). Nil shows the video. var placeholder: String? = nil /// The full-window call's look: square to the window's edges (or as /// rounded as `cornerRadius` says), a smaller name and muted mark, and /// the name lifted by `labelLift` clear of the controls. var cornerRadius: CGFloat = 16 var compact = false var labelLift: CGFloat = 0 /// What shows behind a picture that is not there, or around one that /// does not fill the tile. A tile among others on the full-window /// stage is a shade lighter than the stage, so it reads as a tile. var backdrop = Color.black /// The sound on this tile (spec §15.20): your microphone's glow, or a /// remote person's with "No sound from" and their notices. Nil for a /// screen. var audio: TileAudio? = nil /// Speaker view's stage: VoiceOver hears "On stage: Ana", and whether /// it was pinned there. var onStage = false var pinned = false /// A filmstrip thumbnail: a picture that is not there is its symbol /// alone, since the words would sit under the name. VoiceOver still /// hears them as the tile's value. var iconOnly = false /// The height of the name label and what is over it, lift included, /// which the placeholder keeps clear of. @State private var labelBlock: CGFloat = 0 @Environment(\.fixtureStagePicture) private var fixturePicture var body: some View { TileAudioReader(audio) { info in tile(info) } } private func tile(_ info: TileAudioInfo?) -> some View { ZStack { backdrop if let fixturePicture, onStage, track == nil, !cameraOff, placeholder == nil { FixtureVideo(picture: fixturePicture, screen: screen) } else if let track, !cameraOff, placeholder == nil { // A screen on the stage is fitted whole; around it, the same // picture soft and dimmed rather than black bars. if screen, onStage { ScreenBackdrop(track: track) } MacVideoView(track: track, fit: screen).scaleEffect(x: mirrored ? -1 : 1, y: 1) } else { TilePlaceholder( symbol: cameraOff ? "video.slash" : screen ? "rectangle.on.rectangle" : "person.crop.circle", words: iconOnly ? nil : waiting, iconFont: iconOnly ? .title2 : .largeTitle, reserve: labelBlock) } } .overlay(alignment: .bottomLeading) { VStack(alignment: .leading, spacing: 6) { TileAudioNotices(info) HStack(spacing: 6) { TileSpeakingName(label: label, muted: muted, compact: compact, glow: audio?.glow) TileAudioStatus(info) } } .padding(compact ? 8 : 10).padding(.bottom, compact ? labelLift : 0) // Room past the label's end for the rim's outer breath, when a long // name has it truncating. .padding(.trailing, 6) .tileLabelBlock($labelBlock) } .overlay(alignment: .topLeading) { if muted, !compact { MutedBadge().padding(10) } } .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) .accessibilityElement(children: .ignore) .accessibilityLabel(stageLabel(tileAccessibilityLabel(label, muted: muted, audio: info), onStage: onStage, pinned: pinned)) .accessibilityHint(info?.detail ?? "") .accessibilityValue(cameraOff ? "Camera off" : track == nil || placeholder != nil ? waiting : "Video on") } private var waiting: String { cameraOff ? "Camera off" : placeholder ?? (screen ? "Waiting for the screen" : "Waiting for video") } } private struct MacVideoView: NSViewRepresentable { let track: RTCVideoTrack var fit = false func makeNSView(context: Context) -> FillVideoView { FillVideoView() } func updateNSView(_ view: FillVideoView, context: Context) { view.fit = fit view.attach(track) } static func dismantleNSView(_ view: FillVideoView, coordinator: ()) { view.detach() } } /// The renderer keeps its source aspect ratio while the container crops /// overflow — or, for a screen, letterboxes it. private final class FillVideoView: NSView, RTCVideoViewDelegate { private let renderer = RTCMTLNSVideoView(frame: .zero) private var track: RTCVideoTrack? private var videoSize = NSSize(width: 16, height: 9) var fit = false { didSet { if fit != oldValue { needsLayout = true } } } override init(frame: NSRect) { super.init(frame: frame) wantsLayer = true layer?.masksToBounds = true renderer.delegate = self addSubview(renderer) } required init?(coder: NSCoder) { nil } // A picture takes no clicks: they go to the SwiftUI view it is in, so // a drag on it moves the window (WindowMove) or the self view, as a // drag on a tile with no picture does, the click that brings the // window forward included. override func hitTest(_ point: NSPoint) -> NSView? { 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() // Bottom-left origin: VideoFrame works top down. let placed = VideoFrame.place(video: videoSize, in: bounds.size, fit: fit) renderer.frame = NSRect( x: placed.minX, y: bounds.height - placed.maxY, width: placed.width, height: placed.height) } } #endif