Integrated token request using an application server Downgraded features for adapting others tutorials ios: Updated assets and remove unncessary code Updated schemas and app name Added configure urls view and refactored code ios: Refactored code removed broadcastExt renamed project to OpenViduIOS refactored code Updated Readme removed connection time element Added participants name and moved leave room button to topbar Refactored code Update README.md Renamed and improve project structure
386 lines
13 KiB
Swift
386 lines
13 KiB
Swift
/*
|
|
* 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<Content: View>: View {
|
|
let views: [AnyView]
|
|
let spacing: CGFloat
|
|
|
|
init<Data: RandomAccessCollection>(
|
|
_ data: Data,
|
|
id: KeyPath<Data.Element, Data.Element> = \.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<Content: View>: 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<Content: View>: 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
|
|
}
|
|
}
|