/* * Copyright 2024 LiveKit * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import LiveKit import SFSafeSymbols import SwiftUI #if !os(macOS) let adaptiveMin = 170.0 let toolbarPlacement: ToolbarItemPlacement = .bottomBar #else let adaptiveMin = 300.0 let toolbarPlacement: ToolbarItemPlacement = .primaryAction #endif extension CIImage { // helper to create a `CIImage` for both platforms convenience init(named name: String) { #if !os(macOS) self.init(cgImage: UIImage(named: name)!.cgImage!) #else self.init(data: NSImage(named: name)!.tiffRepresentation!)! #endif } } #if os(macOS) // keeps weak reference to NSWindow class WindowAccess: ObservableObject { private weak var window: NSWindow? deinit { // reset changed properties DispatchQueue.main.async { [weak window] in window?.level = .normal } } @Published public var pinned: Bool = false { didSet { guard oldValue != pinned else { return } level = pinned ? .floating : .normal } } private var level: NSWindow.Level { get { window?.level ?? .normal } set { Task { @MainActor in window?.level = newValue objectWillChange.send() } } } public func set(window: NSWindow?) { self.window = window Task { @MainActor in objectWillChange.send() } } } #endif struct RoomView: View { @EnvironmentObject var appCtx: AppContext @EnvironmentObject var roomCtx: RoomContext @EnvironmentObject var room: Room @State var isCameraPublishingBusy = false @State var isMicrophonePublishingBusy = false @State var isScreenSharePublishingBusy = false @State private var screenPickerPresented = false @State private var publishOptionsPickerPresented = false @State private var cameraPublishOptions = VideoPublishOptions() #if os(macOS) @ObservedObject private var windowAccess = WindowAccess() #endif func sortedParticipants() -> [Participant] { room.allParticipants.values.sorted { p1, p2 in if p1 is LocalParticipant { return true } if p2 is LocalParticipant { return false } return (p1.joinedAt ?? Date()) < (p2.joinedAt ?? Date()) } } func content(geometry: GeometryProxy) -> some View { VStack { HStack { // Title Text Text(roomCtx.name) .font(.title2) .fontWeight(.bold) .multilineTextAlignment(.center) .foregroundColor(.white) .padding() Spacer() // Pushes the button to the right Button(action: { Task { await roomCtx.disconnect() } }, label: { HStack { Image(systemSymbol: .xmarkCircleFill) .renderingMode(.original) Text("Leave Room") .font(.headline) .fontWeight(.semibold) } .padding(8) .background(Color.red.opacity(0.8)) // Background color for the button .foregroundColor(.white) // Text color .cornerRadius(8) }) //.padding() } // Re-connecting Status if case .connecting = room.connectionState { Text("Re-connecting...") .font(.subheadline) .foregroundColor(.white) .padding() .background(Color.black.opacity(0.6)) .cornerRadius(8) .padding(.bottom) } // Participant layout HorVStack(axis: geometry.isTall ? .vertical : .horizontal, spacing: 5) { Group { ParticipantLayout(sortedParticipants(), spacing: 5) { participant in ParticipantView(participant: participant, videoViewMode: .fill) } } .frame( minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity ) } .padding(5) } } var body: some View { GeometryReader { geometry in content(geometry: geometry) } .toolbar { ToolbarItemGroup(placement: toolbarPlacement) { HStack { Spacer() let isMicrophoneEnabled = room.localParticipant.isMicrophoneEnabled() let isCameraEnabled = room.localParticipant.isCameraEnabled() Button(action: { Task { isCameraPublishingBusy = true defer { Task { @MainActor in isCameraPublishingBusy = false } } do { try await room.localParticipant.setCamera(enabled: !isCameraEnabled) } catch { print("Failed to toggle camera: \(error)") } } }, label: { Image(systemSymbol: isCameraEnabled ? .videoFill : .videoSlashFill) .renderingMode(isCameraEnabled ? .original : .template) }) .disabled(isCameraPublishingBusy) Button(action: { Task { isMicrophonePublishingBusy = true defer { Task { @MainActor in isMicrophonePublishingBusy = false } } do { try await room.localParticipant.setMicrophone(enabled: !isMicrophoneEnabled) } catch { print("Failed to toggle microphone: \(error)") } } }, label: { Image(systemSymbol: isMicrophoneEnabled ? .micFill : .micSlashFill) .renderingMode(isMicrophoneEnabled ? .original : .template) }) .disabled(isMicrophonePublishingBusy) Spacer() } } } } } struct ParticipantLayout: View { let views: [AnyView] let spacing: CGFloat init( _ data: Data, id: KeyPath = \.self, spacing: CGFloat, @ViewBuilder content: @escaping (Data.Element) -> Content ) { self.spacing = spacing views = data.map { AnyView(content($0[keyPath: id])) } } func computeColumn(with geometry: GeometryProxy) -> (x: Int, y: Int) { let sqr = Double(views.count).squareRoot() let r: [Int] = [Int(sqr.rounded()), Int(sqr.rounded(.up))] let c = geometry.isTall ? r : r.reversed() return (x: c[0], y: c[1]) } func grid(axis: Axis, geometry: GeometryProxy) -> some View { ScrollView([axis == .vertical ? .vertical : .horizontal]) { HorVGrid(axis: axis, columns: [GridItem(.flexible())], spacing: spacing) { ForEach(0 ..< views.count, id: \.self) { i in views[i] .aspectRatio(1, contentMode: .fill) } } .padding(axis == .horizontal ? [.leading, .trailing] : [.top, .bottom], max(0, ((axis == .horizontal ? geometry.size.width : geometry.size.height) - ((axis == .horizontal ? geometry.size.height : geometry.size.width) * CGFloat(views.count)) - (spacing * CGFloat(views.count - 1))) / 2)) } } var body: some View { GeometryReader { geometry in if views.isEmpty { EmptyView() } else if geometry.size.width <= 300 { grid(axis: .vertical, geometry: geometry) } else if geometry.size.height <= 300 { grid(axis: .horizontal, geometry: geometry) } else { let verticalWhenTall: Axis = geometry.isTall ? .vertical : .horizontal let horizontalWhenTall: Axis = geometry.isTall ? .horizontal : .vertical switch views.count { // simply return first view case 1: views[0] case 3: HorVStack(axis: verticalWhenTall, spacing: spacing) { views[0] HorVStack(axis: horizontalWhenTall, spacing: spacing) { views[1] views[2] } } case 5: HorVStack(axis: verticalWhenTall, spacing: spacing) { views[0] if geometry.isTall { HStack(spacing: spacing) { views[1] views[2] } HStack(spacing: spacing) { views[3] views[4] } } else { VStack(spacing: spacing) { views[1] views[3] } VStack(spacing: spacing) { views[2] views[4] } } } default: let c = computeColumn(with: geometry) VStack(spacing: spacing) { ForEach(0 ... (c.y - 1), id: \.self) { y in HStack(spacing: spacing) { ForEach(0 ... (c.x - 1), id: \.self) { x in let index = (y * c.x) + x if index < views.count { views[index] } } } } } } } } } } struct HorVStack: View { let axis: Axis let horizontalAlignment: HorizontalAlignment let verticalAlignment: VerticalAlignment let spacing: CGFloat? let content: () -> Content init(axis: Axis = .horizontal, horizontalAlignment: HorizontalAlignment = .center, verticalAlignment: VerticalAlignment = .center, spacing: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content) { self.axis = axis self.horizontalAlignment = horizontalAlignment self.verticalAlignment = verticalAlignment self.spacing = spacing self.content = content } var body: some View { Group { if axis == .vertical { VStack(alignment: horizontalAlignment, spacing: spacing, content: content) } else { HStack(alignment: verticalAlignment, spacing: spacing, content: content) } } } } struct HorVGrid: View { let axis: Axis let spacing: CGFloat? let content: () -> Content let columns: [GridItem] init(axis: Axis = .horizontal, columns: [GridItem], spacing: CGFloat? = nil, @ViewBuilder content: @escaping () -> Content) { self.axis = axis self.spacing = spacing self.columns = columns self.content = content } var body: some View { Group { if axis == .vertical { LazyVGrid(columns: columns, spacing: spacing, content: content) } else { LazyHGrid(rows: columns, spacing: spacing, content: content) } } } } extension GeometryProxy { public var isTall: Bool { size.height > size.width } var isWide: Bool { size.width > size.height } }