diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 0000000..d403777 --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,6 @@ +retain_objc_accessible: true +schemes: +- Example (macOS Debug) +targets: +- LiveKitExample (macOS) +workspace: LiveKitExample-dev.xcworkspace diff --git a/.swift-version b/.swift-version new file mode 100644 index 0000000..15df682 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +5.7 # Xcode 14 diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 0000000..54ffea4 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,2 @@ +--exclude Sources/LiveKit/Protos +--header "/*\n * Copyright {year} LiveKit\n *\n * Licensed under the Apache License, Version 2.0 (the \"License\");\n * you may not use this file except in compliance with the License.\n * You may obtain a copy of the License at\n *\n * http://www.apache.org/licenses/LICENSE-2.0\n *\n * Unless required by applicable law or agreed to in writing, software\n * distributed under the License is distributed on an \"AS IS\" BASIS,\n * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n * See the License for the specific language governing permissions and\n * limitations under the License.\n */" diff --git a/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved index ea02141..0e7cf88 100644 --- a/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LiveKitExample-dev.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -11,30 +11,30 @@ } }, { - "package": "Promises", - "repositoryURL": "https://github.com/google/promises.git", + "package": "SFSafeSymbols", + "repositoryURL": "https://github.com/SFSafeSymbols/SFSafeSymbols", "state": { "branch": null, - "revision": "e70e889c0196c76d22759eb50d6a0270ca9f1d9e", - "version": "2.3.1" + "revision": "7cca2d60925876b5953a2cf7341cd80fbeac983c", + "version": "4.1.1" } }, { - "package": "SFSafeSymbols", - "repositoryURL": "https://github.com/SFSafeSymbols/SFSafeSymbols", + "package": "SwiftDocCPlugin", + "repositoryURL": "https://github.com/apple/swift-docc-plugin", "state": { "branch": null, - "revision": "7cca2d60925876b5953a2cf7341cd80fbeac983c", - "version": "4.1.1" + "revision": "26ac5758409154cc448d7ab82389c520fa8a8247", + "version": "1.3.0" } }, { - "package": "WebRTC", - "repositoryURL": "https://github.com/webrtc-sdk/Specs.git", + "package": "SymbolKit", + "repositoryURL": "https://github.com/apple/swift-docc-symbolkit", "state": { "branch": null, - "revision": "4fa8d6d647fc759cdd0265fd413d2f28ea2e0e08", - "version": "114.5735.8" + "revision": "b45d1f2ed151d057b54504d653e0da5552844e34", + "version": "1.0.0" } }, { @@ -54,6 +54,15 @@ "revision": "65e8f29b2d63c4e38e736b25c27b83e012159be8", "version": "1.25.2" } + }, + { + "package": "WebRTC", + "repositoryURL": "https://github.com/livekit/webrtc-xcframework.git", + "state": { + "branch": null, + "revision": "da80ea5be0a2b92ca805ab7ee9ad191f6d938a5f", + "version": "114.5735.10" + } } ] }, diff --git a/LiveKitExample.xcodeproj/project.pbxproj b/LiveKitExample.xcodeproj/project.pbxproj index 04f65ff..6d53879 100644 --- a/LiveKitExample.xcodeproj/project.pbxproj +++ b/LiveKitExample.xcodeproj/project.pbxproj @@ -7,10 +7,16 @@ objects = { /* Begin PBXBuildFile section */ + 2C13AA9E2B5F886700E7BB18 /* FakeAudioProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C13AA9D2B5F886700E7BB18 /* FakeAudioProcessor.swift */; }; + 2C13AA9F2B5F886700E7BB18 /* FakeAudioProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C13AA9D2B5F886700E7BB18 /* FakeAudioProcessor.swift */; }; + 2C8910302B640DA60058BECE /* AudioProcessorOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C89102F2B640DA60058BECE /* AudioProcessorOptionsView.swift */; }; + 2C8910312B640DA60058BECE /* AudioProcessorOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C89102F2B640DA60058BECE /* AudioProcessorOptionsView.swift */; }; 680FE2F227A8EF7700B6F6DB /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = 680FE2F127A8EF7700B6F6DB /* SFSafeSymbols */; }; 680FE2F427A8EFF700B6F6DB /* SFSafeSymbols in Frameworks */ = {isa = PBXBuildFile; productRef = 680FE2F327A8EFF700B6F6DB /* SFSafeSymbols */; }; - 6816B1A8272D45DF005ADB85 /* ExampleObservableRoom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6816B1A7272D45DF005ADB85 /* ExampleObservableRoom.swift */; }; - 6816B1A9272D45DF005ADB85 /* ExampleObservableRoom.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6816B1A7272D45DF005ADB85 /* ExampleObservableRoom.swift */; }; + 6816968E2AF96240008ED486 /* Participant+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6816968D2AF96240008ED486 /* Participant+Helpers.swift */; }; + 6816968F2AF96240008ED486 /* Participant+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6816968D2AF96240008ED486 /* Participant+Helpers.swift */; }; + 6816B1A8272D45DF005ADB85 /* ExampleRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6816B1A7272D45DF005ADB85 /* ExampleRoomMessage.swift */; }; + 6816B1A9272D45DF005ADB85 /* ExampleRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6816B1A7272D45DF005ADB85 /* ExampleRoomMessage.swift */; }; 6816B1B0272D9198005ADB85 /* ParticipantView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6816B1AF272D9198005ADB85 /* ParticipantView.swift */; }; 6816B1B1272D9198005ADB85 /* ParticipantView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6816B1AF272D9198005ADB85 /* ParticipantView.swift */; }; 681A0AB727D888D80097E3F4 /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = 681A0AB627D888D80097E3F4 /* LiveKit */; }; @@ -31,6 +37,8 @@ 6847616527B44A1A001611BE /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6847616327B44A1A001611BE /* Bundle.swift */; }; 6867533B27A65652003707B9 /* AppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6867533A27A65652003707B9 /* AppContext.swift */; }; 6867533C27A65652003707B9 /* AppContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6867533A27A65652003707B9 /* AppContext.swift */; }; + 687230F82B14AE0A0098CCE6 /* PublishOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 687230F72B14AE0A0098CCE6 /* PublishOptionsView.swift */; }; + 687230F92B14AE0A0098CCE6 /* PublishOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 687230F72B14AE0A0098CCE6 /* PublishOptionsView.swift */; }; 68816CC127B4D6BC00E24622 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 68816CC027B4D6BC00E24622 /* KeychainAccess */; }; 68816CC327B4D94200E24622 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = 68816CC227B4D94200E24622 /* KeychainAccess */; }; 68816CC527B4DCD500E24622 /* SecureStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68816CC427B4DCD500E24622 /* SecureStore.swift */; }; @@ -72,7 +80,10 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 6816B1A7272D45DF005ADB85 /* ExampleObservableRoom.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleObservableRoom.swift; sourceTree = ""; }; + 2C13AA9D2B5F886700E7BB18 /* FakeAudioProcessor.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FakeAudioProcessor.swift; sourceTree = ""; }; + 2C89102F2B640DA60058BECE /* AudioProcessorOptionsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AudioProcessorOptionsView.swift; sourceTree = ""; }; + 6816968D2AF96240008ED486 /* Participant+Helpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Participant+Helpers.swift"; sourceTree = ""; }; + 6816B1A7272D45DF005ADB85 /* ExampleRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleRoomMessage.swift; sourceTree = ""; }; 6816B1AF272D9198005ADB85 /* ParticipantView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ParticipantView.swift; sourceTree = ""; }; 681E3F38271FC772007BB547 /* RoomContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomContext.swift; sourceTree = ""; }; 681E3F3E271FC795007BB547 /* Custom.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Custom.swift; sourceTree = ""; }; @@ -88,6 +99,7 @@ 6865EA2527513B4500FFAFC3 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6865EA2D27513B6D00FFAFC3 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 6867533A27A65652003707B9 /* AppContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppContext.swift; sourceTree = ""; }; + 687230F72B14AE0A0098CCE6 /* PublishOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishOptionsView.swift; sourceTree = ""; }; 68816CC427B4DCD500E24622 /* SecureStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureStore.swift; sourceTree = ""; }; 6884B77B2750507400732D47 /* ScreenShareSourcePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenShareSourcePickerView.swift; sourceTree = ""; }; 68B3853C271E780600711D5F /* LiveKitExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveKitExample.swift; sourceTree = ""; }; @@ -148,9 +160,11 @@ 683720D427A0640D007DA986 /* Support */ = { isa = PBXGroup; children = ( + 6816B1A7272D45DF005ADB85 /* ExampleRoomMessage.swift */, 683720D127A06404007DA986 /* ConnectionHistory.swift */, 6847616327B44A1A001611BE /* Bundle.swift */, 68816CC427B4DCD500E24622 /* SecureStore.swift */, + 6816968D2AF96240008ED486 /* Participant+Helpers.swift */, ); path = Support; sourceTree = ""; @@ -188,7 +202,9 @@ 6884B77A2750505B00732D47 /* Views */ = { isa = PBXGroup; children = ( + 2C89102F2B640DA60058BECE /* AudioProcessorOptionsView.swift */, 6884B77B2750507400732D47 /* ScreenShareSourcePickerView.swift */, + 687230F72B14AE0A0098CCE6 /* PublishOptionsView.swift */, ); path = Views; sourceTree = ""; @@ -212,9 +228,9 @@ 685271EA27443907006B4D6A /* Controllers */, 681E3F3E271FC795007BB547 /* Custom.swift */, 681E3F42271FC7AD007BB547 /* ConnectView.swift */, + 2C13AA9D2B5F886700E7BB18 /* FakeAudioProcessor.swift */, 681E3F41271FC7AC007BB547 /* RoomView.swift */, 68B3853C271E780600711D5F /* LiveKitExample.swift */, - 6816B1A7272D45DF005ADB85 /* ExampleObservableRoom.swift */, 6816B1AF272D9198005ADB85 /* ParticipantView.swift */, 68B3853E271E780700711D5F /* Assets.xcassets */, ); @@ -399,14 +415,18 @@ 681E3F45271FC7AD007BB547 /* ConnectView.swift in Sources */, 6867533B27A65652003707B9 /* AppContext.swift in Sources */, 681E3F39271FC772007BB547 /* RoomContext.swift in Sources */, + 2C8910302B640DA60058BECE /* AudioProcessorOptionsView.swift in Sources */, + 2C13AA9E2B5F886700E7BB18 /* FakeAudioProcessor.swift in Sources */, 683720D227A06404007DA986 /* ConnectionHistory.swift in Sources */, 681E3F43271FC7AD007BB547 /* RoomView.swift in Sources */, 6816B1B0272D9198005ADB85 /* ParticipantView.swift in Sources */, 68816CC527B4DCD500E24622 /* SecureStore.swift in Sources */, + 6816968E2AF96240008ED486 /* Participant+Helpers.swift in Sources */, 68B3854C271E780700711D5F /* LiveKitExample.swift in Sources */, 681E3F3F271FC795007BB547 /* Custom.swift in Sources */, 6847616427B44A1A001611BE /* Bundle.swift in Sources */, - 6816B1A8272D45DF005ADB85 /* ExampleObservableRoom.swift in Sources */, + 687230F82B14AE0A0098CCE6 /* PublishOptionsView.swift in Sources */, + 6816B1A8272D45DF005ADB85 /* ExampleRoomMessage.swift in Sources */, 6884B77C2750507400732D47 /* ScreenShareSourcePickerView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -418,14 +438,18 @@ 681E3F46271FC7AD007BB547 /* ConnectView.swift in Sources */, 6867533C27A65652003707B9 /* AppContext.swift in Sources */, 681E3F3A271FC772007BB547 /* RoomContext.swift in Sources */, + 2C8910312B640DA60058BECE /* AudioProcessorOptionsView.swift in Sources */, + 2C13AA9F2B5F886700E7BB18 /* FakeAudioProcessor.swift in Sources */, 683720D327A06404007DA986 /* ConnectionHistory.swift in Sources */, 681E3F44271FC7AD007BB547 /* RoomView.swift in Sources */, 6816B1B1272D9198005ADB85 /* ParticipantView.swift in Sources */, 68816CC627B4DCD500E24622 /* SecureStore.swift in Sources */, + 6816968F2AF96240008ED486 /* Participant+Helpers.swift in Sources */, 68B3854D271E780700711D5F /* LiveKitExample.swift in Sources */, 681E3F40271FC795007BB547 /* Custom.swift in Sources */, 6847616527B44A1A001611BE /* Bundle.swift in Sources */, - 6816B1A9272D45DF005ADB85 /* ExampleObservableRoom.swift in Sources */, + 687230F92B14AE0A0098CCE6 /* PublishOptionsView.swift in Sources */, + 6816B1A9272D45DF005ADB85 /* ExampleRoomMessage.swift in Sources */, 6884B77D2750507400732D47 /* ScreenShareSourcePickerView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -621,7 +645,7 @@ CODE_SIGN_ENTITLEMENTS = "$(SRCROOT)/iOS/iOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = 76TVFCUKK7; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iOS/Info.plist; @@ -636,7 +660,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.2.2; PRODUCT_BUNDLE_IDENTIFIER = io.livekit.example.SwiftSDK.1; PRODUCT_NAME = LiveKitExample; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -655,7 +679,7 @@ CODE_SIGN_ENTITLEMENTS = "$(SRCROOT)/iOS/iOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 30; + CURRENT_PROJECT_VERSION = 33; DEVELOPMENT_TEAM = 76TVFCUKK7; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = iOS/Info.plist; @@ -670,7 +694,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.1.1; + MARKETING_VERSION = 1.2.2; PRODUCT_BUNDLE_IDENTIFIER = io.livekit.example.SwiftSDK.1; PRODUCT_NAME = LiveKitExample; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/LiveKitExample.xcodeproj/xcshareddata/xcschemes/Example (macOS Debug).xcscheme b/LiveKitExample.xcodeproj/xcshareddata/xcschemes/Example (macOS Debug).xcscheme index 6be4bd3..3f4a4c3 100644 --- a/LiveKitExample.xcodeproj/xcshareddata/xcschemes/Example (macOS Debug).xcscheme +++ b/LiveKitExample.xcodeproj/xcshareddata/xcschemes/Example (macOS Debug).xcscheme @@ -35,12 +35,15 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" enableASanStackUseAfterReturn = "YES" + disablePerformanceAntipatternChecker = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" - allowLocationSimulation = "YES"> + allowLocationSimulation = "YES" + queueDebuggingEnabled = "No" + memoryGraphOnResourceException = "Yes"> + + + + + + @Published var videoViewVisible: Bool = true { @@ -38,40 +47,41 @@ final class AppContext: ObservableObject { didSet { store.value.connectionHistory = connectionHistory } } - @Published var outputDevice: RTCIODevice = RTCIODevice.defaultDevice(with: .output) { + @Published var outputDevice: AudioDevice = AudioManager.shared.defaultOutputDevice { didSet { print("didSet outputDevice: \(String(describing: outputDevice))") - Room.audioDeviceModule.outputDevice = outputDevice + AudioManager.shared.outputDevice = outputDevice } } - @Published var inputDevice: RTCIODevice = RTCIODevice.defaultDevice(with: .input) { + @Published var inputDevice: AudioDevice = AudioManager.shared.defaultInputDevice { didSet { print("didSet inputDevice: \(String(describing: inputDevice))") - Room.audioDeviceModule.inputDevice = inputDevice + AudioManager.shared.inputDevice = inputDevice } } @Published var preferSpeakerOutput: Bool = true { - didSet { AudioManager.shared.preferSpeakerOutput = preferSpeakerOutput } + didSet { AudioManager.shared.isSpeakerOutputPreferred = preferSpeakerOutput } } public init(store: ValueStore) { self.store = store - self.videoViewVisible = store.value.videoViewVisible - self.showInformationOverlay = store.value.showInformationOverlay - self.preferSampleBufferRendering = store.value.preferSampleBufferRendering - self.videoViewMode = store.value.videoViewMode - self.videoViewMirrored = store.value.videoViewMirrored - self.connectionHistory = store.value.connectionHistory + videoViewVisible = store.value.videoViewVisible + showInformationOverlay = store.value.showInformationOverlay + preferSampleBufferRendering = store.value.preferSampleBufferRendering + videoViewMode = store.value.videoViewMode + videoViewMirrored = store.value.videoViewMirrored + connectionHistory = store.value.connectionHistory - Room.audioDeviceModule.setDevicesUpdatedHandler { + AudioManager.shared.onDeviceUpdate = { [weak self] audioManager in + guard let self else { return } print("devices did update") // force UI update for outputDevice / inputDevice - DispatchQueue.main.async { - self.outputDevice = Room.audioDeviceModule.outputDevice - self.inputDevice = Room.audioDeviceModule.inputDevice + Task { @MainActor in + self.outputDevice = audioManager.outputDevice + self.inputDevice = audioManager.inputDevice } } } diff --git a/Shared/Controllers/RoomContext.swift b/Shared/Controllers/RoomContext.swift index 4c367bc..dccad84 100644 --- a/Shared/Controllers/RoomContext.swift +++ b/Shared/Controllers/RoomContext.swift @@ -1,10 +1,25 @@ -import SwiftUI +/* + * 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 SwiftUI import WebRTC // This class contains the logic to control behavior of the whole app. final class RoomContext: ObservableObject { - let jsonEncoder = JSONEncoder() let jsonDecoder = JSONDecoder() @@ -13,7 +28,7 @@ final class RoomContext: ObservableObject { // Used to show connection error dialog // private var didClose: Bool = false @Published var shouldShowDisconnectReason: Bool = false - public var latestError: DisconnectReason? + public var latestError: LiveKitError? public let room = Room() @@ -52,7 +67,7 @@ final class RoomContext: ObservableObject { // ConnectOptions @Published var autoSubscribe: Bool = true { - didSet { store.value.autoSubscribe = autoSubscribe} + didSet { store.value.autoSubscribe = autoSubscribe } } @Published var publish: Bool = false { @@ -65,38 +80,46 @@ final class RoomContext: ObservableObject { @Published var messages: [ExampleRoomMessage] = [] @Published var textFieldString: String = "" + + + var audioProcessor: AudioProcessor? = nil + + var _connectTask: Task? public init(store: ValueStore) { self.store = store room.add(delegate: self) - self.url = store.value.url - self.token = store.value.token - self.e2ee = store.value.e2ee - self.e2eeKey = store.value.e2eeKey - self.simulcast = store.value.simulcast - self.adaptiveStream = store.value.adaptiveStream - self.dynacast = store.value.dynacast - self.reportStats = store.value.reportStats - self.autoSubscribe = store.value.autoSubscribe - self.publish = store.value.publishMode + url = store.value.url + token = store.value.token + e2ee = store.value.e2ee + e2eeKey = store.value.e2eeKey + simulcast = store.value.simulcast + adaptiveStream = store.value.adaptiveStream + dynacast = store.value.dynacast + reportStats = store.value.reportStats + autoSubscribe = store.value.autoSubscribe + publish = store.value.publishMode #if os(iOS) - UIApplication.shared.isIdleTimerDisabled = true + UIApplication.shared.isIdleTimerDisabled = true #endif } deinit { #if os(iOS) - UIApplication.shared.isIdleTimerDisabled = false + UIApplication.shared.isIdleTimerDisabled = false #endif print("RoomContext.deinit") } + func cancelConnect() { + _connectTask?.cancel() + } + @MainActor func connect(entry: ConnectionHistory? = nil) async throws -> Room { - - if let entry = entry { + if let entry { url = entry.url token = entry.token e2ee = entry.e2ee @@ -115,6 +138,10 @@ final class RoomContext: ObservableObject { e2eeOptions = E2EEOptions(keyProvider: keyProvider) } + let audioProcessorOptions = AudioProcessorOptions( + capturePostProcessor: FakeAudioProcessor() + ) + let roomOptions = RoomOptions( defaultCameraCaptureOptions: CameraCaptureOptions( dimensions: .h1080_169 @@ -126,35 +153,37 @@ final class RoomContext: ObservableObject { defaultVideoPublishOptions: VideoPublishOptions( simulcast: publish ? false : simulcast ), - adaptiveStream: adaptiveStream, - dynacast: dynacast, - reportStats: reportStats, - e2eeOptions: e2eeOptions + adaptiveStream: true, + dynacast: true, + // e2eeOptions: e2eeOptions, + audioProcessorOptions: audioProcessorOptions, + reportRemoteTrackStatistics: true ) - return try await room.connect(url, - token, - connectOptions: connectOptions, - roomOptions: roomOptions) + let connectTask = Task { + try await room.connect(url: url, + token: token, + connectOptions: connectOptions, + roomOptions: roomOptions) + } + + _connectTask = connectTask + try await connectTask.value + + return room } - func disconnect() async throws { - try await room.disconnect() + func disconnect() async { + await room.disconnect() } func sendMessage() { - - guard let localParticipant = room.localParticipant else { - print("LocalParticipant doesn't exist") - return - } - // Make sure the message is not empty guard !textFieldString.isEmpty else { return } let roomMessage = ExampleRoomMessage(messageId: UUID().uuidString, - senderSid: localParticipant.sid, - senderIdentity: localParticipant.identity, + senderSid: room.localParticipant.sid, + senderIdentity: room.localParticipant.identity, text: textFieldString) textFieldString = "" messages.append(roomMessage) @@ -162,87 +191,81 @@ final class RoomContext: ObservableObject { Task { do { let json = try jsonEncoder.encode(roomMessage) - try await localParticipant.publish(data: json) - } catch let error { + try await room.localParticipant.publish(data: json) + } catch { print("Failed to encode data \(error)") } - } } #if os(macOS) - weak var screenShareTrack: LocalTrackPublication? - func setScreenShareMacOS(enabled: Bool, screenShareSource: MacOSScreenCaptureSource? = nil) async throws { + weak var screenShareTrack: LocalTrackPublication? - guard let localParticipant = room.localParticipant else { - print("LocalParticipant doesn't exist") - return - } - - if enabled, let screenShareSource = screenShareSource { - let track = LocalVideoTrack.createMacOSScreenShareTrack(source: screenShareSource) - screenShareTrack = try await localParticipant.publishVideo(track) - } + @available(macOS 12.3, *) + func setScreenShareMacOS(isEnabled: Bool, screenShareSource: MacOSScreenCaptureSource? = nil) async throws { + if isEnabled, let screenShareSource { + let track = LocalVideoTrack.createMacOSScreenShareTrack(source: screenShareSource) + screenShareTrack = try await room.localParticipant.publish(videoTrack: track) + } - if !enabled, let screenShareTrack = screenShareTrack { - try await localParticipant.unpublish(publication: screenShareTrack) + if !isEnabled, let screenShareTrack { + try await room.localParticipant.unpublish(publication: screenShareTrack) + } } - } #endif } extension RoomContext: RoomDelegate { - func room(_ room: Room, publication: TrackPublication, didUpdateE2EEState e2eeState: E2EEState) { + func room(_ room: Room, track publication: TrackPublication, didUpdateE2EEState e2eeState: E2EEState) { print("Did update e2eeState = [\(e2eeState.toString())] for publication \(publication.sid)") } - func room(_ room: Room, didUpdate connectionState: ConnectionState, oldValue: ConnectionState) { - + func room(_ room: Room, didUpdateConnectionState connectionState: ConnectionState, from oldValue: ConnectionState) { print("Did update connectionState \(oldValue) -> \(connectionState)") - if case .disconnected(let reason) = connectionState, reason != .user { - latestError = reason - DispatchQueue.main.async { - self.shouldShowDisconnectReason = true + if case .disconnected = connectionState, + let error = room.disconnectError, + error.type != .cancelled + { + latestError = room.disconnectError + + Task { @MainActor in + shouldShowDisconnectReason = true // Reset state - self.focusParticipant = nil - self.showMessagesView = false - self.textFieldString = "" - self.messages.removeAll() + focusParticipant = nil + showMessagesView = false + textFieldString = "" + messages.removeAll() // self.objectWillChange.send() } } } - func room(_ room: Room, - participantDidLeave participant: RemoteParticipant) { - DispatchQueue.main.async { + func room(_: Room, participantDidDisconnect participant: RemoteParticipant) { + Task { @MainActor in // self.participants.removeValue(forKey: participant.sid) - if let focusParticipant = self.focusParticipant, - focusParticipant.sid == participant.sid { + if let focusParticipant, focusParticipant.sid == participant.sid { self.focusParticipant = nil } } } - func room(_ room: Room, - participant: RemoteParticipant?, didReceive data: Data) { - + func room(_: Room, participant _: RemoteParticipant?, didReceiveData data: Data, forTopic _: String) { do { let roomMessage = try jsonDecoder.decode(ExampleRoomMessage.self, from: data) // Update UI from main queue - DispatchQueue.main.async { + Task { @MainActor in withAnimation { // Add messages to the @Published messages property // which will trigger the UI to update - self.messages.append(roomMessage) + messages.append(roomMessage) // Show the messages view when new messages arrive - self.showMessagesView = true + showMessagesView = true } } - } catch let error { + } catch { print("Failed to decode data \(error)") } } diff --git a/Shared/Custom.swift b/Shared/Custom.swift index cb6716e..cead250 100644 --- a/Shared/Custom.swift +++ b/Shared/Custom.swift @@ -1,3 +1,19 @@ +/* + * Copyright 2023 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 SwiftUI extension Color { @@ -13,6 +29,7 @@ struct LazyView: View { init(_ build: @autoclosure @escaping () -> Content) { self.build = build } + var body: Content { build() } @@ -20,49 +37,45 @@ struct LazyView: View { // Default button style for this example struct LKButton: View { - let title: String let action: () -> Void var body: some View { - Button(action: action, label: { - Text(title.uppercased()) - .fontWeight(.bold) - .padding(.horizontal, 12) - .padding(.vertical, 10) - } - ) - .background(Color.lkRed) - .cornerRadius(8) + Text(title.uppercased()) + .fontWeight(.bold) + .padding(.horizontal, 12) + .padding(.vertical, 10) + }) + .background(Color.lkRed) + .cornerRadius(8) } } #if os(iOS) -extension LKTextField.`Type` { - func toiOSType() -> UIKeyboardType { - switch self { - case .default: return .default - case .URL: return .URL - case .ascii: return .asciiCapable + extension LKTextField.`Type` { + func toiOSType() -> UIKeyboardType { + switch self { + case .default: return .default + case .URL: return .URL + case .ascii: return .asciiCapable + } } } -} #endif #if os(macOS) -// Avoid showing focus border around textfield for macOS -extension NSTextField { - open override var focusRingType: NSFocusRingType { - get { .none } - set { } + // Avoid showing focus border around textfield for macOS + extension NSTextField { + override open var focusRingType: NSFocusRingType { + get { .none } + set {} + } } -} #endif struct LKTextField: View { - enum `Type` { case `default` case URL @@ -88,8 +101,8 @@ struct LKTextField: View { // #endif .padding() .overlay(RoundedRectangle(cornerRadius: 10.0) - .strokeBorder(Color.white.opacity(0.3), - style: StrokeStyle(lineWidth: 1.0))) + .strokeBorder(Color.white.opacity(0.3), + style: StrokeStyle(lineWidth: 1.0))) }.frame(maxWidth: .infinity) } diff --git a/Shared/ExampleObservableRoom.swift b/Shared/ExampleObservableRoom.swift deleted file mode 100644 index 1aae0d7..0000000 --- a/Shared/ExampleObservableRoom.swift +++ /dev/null @@ -1,44 +0,0 @@ -import SwiftUI -import LiveKit -import AVFoundation - -import WebRTC -import CoreImage.CIFilterBuiltins -import ReplayKit - -extension Participant { - - public var mainVideoPublication: TrackPublication? { - firstScreenSharePublication ?? firstCameraPublication - } - - public var mainVideoTrack: VideoTrack? { - firstScreenShareVideoTrack ?? firstCameraVideoTrack - } - - public var subVideoTrack: VideoTrack? { - firstScreenShareVideoTrack != nil ? firstCameraVideoTrack : nil - } -} - -struct ExampleRoomMessage: Identifiable, Equatable, Hashable, Codable { - // Identifiable protocol needs param named id - var id: String { - messageId - } - - // message id - let messageId: String - - let senderSid: String - let senderIdentity: String - let text: String - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.messageId == rhs.messageId - } - - func hash(into hasher: inout Hasher) { - hasher.combine(messageId) - } -} diff --git a/Shared/FakeAudioProcessor.swift b/Shared/FakeAudioProcessor.swift new file mode 100644 index 0000000..1414029 --- /dev/null +++ b/Shared/FakeAudioProcessor.swift @@ -0,0 +1,46 @@ +/* + * Copyright 2023 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 Foundation +import LiveKit + +class FakeAudioProcessor: AudioProcessor { + + public init() { + + } + + func isEnabled(url: String, token: String) -> Bool { + print("check \(getName()) isEnabled: url: \(url) token: \(token)") + return true + } + + func getName() -> String { + "fake_audio_processor" + } + + func audioProcessingInitialize(sampleRate sampleRateHz: Int, channels: Int) { + print("\(getName()) audioProcessingInitialize: sampleRate: \(sampleRateHz) channels: \(channels)") + } + + func audioProcessingProcess(audioBuffer: LKAudioBuffer) { + print("\(getName()) audioProcessingProcess: \(String(describing: audioBuffer)) ") + } + + func audioProcessingRelease() { + print("\(getName()) audioProcessingRelease:") + } +} diff --git a/Shared/LiveKitExample.swift b/Shared/LiveKitExample.swift index 0f44f58..4f9b5b3 100644 --- a/Shared/LiveKitExample.swift +++ b/Shared/LiveKitExample.swift @@ -1,27 +1,42 @@ -import SwiftUI -import Logging -import LiveKit +/* + * 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 KeychainAccess +import LiveKit +import Logging +import SwiftUI let sync = ValueStore(store: Keychain(service: "io.livekit.example.SwiftSDK.1"), key: "preferences", default: Preferences()) struct RoomSwitchView: View { - @EnvironmentObject var appCtx: AppContext @EnvironmentObject var roomCtx: RoomContext @EnvironmentObject var room: Room var shouldShowRoomView: Bool { - room.connectionState.isConnected || room.connectionState.isReconnecting + room.connectionState == .connected || room.connectionState == .reconnecting } func computeTitle() -> String { if shouldShowRoomView { let elements = [room.name, - room.localParticipant?.name, - room.localParticipant?.identity] + room.localParticipant.name, + room.localParticipant.identity] return elements.compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " ") } @@ -45,7 +60,6 @@ struct RoomSwitchView: View { // Attaches RoomContext and Room to the environment struct RoomContextView: View { - @EnvironmentObject var appCtx: AppContext @StateObject var roomCtx = RoomContext(store: sync) @@ -58,7 +72,7 @@ struct RoomContextView: View { .onDisappear { print("\(String(describing: type(of: self))) onDisappear") Task { - try await roomCtx.disconnect() + await roomCtx.disconnect() } } .onOpenURL(perform: { url in @@ -129,11 +143,9 @@ extension Decimal { @main struct LiveKitExample: App { - @StateObject var appCtx = AppContext(store: sync) func nearestSafeScale(for target: Int, scale: Double) -> Decimal { - let p = Decimal(sign: .plus, exponent: -3, significand: 1) let t = Decimal(target) var s = Decimal(scale).rounded(3, .down) @@ -146,11 +158,11 @@ struct LiveKitExample: App { } init() { - LoggingSystem.bootstrap({ + LoggingSystem.bootstrap { var logHandler = StreamLogHandler.standardOutput(label: $0) logHandler.logLevel = .debug return logHandler - }) + } } var body: some Scene { @@ -160,33 +172,33 @@ struct LiveKitExample: App { } .handlesExternalEvents(matching: Set(arrayLiteral: "*")) #if os(macOS) - .windowStyle(.hiddenTitleBar) - .windowToolbarStyle(.unifiedCompact) + .windowStyle(.hiddenTitleBar) + .windowToolbarStyle(.unifiedCompact) #endif } } #if os(macOS) -extension View { - func withHostingWindow(_ callback: @escaping (NSWindow) -> Void) -> some View { - self.background(HostingWindowFinder(callback: callback)) + extension View { + func withHostingWindow(_ callback: @escaping (NSWindow) -> Void) -> some View { + background(HostingWindowFinder(callback: callback)) + } } -} -struct HostingWindowFinder: NSViewRepresentable { - var callback: (NSWindow) -> Void + struct HostingWindowFinder: NSViewRepresentable { + var callback: (NSWindow) -> Void - func makeNSView(context: Context) -> NSView { - let view = NSView() - DispatchQueue.main.async { [weak view] in - if let window = view?.window { - self.callback(window) + func makeNSView(context _: Context) -> NSView { + let view = NSView() + DispatchQueue.main.async { [weak view] in + if let window = view?.window { + callback(window) + } } + return view } - return view - } - func updateNSView(_ uiView: NSView, context: Context) {} -} + func updateNSView(_: NSView, context _: Context) {} + } #endif diff --git a/Shared/ParticipantView.swift b/Shared/ParticipantView.swift index 165db94..0ca3f96 100644 --- a/Shared/ParticipantView.swift +++ b/Shared/ParticipantView.swift @@ -1,9 +1,24 @@ -import SwiftUI +/* + * 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 struct ParticipantView: View { - @ObservedObject var participant: Participant @EnvironmentObject var appCtx: AppContext @@ -12,7 +27,7 @@ struct ParticipantView: View { @State private var isRendering: Bool = false @State private var dimensions: Dimensions? - @State private var videoTrackStats: TrackStats? + @State private var videoTrackStats: TrackStatistics? func bgView(systemSymbol: SFSymbol, geometry: GeometryProxy) -> some View { Image(systemSymbol: systemSymbol) @@ -36,9 +51,10 @@ struct ParticipantView: View { // VideoView for the Participant if let publication = participant.mainVideoPublication, - !publication.muted, + !publication.isMuted, let track = publication.track as? VideoTrack, - appCtx.videoViewVisible { + appCtx.videoViewVisible + { ZStack(alignment: .topLeading) { SwiftUIVideoView(track, layoutMode: videoViewMode, @@ -47,7 +63,7 @@ struct ParticipantView: View { debugMode: appCtx.showInformationOverlay, isRendering: $isRendering, dimensions: $dimensions, - trackStats: $videoTrackStats) + trackStatistics: $videoTrackStats) if !isRendering { ProgressView().progressViewStyle(CircularProgressViewStyle()) @@ -55,7 +71,8 @@ struct ParticipantView: View { } } } else if let publication = participant.mainVideoPublication as? RemoteTrackPublication, - case .notAllowed = publication.subscriptionState { + case .notAllowed = publication.subscriptionState + { // Show no permission icon bgView(systemSymbol: .exclamationmarkCircle, geometry: geometry) } else { @@ -64,18 +81,19 @@ struct ParticipantView: View { } if appCtx.showInformationOverlay { - VStack(alignment: .leading, spacing: 5) { // Video stats if let publication = participant.mainVideoPublication, - !publication.muted, - let track = publication.track as? VideoTrack { + !publication.isMuted, + let track = publication.track as? VideoTrack + { StatsView(track: track) } // Audio stats if let publication = participant.firstAudioPublication, - !publication.muted, - let track = publication.track as? AudioTrack { + !publication.isMuted, + let track = publication.track as? AudioTrack + { StatsView(track: track) } } @@ -87,7 +105,6 @@ struct ParticipantView: View { maxHeight: .infinity, alignment: .topLeading ) - } VStack(alignment: .trailing, spacing: 0) { @@ -95,40 +112,42 @@ struct ParticipantView: View { if let subVideoTrack = participant.subVideoTrack { SwiftUIVideoView(subVideoTrack, layoutMode: .fill, - mirrorMode: appCtx.videoViewMirrored ? .mirror : .auto - ) - .background(Color.black) - .aspectRatio(contentMode: .fit) - .frame(width: min(geometry.size.width, geometry.size.height) * 0.3) - .cornerRadius(8) - .padding() + mirrorMode: appCtx.videoViewMirrored ? .mirror : .auto) + .background(Color.black) + .aspectRatio(contentMode: .fit) + .frame(width: min(geometry.size.width, geometry.size.height) * 0.3) + .cornerRadius(8) + .padding() } // Bottom user info bar HStack { - Text("\(participant.identity)") // (\(participant.publish ?? "-")) + Text("\(participant.identity)(\(participant.sid))") .lineLimit(1) .truncationMode(.tail) if let publication = participant.mainVideoPublication, - !publication.muted { - + !publication.isMuted + { // is remote if let remotePub = publication as? RemoteTrackPublication { Menu { if case .subscribed = remotePub.subscriptionState { Button { - remotePub.set(subscribed: false) + Task { + try await remotePub.set(subscribed: false) + } } label: { Text("Unsubscribe") } } else if case .unsubscribed = remotePub.subscriptionState { Button { - remotePub.set(subscribed: true) + Task { + try await remotePub.set(subscribed: true) + } } label: { Text("Subscribe") } - } } label: { if case .subscribed = remotePub.subscriptionState { @@ -159,24 +178,27 @@ struct ParticipantView: View { } if let publication = participant.firstAudioPublication, - !publication.muted { - + !publication.isMuted + { // is remote if let remotePub = publication as? RemoteTrackPublication { Menu { if case .subscribed = remotePub.subscriptionState { Button { - remotePub.set(subscribed: false) + Task { + try await remotePub.set(subscribed: false) + } } label: { Text("Unsubscribe") } } else if case .unsubscribed = remotePub.subscriptionState { Button { - remotePub.set(subscribed: true) + Task { + try await remotePub.set(subscribed: true) + } } label: { Text("Subscribe") } - } } label: { if case .subscribed = remotePub.subscriptionState { @@ -226,8 +248,8 @@ struct ParticipantView: View { } }.padding(5) - .frame(minWidth: 0, maxWidth: .infinity) - .background(Color.black.opacity(0.5)) + .frame(minWidth: 0, maxWidth: .infinity) + .background(Color.black.opacity(0.5)) } } .cornerRadius(8) @@ -239,15 +261,14 @@ struct ParticipantView: View { : nil ) }.gesture(TapGesture() - .onEnded { _ in - // Pass the tap event - onTap?(participant) - }) + .onEnded { _ in + // Pass the tap event + onTap?(participant) + }) } } struct StatsView: View { - @ObservedObject private var viewModel: DelegateObserver private let track: Track @@ -276,26 +297,39 @@ struct StatsView: View { Text("Unknown").fontWeight(.bold) } - if let trackStats = viewModel.stats { - - if trackStats.bpsSent != 0 { + // if let trackStats = viewModel.statistics { + ForEach(viewModel.allStatisticts, id: \.self) { trackStats in + ForEach(trackStats.outboundRtpStream.sortedByRidIndex()) { stream in HStack(spacing: 3) { - if let codecName = trackStats.codecName { - Text(codecName.uppercased()).fontWeight(.bold) + Image(systemSymbol: .arrowUp) + + if let codec = trackStats.codec.first(where: { $0.id == stream.codecId }) { + Text(codec.mimeType ?? "?") + } + + if let rid = stream.rid, !rid.isEmpty { + Text(rid.uppercased()) + } + + Text(stream.formattedBps()) + + if let reason = stream.qualityLimitationReason, reason != QualityLimitationReason.none { + Image(systemSymbol: .exclamationmarkTriangleFill) + Text(reason.rawValue.capitalized) } - Image(systemSymbol: .arrowUpCircle) - Text(trackStats.formattedBpsSent()) } } + ForEach(trackStats.inboundRtpStream) { stream in - if trackStats.bpsReceived != 0 { HStack(spacing: 3) { - if let codecName = trackStats.codecName { - Text(codecName.uppercased()).fontWeight(.bold) + Image(systemSymbol: .arrowDown) + + if let codec = trackStats.codec.first(where: { $0.id == stream.codecId }) { + Text(codec.mimeType ?? "?") } - Image(systemSymbol: .arrowDownCircle) - Text(trackStats.formattedBpsReceived()) + + Text(stream.formattedBps()) } } } @@ -310,30 +344,41 @@ struct StatsView: View { } extension StatsView { - class DelegateObserver: ObservableObject, TrackDelegate { private let track: Track @Published var dimensions: Dimensions? - @Published var stats: TrackStats? + @Published var statistics: TrackStatistics? + @Published var simulcastStatistics: [VideoCodec: TrackStatistics] + + var allStatisticts: [TrackStatistics] { + var result: [TrackStatistics] = [] + if let statistics { + result.append(statistics) + } + result.append(contentsOf: simulcastStatistics.values) + return result + } init(track: Track) { self.track = track dimensions = track.dimensions - stats = track.stats + statistics = track.statistics + simulcastStatistics = track.simulcastStatistics track.add(delegate: self) } - func track(_ track: VideoTrack, didUpdate dimensions: Dimensions?) { + func track(_: VideoTrack, didUpdate dimensions: Dimensions?) { Task.detached { @MainActor in self.dimensions = dimensions } } - func track(_ track: Track, didUpdate stats: TrackStats) { + func track(_: Track, didUpdateStatistics statistics: TrackStatistics, simulcastStatistics: [VideoCodec: TrackStatistics]) { Task.detached { @MainActor in - self.stats = stats + self.statistics = statistics + self.simulcastStatistics = simulcastStatistics } } } diff --git a/Shared/RoomView.swift b/Shared/RoomView.swift index c702fd7..de1fef7 100644 --- a/Shared/RoomView.swift +++ b/Shared/RoomView.swift @@ -1,73 +1,82 @@ -import SwiftUI +/* + * 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 import WebRTC #if !os(macOS) -let adaptiveMin = 170.0 -let toolbarPlacement: ToolbarItemPlacement = .bottomBar + let adaptiveMin = 170.0 + let toolbarPlacement: ToolbarItemPlacement = .bottomBar #else -let adaptiveMin = 300.0 -let toolbarPlacement: ToolbarItemPlacement = .primaryAction + 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!) + self.init(cgImage: UIImage(named: name)!.cgImage!) #else - self.init(data: NSImage(named: name)!.tiffRepresentation!)! + self.init(data: NSImage(named: name)!.tiffRepresentation!)! #endif } } -extension RTCIODevice: Identifiable { - - public var id: String { - deviceId - } -} - #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 + // 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 } - self.level = pinned ? .floating : .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 { - DispatchQueue.main.async { - self.window?.level = newValue - self.objectWillChange.send() + 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 - DispatchQueue.main.async { self.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 @@ -77,15 +86,20 @@ struct RoomView: View { @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() + @ObservedObject private var windowAccess = WindowAccess() #endif @State private var showConnectionTime = true + + @State private var audioProcessorPickerPresented = false func messageView(_ message: ExampleRoomMessage) -> some View { - - let isMe = message.senderSid == room.localParticipant?.sid + let isMe = message.senderSid == room.localParticipant.sid return HStack { if isMe { @@ -104,7 +118,7 @@ struct RoomView: View { Spacer() } }.padding(.vertical, 5) - .padding(.horizontal, 10) + .padding(.horizontal, 10) } func scrollToBottom(_ scrollView: ScrollViewProxy) { @@ -115,7 +129,6 @@ struct RoomView: View { } func messagesView(geometry: GeometryProxy) -> some View { - VStack(spacing: 0) { ScrollViewReader { scrollView in ScrollView(.vertical, showsIndicators: true) { @@ -144,7 +157,6 @@ struct RoomView: View { ) } HStack(spacing: 0) { - TextField("Enter message", text: $roomCtx.textFieldString) .textFieldStyle(PlainTextFieldStyle()) .disableAutocorrection(true) @@ -165,7 +177,6 @@ struct RoomView: View { .foregroundColor(roomCtx.textFieldString.isEmpty ? nil : Color.lkRed) } .buttonStyle(.borderless) - } .padding() .background(Color.lkGray2) @@ -187,11 +198,9 @@ struct RoomView: View { } func content(geometry: GeometryProxy) -> some View { - VStack { - if showConnectionTime { - Text("Connected (\([room.serverRegion, "\(String(describing: room.connectStopwatch.total().rounded(to: 2)))s"].compactMap { $0 }.joined(separator: ", ")))") + Text("Connected (\([room.serverRegion, room.serverNodeId, "\(String(describing: room.connectStopwatch.total().rounded(to: 2)))s"].compactMap { $0 }.joined(separator: ", ")))") .multilineTextAlignment(.center) .foregroundColor(.white) .padding() @@ -205,16 +214,16 @@ struct RoomView: View { } HorVStack(axis: geometry.isTall ? .vertical : .horizontal, spacing: 5) { - Group { if let focusParticipant = roomCtx.focusParticipant { ZStack(alignment: .bottomTrailing) { ParticipantView(participant: focusParticipant, - videoViewMode: appCtx.videoViewMode) { _ in + videoViewMode: appCtx.videoViewMode) + { _ in roomCtx.focusParticipant = nil } .overlay(RoundedRectangle(cornerRadius: 5) - .stroke(Color.lkRed.opacity(0.7), lineWidth: 5.0)) + .stroke(Color.lkRed.opacity(0.7), lineWidth: 5.0)) Text("SELECTED") .font(.system(size: 10)) .fontWeight(.bold) @@ -231,9 +240,9 @@ struct RoomView: View { // Array([room.allParticipants.values, room.allParticipants.values].joined()) ParticipantLayout(sortedParticipants(), spacing: 5) { participant in ParticipantView(participant: participant, - videoViewMode: appCtx.videoViewMode) { participant in + videoViewMode: appCtx.videoViewMode) + { participant in roomCtx.focusParticipant = participant - } } } @@ -254,24 +263,19 @@ struct RoomView: View { } var body: some View { - GeometryReader { geometry in content(geometry: geometry) } .toolbar { ToolbarItemGroup(placement: toolbarPlacement) { - - // Text("(\(room.room.remoteParticipants.count)) ") - #if os(macOS) - if let name = room.name { - Text(name) - .fontWeight(.bold) - } + if let name = room.name { + Text(name) + .fontWeight(.bold) + } + + Text(room.localParticipant.identity) - if let identity = room.localParticipant?.identity { - Text(identity) - } #endif // #if os(macOS) @@ -292,141 +296,185 @@ struct RoomView: View { Spacer() Group { - let isCameraEnabled = room.localParticipant?.isCameraEnabled() ?? false - let isMicrophoneEnabled = room.localParticipant?.isMicrophoneEnabled() ?? false - let isScreenShareEnabled = room.localParticipant?.isScreenShareEnabled() ?? false + Button { + Task { + try await room.debug_triggerReconnect(reason: .transport) + } + } label: { + Image(systemSymbol: .repeat) + .renderingMode(.original) + } + } - if (isCameraEnabled) && CameraCapturer.canSwitchPosition() { - Menu { - Button("Switch position") { - Task { - isCameraPublishingBusy = true - defer { Task { @MainActor in isCameraPublishingBusy = false } } - if let track = room.localParticipant?.firstCameraVideoTrack as? LocalVideoTrack, - let cameraCapturer = track.capturer as? CameraCapturer { - try await cameraCapturer.switchCameraPosition() + Group { + let isCameraEnabled = room.localParticipant.isCameraEnabled() + let isMicrophoneEnabled = room.localParticipant.isMicrophoneEnabled() + let isScreenShareEnabled = room.localParticipant.isScreenShareEnabled() + + Group { + if isCameraEnabled, CameraCapturer.canSwitchPosition() { + Menu { + Button("Switch position") { + Task { + isCameraPublishingBusy = true + defer { Task { @MainActor in isCameraPublishingBusy = false } } + if let track = room.localParticipant.firstCameraVideoTrack as? LocalVideoTrack, + let cameraCapturer = track.capturer as? CameraCapturer + { + try await cameraCapturer.switchCameraPosition() + } } } - } - Button("Disable") { - Task { - isCameraPublishingBusy = true - defer { Task { @MainActor in isCameraPublishingBusy = false } } - try await room.localParticipant?.setCamera(enabled: !isCameraEnabled) + Button("Disable") { + Task { + isCameraPublishingBusy = true + defer { Task { @MainActor in isCameraPublishingBusy = false } } + try await room.localParticipant.setCamera(enabled: !isCameraEnabled) + } } + } label: { + Image(systemSymbol: .videoFill) + .renderingMode(.original) } - } label: { - Image(systemSymbol: .videoFill) - .renderingMode(.original) + // disable while publishing/un-publishing + .disabled(isCameraPublishingBusy) + } else { + // Toggle camera enabled + Button(action: { + if isCameraEnabled { + Task { + isCameraPublishingBusy = true + defer { Task { @MainActor in isCameraPublishingBusy = false } } + try await room.localParticipant.setCamera(enabled: false) + } + } else { + publishOptionsPickerPresented = true + } + }, + label: { + Image(systemSymbol: .videoFill) + .renderingMode(isCameraEnabled ? .original : .template) + }) + // disable while publishing/un-publishing + .disabled(isCameraPublishingBusy) } - // disable while publishing/un-publishing - .disabled(isCameraPublishingBusy) - } else { - // Toggle camera enabled - Button(action: { + }.popover(isPresented: $publishOptionsPickerPresented) { + PublishOptionsView(publishOptions: cameraPublishOptions) { pickerResult in + publishOptionsPickerPresented = false + isCameraPublishingBusy = true + cameraPublishOptions = pickerResult Task { - isCameraPublishingBusy = true defer { Task { @MainActor in isCameraPublishingBusy = false } } - try await room.localParticipant?.setCamera(enabled: !isCameraEnabled) + try await room.localParticipant.setCamera(enabled: true, publishOptions: pickerResult) } - }, - label: { - Image(systemSymbol: .videoFill) - .renderingMode(isCameraEnabled ? .original : .template) - }) - // disable while publishing/un-publishing - .disabled(isCameraPublishingBusy) + } + .padding() } // Toggle microphone enabled Button(action: { - Task { - isMicrophonePublishingBusy = true - defer { Task { @MainActor in isMicrophonePublishingBusy = false } } - try await room.localParticipant?.setMicrophone(enabled: !isMicrophoneEnabled) - } - }, - label: { - Image(systemSymbol: .micFill) - .renderingMode(isMicrophoneEnabled ? .original : .template) - }) - // disable while publishing/un-publishing - .disabled(isMicrophonePublishingBusy) + Task { + isMicrophonePublishingBusy = true + defer { Task { @MainActor in isMicrophonePublishingBusy = false } } + try await room.localParticipant.setMicrophone(enabled: !isMicrophoneEnabled) + } + }, + label: { + Image(systemSymbol: .micFill) + .renderingMode(isMicrophoneEnabled ? .original : .template) + }) + // disable while publishing/un-publishing + .disabled(isMicrophonePublishingBusy) #if os(iOS) - Button(action: { - Task { - isScreenSharePublishingBusy = true - defer { Task { @MainActor in isScreenSharePublishingBusy = false } } - try await room.localParticipant?.setScreenShare(enabled: !isScreenShareEnabled) - } - }, - label: { - Image(systemSymbol: .rectangleFillOnRectangleFill) - .renderingMode(isScreenShareEnabled ? .original : .template) - }) - // disable while publishing/un-publishing - .disabled(isScreenSharePublishingBusy) + Button(action: { + Task { + isScreenSharePublishingBusy = true + defer { Task { @MainActor in isScreenSharePublishingBusy = false } } + try await room.localParticipant.setScreenShare(enabled: !isScreenShareEnabled) + } + }, + label: { + Image(systemSymbol: .rectangleFillOnRectangleFill) + .renderingMode(isScreenShareEnabled ? .original : .template) + }) + // disable while publishing/un-publishing + .disabled(isScreenSharePublishingBusy) #elseif os(macOS) - Button(action: { - if isScreenShareEnabled { - // turn off screen share - Task { - isScreenSharePublishingBusy = true - defer { Task { @MainActor in isScreenSharePublishingBusy = false } } - try await roomCtx.setScreenShareMacOS(enabled: false) + Button(action: { + if #available(macOS 12.3, *) { + if isScreenShareEnabled { + // Turn off screen share + Task { + isScreenSharePublishingBusy = true + defer { Task { @MainActor in isScreenSharePublishingBusy = false } } + try await roomCtx.setScreenShareMacOS(isEnabled: false) + } + } else { + screenPickerPresented = true + } + } + }, + label: { + Image(systemSymbol: .rectangleFillOnRectangleFill) + .renderingMode(isScreenShareEnabled ? .original : .template) + .foregroundColor(isScreenShareEnabled ? Color.green : Color.white) + }).popover(isPresented: $screenPickerPresented) { + if #available(macOS 12.3, *) { + ScreenShareSourcePickerView { source in + Task { + isScreenSharePublishingBusy = true + defer { Task { @MainActor in isScreenSharePublishingBusy = false } } + try await roomCtx.setScreenShareMacOS(isEnabled: true, screenShareSource: source) + } + screenPickerPresented = false + }.padding() } - } else { - screenPickerPresented = true } - }, - label: { - Image(systemSymbol: .rectangleFillOnRectangleFill) - .renderingMode(isScreenShareEnabled ? .original : .template) - .foregroundColor(isScreenShareEnabled ? Color.green : Color.white) - }).popover(isPresented: $screenPickerPresented) { - ScreenShareSourcePickerView { source in - Task { - isScreenSharePublishingBusy = true - defer { Task { @MainActor in isScreenSharePublishingBusy = false } } - try await roomCtx.setScreenShareMacOS(enabled: true, screenShareSource: source) - } - screenPickerPresented = false - }.padding() - } - .disabled(isScreenSharePublishingBusy) + .disabled(isScreenSharePublishingBusy) #endif // Toggle messages view (chat example) Button(action: { - withAnimation { - roomCtx.showMessagesView.toggle() - } - }, - label: { - Image(systemSymbol: .messageFill) - .renderingMode(roomCtx.showMessagesView ? .original : .template) - }) + withAnimation { + roomCtx.showMessagesView.toggle() + } + }, + label: { + Image(systemSymbol: .messageFill) + .renderingMode(roomCtx.showMessagesView ? .original : .template) + }) + Button(action: { + audioProcessorPickerPresented = true + }, + label: { + Image(systemSymbol: .waveform) + .renderingMode(roomCtx.room.audioProcessorOptions != nil ? .original : .template) + }) + .popover(isPresented: $audioProcessorPickerPresented) { + AudioProcessorOptionsView(roomCtx: roomCtx) { _ in + audioProcessorPickerPresented = false + }.padding() + }.disabled(roomCtx.room.audioProcessorOptions == nil) } // Spacer() #if os(iOS) - SwiftUIAudioRoutePickerButton() + SwiftUIAudioRoutePickerButton() #endif Menu { - #if os(macOS) - Button { - if let url = URL(string: "livekit://") { - NSWorkspace.shared.open(url) + Button { + if let url = URL(string: "livekit://") { + NSWorkspace.shared.open(url) + } + } label: { + Text("New window") } - } label: { - Text("New window") - } - Divider() + Divider() #endif @@ -441,27 +489,25 @@ struct RoomView: View { #if os(macOS) - Group { - // - Picker("Output device", selection: $appCtx.outputDevice) { - ForEach(Room.audioDeviceModule.outputDevices) { device in - Text(device.isDefault ? "Default" : "\(device.name)").tag(device) + Group { + Picker("Output device", selection: $appCtx.outputDevice) { + ForEach(AudioManager.shared.outputDevices) { device in + Text(device.isDefault ? "Default" : "\(device.name)").tag(device) + } } - } - - Picker("Input device", selection: $appCtx.inputDevice) { - ForEach(Room.audioDeviceModule.inputDevices) { device in - Text(device.isDefault ? "Default" : "\(device.name)").tag(device) + Picker("Input device", selection: $appCtx.inputDevice) { + ForEach(AudioManager.shared.inputDevices) { device in + Text(device.isDefault ? "Default" : "\(device.name)").tag(device) + } } } - } #endif Divider() Button { Task { - try await room.localParticipant?.unpublishAll() + await room.localParticipant.unpublishAll() } } label: { Text("Unpublish all") @@ -471,35 +517,47 @@ struct RoomView: View { Menu { Button { - room.sendSimulate(scenario: .nodeFailure) + Task { + try await room.sendSimulate(scenario: .nodeFailure) + } } label: { Text("Node failure") } Button { - room.sendSimulate(scenario: .serverLeave) + Task { + try await room.sendSimulate(scenario: .serverLeave) + } } label: { Text("Server leave") } Button { - room.sendSimulate(scenario: .migration) + Task { + try await room.sendSimulate(scenario: .migration) + } } label: { Text("Migration") } Button { - room.sendSimulate(scenario: .speakerUpdate(seconds: 3)) + Task { + try await room.sendSimulate(scenario: .speakerUpdate(seconds: 3)) + } } label: { Text("Speaker update") } Button { - room.sendSimulate(scenario: .forceTCP) + Task { + try await room.sendSimulate(scenario: .forceTCP) + } } label: { Text("Force TCP") } Button { - room.sendSimulate(scenario: .forceTLS) + Task { + try await room.sendSimulate(scenario: .forceTLS) + } } label: { Text("Force TLS") } @@ -510,13 +568,17 @@ struct RoomView: View { Group { Menu { Button { - room.localParticipant?.setTrackSubscriptionPermissions(allParticipantsAllowed: true) + Task { + try await room.localParticipant.setTrackSubscriptionPermissions(allParticipantsAllowed: true) + } } label: { Text("Allow all") } Button { - room.localParticipant?.setTrackSubscriptionPermissions(allParticipantsAllowed: false) + Task { + try await room.localParticipant.setTrackSubscriptionPermissions(allParticipantsAllowed: false) + } } label: { Text("Disallow all") } @@ -534,14 +596,14 @@ struct RoomView: View { // Disconnect Button(action: { - Task { - try await roomCtx.disconnect() - } - }, - label: { - Image(systemSymbol: .xmarkCircleFill) - .renderingMode(.original) - }) + Task { + await roomCtx.disconnect() + } + }, + label: { + Image(systemSymbol: .xmarkCircleFill) + .renderingMode(.original) + }) } } // #if os(macOS) @@ -550,9 +612,9 @@ struct RoomView: View { .onAppear { // Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { _ in - DispatchQueue.main.async { + Task { @MainActor in withAnimation { - self.showConnectionTime = false + showConnectionTime = false } } } @@ -561,7 +623,6 @@ struct RoomView: View { } struct ParticipantLayout: View { - let views: [AnyView] let spacing: CGFloat @@ -569,9 +630,10 @@ struct ParticipantLayout: View { _ data: Data, id: KeyPath = \.self, spacing: CGFloat, - @ViewBuilder content: @escaping (Data.Element) -> Content) { + @ViewBuilder content: @escaping (Data.Element) -> Content + ) { self.spacing = spacing - self.views = data.map { AnyView(content($0[keyPath: id])) } + views = data.map { AnyView(content($0[keyPath: id])) } } func computeColumn(with geometry: GeometryProxy) -> (x: Int, y: Int) { @@ -582,16 +644,16 @@ struct ParticipantLayout: View { } func grid(axis: Axis, geometry: GeometryProxy) -> some View { - ScrollView([ axis == .vertical ? .vertical : .horizontal ]) { + ScrollView([axis == .vertical ? .vertical : .horizontal]) { HorVGrid(axis: axis, columns: [GridItem(.flexible())], spacing: spacing) { - ForEach(0..: View { } 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 @@ -612,35 +673,34 @@ struct ParticipantLayout: View { // 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[0] + HorVStack(axis: horizontalWhenTall, 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] + } + 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] + } } } - } // case 6: // if geometry.isTall { // VStack { @@ -674,9 +734,9 @@ struct ParticipantLayout: View { default: let c = computeColumn(with: geometry) VStack(spacing: spacing) { - ForEach(0...(c.y - 1), id: \.self) { y in + ForEach(0 ... (c.y - 1), id: \.self) { y in HStack(spacing: spacing) { - ForEach(0...(c.x - 1), id: \.self) { x in + ForEach(0 ... (c.x - 1), id: \.self) { x in let index = (y * c.x) + x if index < views.count { views[index] @@ -685,7 +745,6 @@ struct ParticipantLayout: View { } } } - } } } @@ -703,8 +762,8 @@ struct HorVStack: View { horizontalAlignment: HorizontalAlignment = .center, verticalAlignment: VerticalAlignment = .center, spacing: CGFloat? = nil, - @ViewBuilder content: @escaping () -> Content) { - + @ViewBuilder content: @escaping () -> Content) + { self.axis = axis self.horizontalAlignment = horizontalAlignment self.verticalAlignment = verticalAlignment @@ -732,8 +791,8 @@ struct HorVGrid: View { init(axis: Axis = .horizontal, columns: [GridItem], spacing: CGFloat? = nil, - @ViewBuilder content: @escaping () -> Content) { - + @ViewBuilder content: @escaping () -> Content) + { self.axis = axis self.spacing = spacing self.columns = columns @@ -752,7 +811,6 @@ struct HorVGrid: View { } extension GeometryProxy { - public var isTall: Bool { size.height > size.width } diff --git a/Shared/Support/Bundle.swift b/Shared/Support/Bundle.swift index d8811ad..548dfa1 100644 --- a/Shared/Support/Bundle.swift +++ b/Shared/Support/Bundle.swift @@ -1,14 +1,30 @@ +/* + * Copyright 2023 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 Foundation -extension Bundle { - public var appName: String { getInfo("CFBundleName") } - public var displayName: String {getInfo("CFBundleDisplayName")} - public var language: String {getInfo("CFBundleDevelopmentRegion")} - public var identifier: String {getInfo("CFBundleIdentifier")} +public extension Bundle { + var appName: String { getInfo("CFBundleName") } + var displayName: String { getInfo("CFBundleDisplayName") } + var language: String { getInfo("CFBundleDevelopmentRegion") } + var identifier: String { getInfo("CFBundleIdentifier") } - public var appBuild: String { getInfo("CFBundleVersion") } - public var appVersionLong: String { getInfo("CFBundleShortVersionString") } - public var appVersionShort: String { getInfo("CFBundleShortVersion") } + var appBuild: String { getInfo("CFBundleVersion") } + var appVersionLong: String { getInfo("CFBundleShortVersionString") } + var appVersionShort: String { getInfo("CFBundleShortVersion") } - fileprivate func getInfo(_ str: String) -> String { infoDictionary?[str] as? String ?? "⚠️" } + private func getInfo(_ str: String) -> String { infoDictionary?[str] as? String ?? "⚠️" } } diff --git a/Shared/Support/ConnectionHistory.swift b/Shared/Support/ConnectionHistory.swift index 043e9e5..7bb8608 100644 --- a/Shared/Support/ConnectionHistory.swift +++ b/Shared/Support/ConnectionHistory.swift @@ -1,8 +1,23 @@ -import SwiftUI +/* + * Copyright 2023 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 SwiftUI struct ConnectionHistory: Codable { - let updated: Date let url: String let token: String @@ -11,43 +26,37 @@ struct ConnectionHistory: Codable { let roomSid: String? let roomName: String? let participantSid: String - let participantIdentity: String + let participantIdentity: String? let participantName: String? } extension ConnectionHistory: Identifiable { - var id: Int { - self.hashValue + hashValue } } extension ConnectionHistory: Hashable, Equatable { - func hash(into hasher: inout Hasher) { hasher.combine(url) hasher.combine(token) } static func == (lhs: ConnectionHistory, rhs: ConnectionHistory) -> Bool { - return lhs.url == rhs.url && lhs.token == rhs.token + lhs.url == rhs.url && lhs.token == rhs.token } } -extension Sequence where Element == ConnectionHistory { - +extension Sequence { var sortedByUpdated: [ConnectionHistory] { Array(self).sorted { $0.updated > $1.updated } } } -extension Set where Element == ConnectionHistory { - +extension Set { mutating func update(room: Room, e2ee: Bool, e2eeKey: String) { - guard let url = room.url, - let token = room.token, - let localParticipant = room.localParticipant else { return } + let token = room.token else { return } let element = ConnectionHistory( updated: Date(), @@ -57,11 +66,11 @@ extension Set where Element == ConnectionHistory { e2eeKey: e2eeKey, roomSid: room.sid, roomName: room.name, - participantSid: localParticipant.sid, - participantIdentity: localParticipant.identity, - participantName: localParticipant.name + participantSid: room.localParticipant.sid, + participantIdentity: room.localParticipant.identity, + participantName: room.localParticipant.name ) - self.update(with: element) + update(with: element) } } diff --git a/Shared/Support/ExampleRoomMessage.swift b/Shared/Support/ExampleRoomMessage.swift new file mode 100644 index 0000000..b97bc50 --- /dev/null +++ b/Shared/Support/ExampleRoomMessage.swift @@ -0,0 +1,37 @@ +/* + * Copyright 2023 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. + */ + +struct ExampleRoomMessage: Identifiable, Equatable, Hashable, Codable { + // Identifiable protocol needs param named id + var id: String { + messageId + } + + // message id + let messageId: String + + let senderSid: String + let senderIdentity: String? + let text: String + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.messageId == rhs.messageId + } + + func hash(into hasher: inout Hasher) { + hasher.combine(messageId) + } +} diff --git a/Shared/Support/Participant+Helpers.swift b/Shared/Support/Participant+Helpers.swift new file mode 100644 index 0000000..a7a1c9d --- /dev/null +++ b/Shared/Support/Participant+Helpers.swift @@ -0,0 +1,31 @@ +/* + * Copyright 2023 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 + +public extension Participant { + var mainVideoPublication: TrackPublication? { + firstScreenSharePublication ?? firstCameraPublication + } + + var mainVideoTrack: VideoTrack? { + firstScreenShareVideoTrack ?? firstCameraVideoTrack + } + + var subVideoTrack: VideoTrack? { + firstScreenShareVideoTrack != nil ? firstCameraVideoTrack : nil + } +} diff --git a/Shared/Support/SecureStore.swift b/Shared/Support/SecureStore.swift index 8d2985f..0fdd120 100644 --- a/Shared/Support/SecureStore.swift +++ b/Shared/Support/SecureStore.swift @@ -1,7 +1,23 @@ -import SwiftUI -import KeychainAccess +/* + * Copyright 2023 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 Combine +import KeychainAccess import LiveKit +import SwiftUI struct Preferences: Codable, Equatable { var url = "" @@ -33,7 +49,6 @@ let encoder = JSONEncoder() let decoder = JSONDecoder() class ValueStore: ObservableObject { - private let store: Keychain private let key: String private let message = "" @@ -52,14 +67,15 @@ class ValueStore: ObservableObject { .synchronizable(true) } - public init(store: Keychain, key: String, `default`: T) { + public init(store: Keychain, key: String, default: T) { self.store = store self.key = key - self.value = `default` + value = `default` if let data = try? storeWithOptions.getData(key), - let result = try? decoder.decode(T.self, from: data) { - self.value = result + let result = try? decoder.decode(T.self, from: data) + { + value = result } } @@ -77,8 +93,8 @@ class ValueStore: ObservableObject { public func sync() { do { let data = try encoder.encode(value) - try self.storeWithOptions.set(data, key: key) - } catch let error { + try storeWithOptions.set(data, key: key) + } catch { print("Failed to write in Keychain, error: \(error)") } } diff --git a/Shared/Views/AudioProcessorOptionsView.swift b/Shared/Views/AudioProcessorOptionsView.swift new file mode 100644 index 0000000..47adf21 --- /dev/null +++ b/Shared/Views/AudioProcessorOptionsView.swift @@ -0,0 +1,49 @@ +/* + * Copyright 2023 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 Foundation +import LiveKit +import SwiftUI + +struct AudioProcessorOptionsView: View { + typealias OnNSEnabled = (_ enabled: Bool) -> Void + + @State private var enabled: Bool + private let roomCtx: RoomContext + private let onEnabled: OnNSEnabled + + init(roomCtx: RoomContext, _ onEnabled: @escaping OnNSEnabled) { + self.roomCtx = roomCtx + self.onEnabled = onEnabled + self.enabled = !LiveKit.AudioManager.shared.bypassForCapturePostProcessing + } + + var body: some View { + VStack(alignment: .center, spacing: 10) { + Text("Audio Processor") + .fontWeight(.bold) + Text("name: \(roomCtx.room.audioProcessorOptions?.capturePostProcessor?.getName() ?? "")") + + Toggle(isOn: $enabled, label: { + Text("Toggle") + }).onChange(of: enabled) { newValue in + LiveKit.AudioManager.shared.bypassForCapturePostProcessing = !newValue + onEnabled(newValue); + } + .keyboardShortcut(.defaultAction) + } + } +} diff --git a/Shared/Views/PublishOptionsView.swift b/Shared/Views/PublishOptionsView.swift new file mode 100644 index 0000000..b0025f1 --- /dev/null +++ b/Shared/Views/PublishOptionsView.swift @@ -0,0 +1,86 @@ +/* + * Copyright 2023 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 Foundation +import LiveKit +import SwiftUI + +struct PublishOptionsView: View { + typealias OnPublish = (_ publishOptions: VideoPublishOptions) -> Void + + @State private var simulcast: Bool = true + @State private var preferredVideoCodec: VideoCodec? + @State private var preferredBackupVideoCodec: VideoCodec? + + private let providedPublishOptions: VideoPublishOptions + private let onPublish: OnPublish + + init(publishOptions: VideoPublishOptions, _ onPublish: @escaping OnPublish) { + providedPublishOptions = publishOptions + self.onPublish = onPublish + + simulcast = publishOptions.simulcast + preferredVideoCodec = publishOptions.preferredCodec + preferredBackupVideoCodec = publishOptions.preferredBackupCodec + } + + var body: some View { + VStack(alignment: .center, spacing: 10) { + Text("Publish options") + .fontWeight(.bold) + + Picker("Codec", selection: $preferredVideoCodec) { + Text("Auto").tag(nil as VideoCodec?) + ForEach(VideoCodec.all) { + Text($0.id.uppercased()).tag($0 as VideoCodec?) + } + }.onChange(of: preferredVideoCodec) { newValue in + if newValue?.isSVC ?? false { + preferredBackupVideoCodec = .vp8 + } else { + preferredBackupVideoCodec = nil + } + } + + Picker("Backup Codec", selection: $preferredBackupVideoCodec) { + Text("Off").tag(nil as VideoCodec?) + ForEach(VideoCodec.allBackup.filter { $0 != preferredVideoCodec }) { + Text($0.id.uppercased()).tag($0 as VideoCodec?) + } + }.disabled(!(preferredVideoCodec?.isSVC ?? false)) + + Toggle(isOn: $simulcast, label: { + Text("Simulcast") + }) + + Button("Publish") { + let result = VideoPublishOptions( + name: providedPublishOptions.name, + encoding: providedPublishOptions.encoding, + screenShareEncoding: providedPublishOptions.screenShareEncoding, + simulcast: simulcast, + simulcastLayers: providedPublishOptions.simulcastLayers, + screenShareSimulcastLayers: providedPublishOptions.screenShareSimulcastLayers, + preferredCodec: preferredVideoCodec, + preferredBackupCodec: preferredBackupVideoCodec + ) + + onPublish(result) + } + .keyboardShortcut(.defaultAction) + } + } +} diff --git a/Shared/Views/ScreenShareSourcePickerView.swift b/Shared/Views/ScreenShareSourcePickerView.swift index b72018d..f4f1014 100644 --- a/Shared/Views/ScreenShareSourcePickerView.swift +++ b/Shared/Views/ScreenShareSourcePickerView.swift @@ -1,25 +1,39 @@ -import Foundation +/* + * Copyright 2023 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 CoreGraphics -import SwiftUI +import Foundation import LiveKit +import SwiftUI #if os(macOS) -class ScreenShareSourcePickerCtrl: ObservableObject { - - @Published var tracks = [LocalVideoTrack]() - @Published var mode: ScreenShareSourcePickerView.Mode = .display { - didSet { - guard oldValue != mode else { return } - Task { - await restartTracks() + @available(macOS 12.3, *) + class ScreenShareSourcePickerCtrl: ObservableObject { + @Published var tracks = [LocalVideoTrack]() + @Published var mode: ScreenShareSourcePickerView.Mode = .display { + didSet { + guard oldValue != mode else { return } + Task { + try await restartTracks() + } } } - } - - private func restartTracks() async { - Task { + private func restartTracks() async throws { // stop in parallel await withThrowingTaskGroup(of: Void.self) { group in for track in tracks { @@ -29,7 +43,7 @@ class ScreenShareSourcePickerCtrl: ObservableObject { } } - let sources = try await MacOSScreenCapturer.sources(for: (mode == .display ? .display : .window)) + let sources = try await MacOSScreenCapturer.sources(for: mode == .display ? .display : .window) let options = ScreenShareCaptureOptions(dimensions: .h360_43, fps: 5) let _newTracks = sources.map { LocalVideoTrack.createMacOSScreenShareTrack(source: $0, options: options) } @@ -46,93 +60,91 @@ class ScreenShareSourcePickerCtrl: ObservableObject { } } } - } - init() { - Task { - await restartTracks() + init() { + Task { + try await restartTracks() + } } - } - - deinit { - print("\(type(of: self)) deinit") + deinit { + print("\(type(of: self)) deinit") - // copy - let _tracks = tracks + // copy + let _tracks = tracks - Task { - // stop in parallel - await withThrowingTaskGroup(of: Void.self) { group in - for track in _tracks { - group.addTask { - try await track.stop() + Task { + // stop in parallel + await withThrowingTaskGroup(of: Void.self) { group in + for track in _tracks { + group.addTask { + try await track.stop() + } } } } } } -} -typealias OnPickScreenShareSource = (MacOSScreenCaptureSource) -> Void + typealias OnPickScreenShareSource = (MacOSScreenCaptureSource) -> Void -struct ScreenShareSourcePickerView: View { - - public enum Mode { - case display - case window - } - - @ObservedObject var ctrl = ScreenShareSourcePickerCtrl() + @available(macOS 12.3, *) + struct ScreenShareSourcePickerView: View { + public enum Mode { + case display + case window + } - let onPickScreenShareSource: OnPickScreenShareSource? + @ObservedObject var ctrl = ScreenShareSourcePickerCtrl() - private var columns = [ - GridItem(.fixed(250)), - GridItem(.fixed(250)) - ] + let onPickScreenShareSource: OnPickScreenShareSource? - init(onPickScreenShareSource: OnPickScreenShareSource? = nil) { - self.onPickScreenShareSource = onPickScreenShareSource - } + private var columns = [ + GridItem(.fixed(250)), + GridItem(.fixed(250)), + ] - var body: some View { + init(onPickScreenShareSource: OnPickScreenShareSource? = nil) { + self.onPickScreenShareSource = onPickScreenShareSource + } - VStack { - Picker("", selection: $ctrl.mode) { - Text("Entire Screen").tag(ScreenShareSourcePickerView.Mode.display) - Text("Application Window").tag(ScreenShareSourcePickerView.Mode.window) - } - .pickerStyle(SegmentedPickerStyle()) - - ScrollView(.vertical, showsIndicators: true) { - LazyVGrid(columns: columns, - alignment: .center, - spacing: 10) { - - ForEach(ctrl.tracks) { track in - ZStack { - SwiftUIVideoView(track, layoutMode: .fit) - .aspectRatio(1, contentMode: .fit) - .onTapGesture { - guard let capturer = track.capturer as? MacOSScreenCapturer, - let source = capturer.captureSource else { return } - onPickScreenShareSource?(source) + var body: some View { + VStack { + Picker("", selection: $ctrl.mode) { + Text("Entire Screen").tag(ScreenShareSourcePickerView.Mode.display) + Text("Application Window").tag(ScreenShareSourcePickerView.Mode.window) + } + .pickerStyle(SegmentedPickerStyle()) + + ScrollView(.vertical, showsIndicators: true) { + LazyVGrid(columns: columns, + alignment: .center, + spacing: 10) + { + ForEach(ctrl.tracks) { track in + ZStack { + SwiftUIVideoView(track, layoutMode: .fit) + .aspectRatio(1, contentMode: .fit) + .onTapGesture { + guard let capturer = track.capturer as? MacOSScreenCapturer, + let source = capturer.captureSource else { return } + onPickScreenShareSource?(source) + } + + if let capturer = track.capturer as? MacOSScreenCapturer, + let source = capturer.captureSource as? MacOSWindow, + let appName = source.owningApplication?.applicationName + { + Text(appName) + .shadow(color: .black, radius: 1) } - - if let capturer = track.capturer as? MacOSScreenCapturer, - let source = capturer.captureSource as? MacOSWindow, - let appName = source.owningApplication?.applicationName { - Text(appName) - .shadow(color: .black, radius: 1) } } } } + .frame(minHeight: 350) } - .frame(minHeight: 350) } } -} #endif diff --git a/iOS/BroadcastExt/LoggingOSLog.swift b/iOS/BroadcastExt/LoggingOSLog.swift index a8aa4d6..d7a5619 100644 --- a/iOS/BroadcastExt/LoggingOSLog.swift +++ b/iOS/BroadcastExt/LoggingOSLog.swift @@ -1,26 +1,41 @@ +/* + * Copyright 2023 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 Foundation import Logging import os public struct LoggingOSLog: LogHandler { - public var logLevel: Logging.Logger.Level = .debug private let oslogger: OSLog public init(label: String) { - self.oslogger = OSLog(subsystem: label, category: "") + oslogger = OSLog(subsystem: label, category: "") } - public init(label: String, log: OSLog) { - self.oslogger = log + public init(label _: String, log: OSLog) { + oslogger = log } - public func log(level: Logging.Logger.Level, message: Logging.Logger.Message, metadata: Logging.Logger.Metadata?, file: String, function: String, line: UInt) { - var combinedPrettyMetadata = self.prettyMetadata + public func log(level _: Logging.Logger.Level, message: Logging.Logger.Message, metadata: Logging.Logger.Metadata?, file _: String, function _: String, line _: UInt) { + var combinedPrettyMetadata = prettyMetadata if let metadataOverride = metadata, !metadataOverride.isEmpty { - combinedPrettyMetadata = self.prettify( + combinedPrettyMetadata = prettify( self.metadata.merging(metadataOverride) { - return $1 + $1 } ) } @@ -29,13 +44,13 @@ public struct LoggingOSLog: LogHandler { if combinedPrettyMetadata != nil { formedMessage += " -- " + combinedPrettyMetadata! } - os_log("%{public}@", log: self.oslogger, type: OSLogType.from(loggerLevel: .info), formedMessage as NSString) + os_log("%{public}@", log: oslogger, type: OSLogType.from(loggerLevel: .info), formedMessage as NSString) } private var prettyMetadata: String? public var metadata = Logger.Metadata() { didSet { - self.prettyMetadata = self.prettify(self.metadata) + prettyMetadata = prettify(metadata) } } @@ -44,10 +59,10 @@ public struct LoggingOSLog: LogHandler { /// - metadataKey: the key for the metadata item. public subscript(metadataKey metadataKey: String) -> Logging.Logger.Metadata.Value? { get { - return self.metadata[metadataKey] + metadata[metadataKey] } set { - self.metadata[metadataKey] = newValue + metadata[metadataKey] = newValue } } diff --git a/iOS/BroadcastExt/SampleHandler.swift b/iOS/BroadcastExt/SampleHandler.swift index 74eddc7..796ae63 100644 --- a/iOS/BroadcastExt/SampleHandler.swift +++ b/iOS/BroadcastExt/SampleHandler.swift @@ -1,9 +1,18 @@ -// -// NewSampleHandler.swift -// BroadcastExt -// -// Created by David Liu on 6/15/22. -// +/* + * Copyright 2023 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 Foundation import LiveKit @@ -12,13 +21,12 @@ import OSLog private let broadcastLogger = OSLog(subsystem: "io.livekit.example.SwiftSDK", category: "Broadcast") class SampleHandler: LKSampleHandler { - - public override init() { + override public init() { // Turn on logging for the Broadcast Extension - LoggingSystem.bootstrap({ label in + LoggingSystem.bootstrap { label in var logHandler = LoggingOSLog(label: label, log: broadcastLogger) logHandler.logLevel = .debug return logHandler - }) + } } }