From db28c4e7d8ee159c06e9bb13d2b04b3d4bd97712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 21 Aug 2025 14:23:50 +0200 Subject: [PATCH 01/21] Extract logic --- VoiceAgent.xcodeproj/project.pbxproj | 130 -------- .../xcschemes/VoiceAgent.xcscheme | 9 +- VoiceAgent/App/AppView.swift | 66 ++-- VoiceAgent/App/AppViewModel.swift | 307 ------------------ VoiceAgent/Auth/TokenService.swift | 94 ------ VoiceAgent/Chat/ChatScrollView.swift | 43 +++ .../Chat/{View => }/ChatTextInputView.swift | 11 +- VoiceAgent/Chat/{View => }/ChatView.swift | 22 +- VoiceAgent/Chat/ChatViewModel.swift | 80 ----- VoiceAgent/Chat/Message.swift | 24 -- VoiceAgent/Chat/Receive/MessageReceiver.swift | 11 - .../TranscriptionDelegateReceiver.swift | 53 --- .../Receive/TranscriptionStreamReceiver.swift | 157 --------- VoiceAgent/Chat/Send/LocalMessageSender.swift | 40 --- VoiceAgent/Chat/Send/MessageSender.swift | 11 - .../{Devices => }/AudioDeviceSelector.swift | 8 +- VoiceAgent/ControlBar/ControlBar.swift | 53 +-- .../{Devices => }/VideoDeviceSelector.swift | 8 +- VoiceAgent/DI/Dependencies.swift | 50 --- VoiceAgent/Helpers/ObservableObject+.swift | 17 - VoiceAgent/Helpers/VideoTrack+.swift | 10 - VoiceAgent/Helpers/View+.swift | 16 - .../Interactions/TextInteractionView.swift | 10 +- .../Interactions/VisionInteractionView.swift | 8 +- .../Participant/AgentParticipantView.swift | 34 +- .../Participant/LocalParticipantView.swift | 8 +- VoiceAgent/Participant/ScreenShareView.swift | 4 +- VoiceAgent/Start/StartView.swift | 8 +- VoiceAgent/VoiceAgentApp.swift | 30 +- VoiceAgentTests/ChatViewModelTests.swift | 53 --- 30 files changed, 184 insertions(+), 1191 deletions(-) delete mode 100644 VoiceAgent/App/AppViewModel.swift delete mode 100644 VoiceAgent/Auth/TokenService.swift create mode 100644 VoiceAgent/Chat/ChatScrollView.swift rename VoiceAgent/Chat/{View => }/ChatTextInputView.swift (91%) rename VoiceAgent/Chat/{View => }/ChatView.swift (63%) delete mode 100644 VoiceAgent/Chat/ChatViewModel.swift delete mode 100644 VoiceAgent/Chat/Message.swift delete mode 100644 VoiceAgent/Chat/Receive/MessageReceiver.swift delete mode 100644 VoiceAgent/Chat/Receive/TranscriptionDelegateReceiver.swift delete mode 100644 VoiceAgent/Chat/Receive/TranscriptionStreamReceiver.swift delete mode 100644 VoiceAgent/Chat/Send/LocalMessageSender.swift delete mode 100644 VoiceAgent/Chat/Send/MessageSender.swift rename VoiceAgent/ControlBar/{Devices => }/AudioDeviceSelector.swift (71%) rename VoiceAgent/ControlBar/{Devices => }/VideoDeviceSelector.swift (72%) delete mode 100644 VoiceAgent/DI/Dependencies.swift delete mode 100644 VoiceAgent/Helpers/ObservableObject+.swift delete mode 100644 VoiceAgent/Helpers/VideoTrack+.swift delete mode 100644 VoiceAgentTests/ChatViewModelTests.swift diff --git a/VoiceAgent.xcodeproj/project.pbxproj b/VoiceAgent.xcodeproj/project.pbxproj index 9d1998a..b2fa494 100644 --- a/VoiceAgent.xcodeproj/project.pbxproj +++ b/VoiceAgent.xcodeproj/project.pbxproj @@ -23,13 +23,6 @@ remoteGlobalIDString = ACAEBA572DE6EE970072E93E; remoteInfo = BroadcastExtension; }; - ACC2802B2DEDDA1D0023C137 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = B5B5E3AA2D124AE00099C9BE /* Project object */; - proxyType = 1; - remoteGlobalIDString = B5B5E3B12D124AE00099C9BE; - remoteInfo = VoiceAgent; - }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -49,7 +42,6 @@ /* Begin PBXFileReference section */ ACAEBA582DE6EE970072E93E /* BroadcastExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = BroadcastExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; ACAEBA5A2DE6EE970072E93E /* ReplayKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ReplayKit.framework; path = System/Library/Frameworks/ReplayKit.framework; sourceTree = SDKROOT; }; - ACC280272DEDDA1D0023C137 /* VoiceAgentTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = VoiceAgentTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B5B5E3B22D124AE00099C9BE /* VoiceAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = VoiceAgent.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -82,11 +74,6 @@ path = BroadcastExtension; sourceTree = ""; }; - ACC280282DEDDA1D0023C137 /* VoiceAgentTests */ = { - isa = PBXFileSystemSynchronizedRootGroup; - path = VoiceAgentTests; - sourceTree = ""; - }; B5B5E3B42D124AE00099C9BE /* VoiceAgent */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -107,13 +94,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - ACC280242DEDDA1D0023C137 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; B5B5E3AF2D124AE00099C9BE /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -140,7 +120,6 @@ children = ( B5B5E3B42D124AE00099C9BE /* VoiceAgent */, ACAEBA5C2DE6EE970072E93E /* BroadcastExtension */, - ACC280282DEDDA1D0023C137 /* VoiceAgentTests */, ACAEBA592DE6EE970072E93E /* Frameworks */, B5B5E3B32D124AE00099C9BE /* Products */, ); @@ -151,7 +130,6 @@ children = ( B5B5E3B22D124AE00099C9BE /* VoiceAgent.app */, ACAEBA582DE6EE970072E93E /* BroadcastExtension.appex */, - ACC280272DEDDA1D0023C137 /* VoiceAgentTests.xctest */, ); name = Products; sourceTree = ""; @@ -182,29 +160,6 @@ productReference = ACAEBA582DE6EE970072E93E /* BroadcastExtension.appex */; productType = "com.apple.product-type.app-extension"; }; - ACC280262DEDDA1D0023C137 /* VoiceAgentTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = ACC2802F2DEDDA1D0023C137 /* Build configuration list for PBXNativeTarget "VoiceAgentTests" */; - buildPhases = ( - ACC280232DEDDA1D0023C137 /* Sources */, - ACC280242DEDDA1D0023C137 /* Frameworks */, - ACC280252DEDDA1D0023C137 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ACC2802C2DEDDA1D0023C137 /* PBXTargetDependency */, - ); - fileSystemSynchronizedGroups = ( - ACC280282DEDDA1D0023C137 /* VoiceAgentTests */, - ); - name = VoiceAgentTests; - packageProductDependencies = ( - ); - productName = VoiceAgentTests; - productReference = ACC280272DEDDA1D0023C137 /* VoiceAgentTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; B5B5E3B12D124AE00099C9BE /* VoiceAgent */ = { isa = PBXNativeTarget; buildConfigurationList = B5B5E3C12D124AE20099C9BE /* Build configuration list for PBXNativeTarget "VoiceAgent" */; @@ -245,10 +200,6 @@ ACAEBA572DE6EE970072E93E = { CreatedOnToolsVersion = 16.3; }; - ACC280262DEDDA1D0023C137 = { - CreatedOnToolsVersion = 16.3; - TestTargetID = B5B5E3B12D124AE00099C9BE; - }; B5B5E3B12D124AE00099C9BE = { CreatedOnToolsVersion = 16.2; }; @@ -274,7 +225,6 @@ projectRoot = ""; targets = ( B5B5E3B12D124AE00099C9BE /* VoiceAgent */, - ACC280262DEDDA1D0023C137 /* VoiceAgentTests */, ACAEBA572DE6EE970072E93E /* BroadcastExtension */, ); }; @@ -288,13 +238,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - ACC280252DEDDA1D0023C137 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; B5B5E3B02D124AE00099C9BE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -312,13 +255,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - ACC280232DEDDA1D0023C137 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; B5B5E3AE2D124AE00099C9BE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -338,11 +274,6 @@ target = ACAEBA572DE6EE970072E93E /* BroadcastExtension */; targetProxy = ACAEBA602DE6EE970072E93E /* PBXContainerItemProxy */; }; - ACC2802C2DEDDA1D0023C137 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = B5B5E3B12D124AE00099C9BE /* VoiceAgent */; - targetProxy = ACC2802B2DEDDA1D0023C137 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -411,58 +342,6 @@ }; name = Release; }; - ACC2802D2DEDDA1D0023C137 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 76TVFCUKK7; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.livekit.VoiceAgentTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VoiceAgent.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VoiceAgent"; - XROS_DEPLOYMENT_TARGET = 2.0; - }; - name = Debug; - }; - ACC2802E2DEDDA1D0023C137 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_IDENTITY = "Apple Development"; - "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; - CODE_SIGN_STYLE = Automatic; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = 76TVFCUKK7; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MACOSX_DEPLOYMENT_TARGET = 15.0; - MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.livekit.VoiceAgentTests; - PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,7"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/VoiceAgent.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/VoiceAgent"; - XROS_DEPLOYMENT_TARGET = 2.0; - }; - name = Release; - }; B5B5E3BF2D124AE20099C9BE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -689,15 +568,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - ACC2802F2DEDDA1D0023C137 /* Build configuration list for PBXNativeTarget "VoiceAgentTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - ACC2802D2DEDDA1D0023C137 /* Debug */, - ACC2802E2DEDDA1D0023C137 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; B5B5E3AD2D124AE00099C9BE /* Build configuration list for PBXProject "VoiceAgent" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/VoiceAgent.xcodeproj/xcshareddata/xcschemes/VoiceAgent.xcscheme b/VoiceAgent.xcodeproj/xcshareddata/xcschemes/VoiceAgent.xcscheme index e1b74c4..622a245 100644 --- a/VoiceAgent.xcodeproj/xcshareddata/xcschemes/VoiceAgent.xcscheme +++ b/VoiceAgent.xcodeproj/xcshareddata/xcschemes/VoiceAgent.xcscheme @@ -27,13 +27,8 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - shouldUseLaunchSchemeArgsEnv = "YES"> - - - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> some View { StartView() + .onAppear { + chat = false + } } @ViewBuilder private func interactions() -> some View { #if os(visionOS) - VisionInteractionView(keyboardFocus: $keyboardFocus) - .environment(chatViewModel) + VisionInteractionView(chat: chat, keyboardFocus: $keyboardFocus) .overlay(alignment: .bottom) { agentListening() .padding(16 * .grid) } #else - switch viewModel.interactionMode { - case .text: + if chat { TextInteractionView(keyboardFocus: $keyboardFocus) - .environment(chatViewModel) - case .voice: + } else { VoiceInteractionView() .overlay(alignment: .bottom) { agentListening() @@ -85,12 +83,12 @@ struct AppView: View { @ViewBuilder private func errors() -> some View { #if !os(visionOS) - if case .reconnecting = viewModel.connectionState { + if case .reconnecting = conversation.connectionState { WarningView(warning: "warning.reconnecting") } - if let error { - ErrorView(error: error) { self.error = nil } + if let error = conversation.error { + ErrorView(error: error) { conversation.resetError() } } #endif } @@ -98,14 +96,14 @@ struct AppView: View { @ViewBuilder private func agentListening() -> some View { ZStack { - if chatViewModel.messages.isEmpty, - !viewModel.isCameraEnabled, - !viewModel.isScreenShareEnabled + if conversation.messages.isEmpty, + !localMedia.isCameraEnabled, + !localMedia.isScreenShareEnabled { AgentListeningView() } } - .animation(.default, value: chatViewModel.messages.isEmpty) + .animation(.default, value: conversation.messages.isEmpty) } } diff --git a/VoiceAgent/App/AppViewModel.swift b/VoiceAgent/App/AppViewModel.swift deleted file mode 100644 index 205284e..0000000 --- a/VoiceAgent/App/AppViewModel.swift +++ /dev/null @@ -1,307 +0,0 @@ -@preconcurrency import AVFoundation -import Combine -import LiveKit -import Observation - -/// The main view model encapsulating root states and behaviors of the app -/// such as connection, published tracks, etc. -/// -/// It consumes `LiveKit.Room` object, observing its internal state and propagating appropriate changes. -/// It does not expose any publicly mutable state, encouraging unidirectional data flow. -@MainActor -@Observable -final class AppViewModel { - // MARK: - Constants - - private enum Constants { - static let agentConnectionTimeout: TimeInterval = 20 - } - - // MARK: - Errors - - enum Error: LocalizedError { - case agentNotConnected - - var errorDescription: String? { - switch self { - case .agentNotConnected: - "Agent did not connect to the Room" - } - } - } - - // MARK: - Modes - - enum InteractionMode { - case voice - case text - } - - let agentFeatures: AgentFeatures - - // MARK: - State - - // MARK: Connection - - private(set) var connectionState: ConnectionState = .disconnected - private(set) var isListening = false - var isInteractive: Bool { - switch connectionState { - case .disconnected where isListening, - .connecting where isListening, - .connected, - .reconnecting: - true - default: - false - } - } - - private(set) var agent: Participant? - - private(set) var interactionMode: InteractionMode = .voice - - // MARK: Tracks - - private(set) var isMicrophoneEnabled = false - private(set) var audioTrack: (any AudioTrack)? - private(set) var isCameraEnabled = false - private(set) var cameraTrack: (any VideoTrack)? - private(set) var isScreenShareEnabled = false - private(set) var screenShareTrack: (any VideoTrack)? - - private(set) var agentAudioTrack: (any AudioTrack)? - private(set) var avatarCameraTrack: (any VideoTrack)? - - // MARK: Devices - - private(set) var audioDevices: [AudioDevice] = AudioManager.shared.inputDevices - private(set) var selectedAudioDeviceID: String = AudioManager.shared.inputDevice.deviceId - - private(set) var videoDevices: [AVCaptureDevice] = [] - private(set) var selectedVideoDeviceID: String? - - private(set) var canSwitchCamera = false - - // MARK: - Dependencies - - @ObservationIgnored - @Dependency(\.room) private var room - @ObservationIgnored - @Dependency(\.tokenService) private var tokenService - @ObservationIgnored - @Dependency(\.errorHandler) private var errorHandler - - // MARK: - Initialization - - init(agentFeatures: AgentFeatures = .current) { - self.agentFeatures = agentFeatures - - observeRoom() - observeDevices() - } - - private func observeRoom() { - Task { [weak self] in - guard let changes = self?.room.changes else { return } - for await _ in changes { - guard let self else { return } - - connectionState = room.connectionState - agent = room.agentParticipant - - isMicrophoneEnabled = room.localParticipant.isMicrophoneEnabled() - audioTrack = room.localParticipant.firstAudioTrack - isCameraEnabled = room.localParticipant.isCameraEnabled() - cameraTrack = room.localParticipant.firstCameraVideoTrack - isScreenShareEnabled = room.localParticipant.isScreenShareEnabled() - screenShareTrack = room.localParticipant.firstScreenShareVideoTrack - - agentAudioTrack = room.agentParticipant?.audioTracks - .first(where: { $0.source == .microphone })?.track as? AudioTrack - avatarCameraTrack = room.agentParticipant?.avatarWorker?.firstCameraVideoTrack - } - } - } - - private func observeDevices() { - Task { - do { - try AudioManager.shared.set(microphoneMuteMode: .inputMixer) // don't play mute sound effect - try await AudioManager.shared.setRecordingAlwaysPreparedMode(true) - - AudioManager.shared.onDeviceUpdate = { [weak self] _ in - Task { @MainActor in - self?.audioDevices = AudioManager.shared.inputDevices - self?.selectedAudioDeviceID = AudioManager.shared.defaultInputDevice.deviceId - } - } - - canSwitchCamera = try await CameraCapturer.canSwitchPosition() - videoDevices = try await CameraCapturer.captureDevices() - selectedVideoDeviceID = videoDevices.first?.uniqueID - } catch { - errorHandler(error) - } - } - } - - deinit { - AudioManager.shared.onDeviceUpdate = nil - } - - private func resetState() { - isListening = false - interactionMode = .voice - } - - // MARK: - Connection - - func connect() async { - errorHandler(nil) - resetState() - do { - if agentFeatures.contains(.voice) { - try await connectWithVoice() - } else { - try await connectWithoutVoice() - } - - try await checkAgentConnected() - } catch { - errorHandler(error) - resetState() - } - } - - /// Connect and enable microphone, capture pre-connect audio - private func connectWithVoice() async throws { - try await room.withPreConnectAudio { - await MainActor.run { self.isListening = true } - - let connectionDetails = try await self.getConnection() - - try await self.room.connect( - url: connectionDetails.serverUrl, - token: connectionDetails.participantToken, - connectOptions: .init(enableMicrophone: true) - ) - } - } - - /// Connect without enabling microphone - private func connectWithoutVoice() async throws { - let connectionDetails = try await getConnection() - - try await room.connect( - url: connectionDetails.serverUrl, - token: connectionDetails.participantToken, - connectOptions: .init(enableMicrophone: false) - ) - } - - private func getConnection() async throws -> TokenService.ConnectionDetails { - let roomName = "room-\(Int.random(in: 1000 ... 9999))" - let participantName = "user-\(Int.random(in: 1000 ... 9999))" - - return try await tokenService.fetchConnectionDetails( - roomName: roomName, - participantName: participantName - )! - } - - func disconnect() async { - await room.disconnect() - resetState() - } - - private func checkAgentConnected() async throws { - try await Task.sleep(for: .seconds(Constants.agentConnectionTimeout)) - if connectionState == .connected, agent == nil { - await disconnect() - throw Error.agentNotConnected - } - } - - // MARK: - Actions - - func toggleTextInput() { - switch interactionMode { - case .voice: - interactionMode = .text - case .text: - interactionMode = .voice - } - } - - func toggleMicrophone() async { - do { - try await room.localParticipant.setMicrophone(enabled: !isMicrophoneEnabled) - } catch { - errorHandler(error) - } - } - - func toggleCamera() async { - let enable = !isCameraEnabled - do { - // One video track at a time - if enable, isScreenShareEnabled { - try await room.localParticipant.setScreenShare(enabled: false) - } - - let device = try await CameraCapturer.captureDevices().first(where: { $0.uniqueID == selectedVideoDeviceID }) - try await room.localParticipant.setCamera(enabled: enable, captureOptions: CameraCaptureOptions(device: device)) - } catch { - errorHandler(error) - } - } - - func toggleScreenShare() async { - let enable = !isScreenShareEnabled - do { - // One video track at a time - if enable, isCameraEnabled { - try await room.localParticipant.setCamera(enabled: false) - } - try await room.localParticipant.setScreenShare(enabled: enable) - } catch { - errorHandler(error) - } - } - - #if os(macOS) - func select(audioDevice: AudioDevice) { - selectedAudioDeviceID = audioDevice.deviceId - - let device = AudioManager.shared.inputDevices.first(where: { $0.deviceId == selectedAudioDeviceID }) ?? AudioManager.shared.defaultInputDevice - AudioManager.shared.inputDevice = device - } - - func select(videoDevice: AVCaptureDevice) async { - selectedVideoDeviceID = videoDevice.uniqueID - - guard let cameraCapturer = getCameraCapturer() else { return } - do { - let captureOptions = CameraCaptureOptions(device: videoDevice) - try await cameraCapturer.set(options: captureOptions) - } catch { - errorHandler(error) - } - } - #endif - - func switchCamera() async { - guard let cameraCapturer = getCameraCapturer() else { return } - do { - try await cameraCapturer.switchCameraPosition() - } catch { - errorHandler(error) - } - } - - private func getCameraCapturer() -> CameraCapturer? { - guard let cameraTrack = cameraTrack as? LocalVideoTrack else { return nil } - return cameraTrack.capturer as? CameraCapturer - } -} diff --git a/VoiceAgent/Auth/TokenService.swift b/VoiceAgent/Auth/TokenService.swift deleted file mode 100644 index 3880480..0000000 --- a/VoiceAgent/Auth/TokenService.swift +++ /dev/null @@ -1,94 +0,0 @@ -import Foundation - -/// An example service for fetching LiveKit authentication tokens -/// -/// To use the LiveKit Cloud sandbox (development only) -/// - Enable your sandbox here https://cloud.livekit.io/projects/p_/sandbox/templates/token-server -/// - Create .env.xcconfig with your LIVEKIT_SANDBOX_ID -/// -/// To use a hardcoded token (development only) -/// - Generate a token: https://docs.livekit.io/home/cli/cli-setup/#generate-access-token -/// - Set `hardcodedServerUrl` and `hardcodedToken` below -/// -/// To use your own server (production applications) -/// - Add a token endpoint to your server with a LiveKit Server SDK https://docs.livekit.io/home/server/generating-tokens/ -/// - Modify or replace this class as needed to connect to your new token server -/// - Rejoice in your new production-ready LiveKit application! -/// -/// See [docs](https://docs.livekit.io/home/get-started/authentication) for more information. -actor TokenService { - struct ConnectionDetails: Codable { - let serverUrl: String - let roomName: String - let participantName: String - let participantToken: String - } - - func fetchConnectionDetails(roomName: String, participantName: String) async throws -> ConnectionDetails? { - if let hardcodedConnectionDetails = fetchHardcodedConnectionDetails(roomName: roomName, participantName: participantName) { - return hardcodedConnectionDetails - } - - return try await fetchConnectionDetailsFromSandbox(roomName: roomName, participantName: participantName) - } - - private let hardcodedServerUrl: String? = nil - private let hardcodedToken: String? = nil - - private let sandboxId: String? = { - if let value = Bundle.main.object(forInfoDictionaryKey: "LiveKitSandboxId") as? String { - // LK CLI will add unwanted double quotes - return value.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) - } - return nil - }() - - private let sandboxUrl: String = "https://cloud-api.livekit.io/api/sandbox/connection-details" - private func fetchConnectionDetailsFromSandbox(roomName: String, participantName: String) async throws -> ConnectionDetails? { - guard let sandboxId else { - return nil - } - - var urlComponents = URLComponents(string: sandboxUrl)! - urlComponents.queryItems = [ - URLQueryItem(name: "roomName", value: roomName), - URLQueryItem(name: "participantName", value: participantName), - ] - - var request = URLRequest(url: urlComponents.url!) - request.httpMethod = "POST" - request.addValue(sandboxId, forHTTPHeaderField: "X-Sandbox-ID") - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - debugPrint("Failed to connect to LiveKit Cloud sandbox") - return nil - } - - guard (200 ... 299).contains(httpResponse.statusCode) else { - debugPrint("Error from LiveKit Cloud sandbox: \(httpResponse.statusCode), response: \(httpResponse)") - return nil - } - - guard let connectionDetails = try? JSONDecoder().decode(ConnectionDetails.self, from: data) else { - debugPrint("Error parsing connection details from LiveKit Cloud sandbox, response: \(httpResponse)") - return nil - } - - return connectionDetails - } - - private func fetchHardcodedConnectionDetails(roomName: String, participantName: String) -> ConnectionDetails? { - guard let serverUrl = hardcodedServerUrl, let token = hardcodedToken else { - return nil - } - - return .init( - serverUrl: serverUrl, - roomName: roomName, - participantName: participantName, - participantToken: token - ) - } -} diff --git a/VoiceAgent/Chat/ChatScrollView.swift b/VoiceAgent/Chat/ChatScrollView.swift new file mode 100644 index 0000000..0071649 --- /dev/null +++ b/VoiceAgent/Chat/ChatScrollView.swift @@ -0,0 +1,43 @@ +import LiveKit +import SwiftUI + +struct ChatScrollView: View { + typealias MessageBuilder = (ReceivedMessage) -> Content + + @LKConversation private var conversation + let messageBuilder: MessageBuilder + + var body: some View { + ScrollViewReader { scrollView in + ScrollView { + LazyVStack { + ForEach(conversation.messages.values.reversed(), content: { message in + messageBuilder(message) + .upsideDown() + .id(message.id) + }) + } + } + .onChange(of: conversation.messages.count) { + scrollView.scrollTo(conversation.messages.keys.last) + } + .upsideDown() + .scrollIndicators(.never) + .animation(.default, value: conversation.messages) + } + } +} + +private struct UpsideDown: ViewModifier { + func body(content: Content) -> some View { + content + .rotationEffect(.radians(Double.pi)) + .scaleEffect(x: -1, y: 1, anchor: .center) + } +} + +private extension View { + func upsideDown() -> some View { + modifier(UpsideDown()) + } +} diff --git a/VoiceAgent/Chat/View/ChatTextInputView.swift b/VoiceAgent/Chat/ChatTextInputView.swift similarity index 91% rename from VoiceAgent/Chat/View/ChatTextInputView.swift rename to VoiceAgent/Chat/ChatTextInputView.swift index ceb58cc..3886692 100644 --- a/VoiceAgent/Chat/View/ChatTextInputView.swift +++ b/VoiceAgent/Chat/ChatTextInputView.swift @@ -1,8 +1,9 @@ +import LiveKit import SwiftUI /// A multiplatform view that shows the chat input text field and send button. struct ChatTextInputView: View { - @Environment(ChatViewModel.self) private var chatViewModel + @LKConversation private var conversation @Environment(\.horizontalSizeClass) private var horizontalSizeClass @FocusState.Binding var keyboardFocus: Bool @@ -82,12 +83,6 @@ struct ChatTextInputView: View { let text = messageText messageText = "" keyboardFocus = false - await chatViewModel.sendMessage(text) + await conversation.send(text: text) } } - -#Preview { - @FocusState var focus - ChatTextInputView(keyboardFocus: $focus) - .environment(ChatViewModel()) -} diff --git a/VoiceAgent/Chat/View/ChatView.swift b/VoiceAgent/Chat/ChatView.swift similarity index 63% rename from VoiceAgent/Chat/View/ChatView.swift rename to VoiceAgent/Chat/ChatView.swift index 8a723b0..c322a00 100644 --- a/VoiceAgent/Chat/View/ChatView.swift +++ b/VoiceAgent/Chat/ChatView.swift @@ -1,38 +1,22 @@ +import LiveKit import SwiftUI -/// A multiplatform view that shows the message feed. struct ChatView: View { - @Environment(ChatViewModel.self) private var viewModel - var body: some View { - ScrollViewReader { scrollView in - ScrollView { - LazyVStack { - ForEach(viewModel.messages.values.reversed(), content: message) - } - } - .onChange(of: viewModel.messages.count) { - scrollView.scrollTo(viewModel.messages.keys.last) - } - .upsideDown() + ChatScrollView(messageBuilder: message) .padding(.horizontal) - .scrollIndicators(.never) - .animation(.default, value: viewModel.messages) - } } @ViewBuilder private func message(_ message: ReceivedMessage) -> some View { ZStack { switch message.content { - case let .userTranscript(text): + case let .userTranscript(text), let .userInput(text): userTranscript(text) case let .agentTranscript(text): agentTranscript(text) } } - .upsideDown() - .id(message.id) // for the ScrollViewReader to work } @ViewBuilder diff --git a/VoiceAgent/Chat/ChatViewModel.swift b/VoiceAgent/Chat/ChatViewModel.swift deleted file mode 100644 index bc32665..0000000 --- a/VoiceAgent/Chat/ChatViewModel.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Collections -import Foundation -import LiveKit -import Observation - -/// A view model that aggregates messages from multiple message providers (senders and receivers) -/// and exposes a single entry point for the UI to interact with the message feed. -/// -/// It does not expose any publicly mutable state, encouraging unidirectional data flow. -@MainActor -@Observable -final class ChatViewModel { - // MARK: - State - - private(set) var messages: OrderedDictionary = [:] - - // MARK: - Dependencies - - @ObservationIgnored - @Dependency(\.room) private var room - @ObservationIgnored - @Dependency(\.messageReceivers) private var messageReceivers - @ObservationIgnored - @Dependency(\.messageSenders) private var messageSenders - @ObservationIgnored - @Dependency(\.errorHandler) private var errorHandler - - // MARK: - Initialization - - init() { - observeMessages() - observeRoom() - } - - // MARK: - Private - - private func observeMessages() { - for messageReceiver in messageReceivers { - Task { [weak self] in - do { - for await message in try await messageReceiver.messages() { - guard let self else { return } - messages.updateValue(message, forKey: message.id) - } - } catch { - self?.errorHandler(error) - } - } - } - } - - private func observeRoom() { - Task { [weak self] in - guard let changes = self?.room.changes else { return } - for await _ in changes { - guard let self else { return } - if room.connectionState == .disconnected { - clearHistory() - } - } - } - } - - private func clearHistory() { - messages.removeAll() - } - - // MARK: - Actions - - func sendMessage(_ text: String) async { - let message = SentMessage(id: UUID().uuidString, timestamp: Date(), content: .userText(text)) - do { - for sender in messageSenders { - try await sender.send(message) - } - } catch { - errorHandler(error) - } - } -} diff --git a/VoiceAgent/Chat/Message.swift b/VoiceAgent/Chat/Message.swift deleted file mode 100644 index 163df3b..0000000 --- a/VoiceAgent/Chat/Message.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -/// A message received from the agent. -struct ReceivedMessage: Identifiable, Equatable, Sendable { - let id: String - let timestamp: Date - let content: Content - - enum Content: Equatable, Sendable { - case agentTranscript(String) - case userTranscript(String) - } -} - -/// A message sent to the agent. -struct SentMessage: Identifiable, Equatable, Sendable { - let id: String - let timestamp: Date - let content: Content - - enum Content: Equatable, Sendable { - case userText(String) - } -} diff --git a/VoiceAgent/Chat/Receive/MessageReceiver.swift b/VoiceAgent/Chat/Receive/MessageReceiver.swift deleted file mode 100644 index 6394d6d..0000000 --- a/VoiceAgent/Chat/Receive/MessageReceiver.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -/// A protocol that defines a message receiver. -/// -/// A message receiver is responsible for creating a stream of messages from the agent. -/// It is used to receive messages from the agent and update the message feed. -/// -/// - SeeAlso: ``ReceivedMessage`` -protocol MessageReceiver: Sendable { - func messages() async throws -> AsyncStream -} diff --git a/VoiceAgent/Chat/Receive/TranscriptionDelegateReceiver.swift b/VoiceAgent/Chat/Receive/TranscriptionDelegateReceiver.swift deleted file mode 100644 index 8c9d5d8..0000000 --- a/VoiceAgent/Chat/Receive/TranscriptionDelegateReceiver.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation -import LiveKit - -/// An actor that receives transcription messages from the room and yields them as messages. -/// -/// Room delegate methods are called multiple times for each message, with a stable message ID -/// that can be direcly used for diffing. -/// -/// Example: -/// ``` -/// { id: "1", content: "Hello" } -/// { id: "1", content: "Hello world!" } -/// ``` -@available(*, deprecated, message: "Use TranscriptionStreamReceiver compatible with livekit-agents 1.0") -actor TranscriptionDelegateReceiver: MessageReceiver, RoomDelegate { - private let room: Room - private var continuation: AsyncStream.Continuation? - - init(room: Room) { - self.room = room - room.add(delegate: self) - } - - deinit { - room.remove(delegate: self) - } - - /// Creates a new message stream for the transcription delegate receiver. - func messages() -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream(of: ReceivedMessage.self) - self.continuation = continuation - return stream - } - - nonisolated func room(_: Room, participant: Participant, trackPublication _: TrackPublication, didReceiveTranscriptionSegments segments: [TranscriptionSegment]) { - segments - .filter { !$0.text.isEmpty } - .forEach { segment in - let message = ReceivedMessage( - id: segment.id, - timestamp: segment.lastReceivedTime, - content: participant.isAgent ? .agentTranscript(segment.text) : .userTranscript(segment.text) - ) - Task { - await yield(message) - } - } - } - - private func yield(_ message: ReceivedMessage) { - continuation?.yield(message) - } -} diff --git a/VoiceAgent/Chat/Receive/TranscriptionStreamReceiver.swift b/VoiceAgent/Chat/Receive/TranscriptionStreamReceiver.swift deleted file mode 100644 index f0bb51a..0000000 --- a/VoiceAgent/Chat/Receive/TranscriptionStreamReceiver.swift +++ /dev/null @@ -1,157 +0,0 @@ -import Foundation -import LiveKit - -/// An actor that converts raw text streams from the LiveKit `Room` into `Message` objects. -/// - Note: Streams are supported by `livekit-agents` >= 1.0.0. -/// - SeeAlso: ``TranscriptionDelegateReceiver`` -/// -/// For agent messages, new text stream is emitted for each message, and the stream is closed when the message is finalized. -/// Each agent message is delivered in chunks, that are accumulated and published into the message stream. -/// -/// For user messages, the full transcription is sent each time, but may be updated until finalized. -/// -/// The ID of the segment is stable and unique across the lifetime of the message. -/// This ID can be used directly for `Identifiable` conformance. -/// -/// Example text stream for agent messages: -/// ``` -/// { segment_id: "1", content: "Hello" } -/// { segment_id: "1", content: " world" } -/// { segment_id: "1", content: "!" } -/// { segment_id: "2", content: "Hello" } -/// { segment_id: "2", content: " Apple" } -/// { segment_id: "2", content: "!" } -/// ``` -/// -/// Example text stream for user messages: -/// ``` -/// { segment_id: "3", content: "Hello" } -/// { segment_id: "3", content: "Hello world!" } -/// { segment_id: "4", content: "Hello" } -/// { segment_id: "4", content: "Hello Apple!" } -/// ``` -/// -/// Example output: -/// ``` -/// Message(id: "1", timestamp: 2025-01-01 12:00:00 +0000, content: .agentTranscript("Hello world!")) -/// Message(id: "2", timestamp: 2025-01-01 12:00:10 +0000, content: .agentTranscript("Hello Apple!")) -/// Message(id: "3", timestamp: 2025-01-01 12:00:20 +0000, content: .userTranscript("Hello world!")) -/// Message(id: "4", timestamp: 2025-01-01 12:00:30 +0000, content: .userTranscript("Hello Apple!")) -/// ``` -/// -actor TranscriptionStreamReceiver: MessageReceiver { - private struct PartialMessageID: Hashable { - let segmentID: String - let participantID: Participant.Identity - } - - private struct PartialMessage { - var content: String - let timestamp: Date - var streamID: String - - mutating func appendContent(_ newContent: String) { - content += newContent - } - - mutating func replaceContent(_ newContent: String, streamID: String) { - content = newContent - self.streamID = streamID - } - } - - private let transcriptionTopic = "lk.transcription" - private enum TranscriptionAttributes: String { - case final = "lk.transcription_final" - case segment = "lk.segment_id" - } - - private let room: Room - - private lazy var partialMessages: [PartialMessageID: PartialMessage] = [:] - - init(room: Room) { - self.room = room - } - - /// Creates a new message stream for the chat topic. - func messages() async throws -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream(of: ReceivedMessage.self) - - try await room.registerTextStreamHandler(for: transcriptionTopic) { [weak self] reader, participantIdentity in - guard let self else { return } - for try await message in reader where !message.isEmpty { - await continuation.yield(processIncoming(partialMessage: message, reader: reader, participantIdentity: participantIdentity)) - } - } - - continuation.onTermination = { [weak self] _ in - Task { - guard let self else { return } - await self.room.unregisterTextStreamHandler(for: self.transcriptionTopic) - } - } - - return stream - } - - /// Aggregates the incoming text into a message, storing the partial content in the `partialMessages` dictionary. - /// - Note: When the message is finalized, or a new message is started, the dictionary is purged to limit memory usage. - private func processIncoming(partialMessage message: String, reader: TextStreamReader, participantIdentity: Participant.Identity) -> ReceivedMessage { - let segmentID = reader.info.attributes[TranscriptionAttributes.segment.rawValue] ?? reader.info.id - let participantID = participantIdentity - let partialID = PartialMessageID(segmentID: segmentID, participantID: participantID) - - let currentStreamID = reader.info.id - - let timestamp: Date - let updatedContent: String - - if var existingMessage = partialMessages[partialID] { - // Update existing message - if existingMessage.streamID == currentStreamID { - // Same stream, append content - existingMessage.appendContent(message) - } else { - // Different stream for same segment, replace content - existingMessage.replaceContent(message, streamID: currentStreamID) - } - updatedContent = existingMessage.content - timestamp = existingMessage.timestamp - partialMessages[partialID] = existingMessage - } else { - // This is a new message - updatedContent = message - timestamp = reader.info.timestamp - partialMessages[partialID] = PartialMessage( - content: updatedContent, - timestamp: timestamp, - streamID: currentStreamID - ) - cleanupPreviousTurn(participantIdentity, exceptSegmentID: segmentID) - } - - let isFinal = reader.info.attributes[TranscriptionAttributes.final.rawValue] == "true" - if isFinal { - partialMessages[partialID] = nil - } - - let newOrUpdatedMessage = ReceivedMessage( - id: segmentID, - timestamp: timestamp, - content: participantIdentity == room.localParticipant.identity ? .userTranscript(updatedContent) : .agentTranscript(updatedContent) - ) - - return newOrUpdatedMessage - } - - private func cleanupPreviousTurn(_ participantID: Participant.Identity, exceptSegmentID: String) { - let keysToRemove = partialMessages.keys.filter { - $0.participantID == participantID && $0.segmentID != exceptSegmentID - } - - for key in keysToRemove { - partialMessages[key] = nil - } - } -} diff --git a/VoiceAgent/Chat/Send/LocalMessageSender.swift b/VoiceAgent/Chat/Send/LocalMessageSender.swift deleted file mode 100644 index cfad208..0000000 --- a/VoiceAgent/Chat/Send/LocalMessageSender.swift +++ /dev/null @@ -1,40 +0,0 @@ -import Foundation -import LiveKit - -/// An actor that sends local messages to the agent. -/// Currently, it only supports sending text messages. -/// -/// It also serves as the loopback for the local messages, -/// so that they can be displayed in the message feed -/// without relying on the agent-side transcription. -actor LocalMessageSender: MessageSender, MessageReceiver { - private let room: Room - private let topic: String - - private var messageContinuation: AsyncStream.Continuation? - - init(room: Room, topic: String = "lk.chat") { - self.room = room - self.topic = topic - } - - func send(_ message: SentMessage) async throws { - guard case let .userText(text) = message.content else { return } - - try await room.localParticipant.sendText(text, for: topic) - - let loopbackMessage = ReceivedMessage( - id: message.id, - timestamp: message.timestamp, - content: .userTranscript(text) - ) - - messageContinuation?.yield(loopbackMessage) - } - - func messages() async throws -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream() - messageContinuation = continuation - return stream - } -} diff --git a/VoiceAgent/Chat/Send/MessageSender.swift b/VoiceAgent/Chat/Send/MessageSender.swift deleted file mode 100644 index 9cd39e2..0000000 --- a/VoiceAgent/Chat/Send/MessageSender.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -/// A protocol that defines a message sender. -/// -/// A message sender is responsible for sending messages to the agent. -/// It is used to send messages to the agent and update the message feed. -/// -/// - SeeAlso: ``SentMessage`` -protocol MessageSender: Sendable { - func send(_ message: SentMessage) async throws -} diff --git a/VoiceAgent/ControlBar/Devices/AudioDeviceSelector.swift b/VoiceAgent/ControlBar/AudioDeviceSelector.swift similarity index 71% rename from VoiceAgent/ControlBar/Devices/AudioDeviceSelector.swift rename to VoiceAgent/ControlBar/AudioDeviceSelector.swift index c18e9f8..bfa1bb6 100644 --- a/VoiceAgent/ControlBar/Devices/AudioDeviceSelector.swift +++ b/VoiceAgent/ControlBar/AudioDeviceSelector.swift @@ -3,17 +3,17 @@ import SwiftUI #if os(macOS) /// A platform-specific view that shows a list of available audio devices. struct AudioDeviceSelector: View { - @Environment(AppViewModel.self) private var viewModel + @EnvironmentObject private var devices: DeviceSwitcher var body: some View { Menu { - ForEach(viewModel.audioDevices, id: \.deviceId) { device in + ForEach(devices.audioDevices, id: \.deviceId) { device in Button { - viewModel.select(audioDevice: device) + devices.select(audioDevice: device) } label: { HStack { Text(device.name) - if device.deviceId == viewModel.selectedAudioDeviceID { + if device.deviceId == devices.selectedAudioDeviceID { Image(systemName: "checkmark") } } diff --git a/VoiceAgent/ControlBar/ControlBar.swift b/VoiceAgent/ControlBar/ControlBar.swift index 52322e8..0903fa4 100644 --- a/VoiceAgent/ControlBar/ControlBar.swift +++ b/VoiceAgent/ControlBar/ControlBar.swift @@ -4,7 +4,10 @@ import LiveKitComponents /// Available controls depend on the agent features and the track availability. /// - SeeAlso: ``AgentFeatures`` struct ControlBar: View { - @Environment(AppViewModel.self) private var viewModel + @LKConversation private var conversation + @LKLocalMedia private var localMedia + + @Binding var chat: Bool @Environment(\.horizontalSizeClass) private var horizontalSizeClass private enum Constants { @@ -15,17 +18,17 @@ struct ControlBar: View { var body: some View { HStack(spacing: .zero) { biggerSpacer() - if viewModel.agentFeatures.contains(.voice) { + if AppFeatures.voice { audioControls() flexibleSpacer() } - if viewModel.agentFeatures.contains(.video) { + if AppFeatures.video { videoControls() flexibleSpacer() screenShareButton() flexibleSpacer() } - if viewModel.agentFeatures.contains(.text) { + if AppFeatures.text { textInputButton() flexibleSpacer() } @@ -79,14 +82,14 @@ struct ControlBar: View { private func audioControls() -> some View { HStack(spacing: .zero) { Spacer() - AsyncButton(action: viewModel.toggleMicrophone) { + AsyncButton(action: localMedia.toggleMicrophone) { HStack(spacing: .grid) { - Image(systemName: viewModel.isMicrophoneEnabled ? "microphone.fill" : "microphone.slash.fill") + Image(systemName: localMedia.isMicrophoneEnabled ? "microphone.fill" : "microphone.slash.fill") .transition(.symbolEffect) - BarAudioVisualizer(audioTrack: viewModel.audioTrack, barColor: .fg1, barCount: 3, barSpacingFactor: 0.1) + BarAudioVisualizer(audioTrack: localMedia.microphoneTrack, barColor: .fg1, barCount: 3, barSpacingFactor: 0.1) .frame(width: 2 * .grid, height: 0.5 * Constants.buttonHeight) .frame(maxHeight: .infinity) - .id(viewModel.audioTrack?.id) + .id(localMedia.microphoneTrack?.id) } .frame(height: Constants.buttonHeight) .padding(.horizontal, 2 * .grid) @@ -106,8 +109,10 @@ struct ControlBar: View { private func videoControls() -> some View { HStack(spacing: .zero) { Spacer() - AsyncButton(action: viewModel.toggleCamera) { - Image(systemName: viewModel.isCameraEnabled ? "video.fill" : "video.slash.fill") + AsyncButton { + await localMedia.toggleCamera(disableScreenShare: true) + } label: { + Image(systemName: localMedia.isCameraEnabled ? "video.fill" : "video.slash.fill") .transition(.symbolEffect) .frame(height: Constants.buttonHeight) .padding(.horizontal, 2 * .grid) @@ -121,48 +126,55 @@ struct ControlBar: View { Spacer() } .frame(width: Constants.buttonWidth) - .disabled(viewModel.agent == nil) + .disabled(!conversation.hasAgents) } @ViewBuilder private func screenShareButton() -> some View { - AsyncButton(action: viewModel.toggleScreenShare) { + AsyncButton { + await localMedia.toggleScreenShare(disableCamera: true) + } label: { Image(systemName: "arrow.up.square.fill") .frame(width: Constants.buttonWidth, height: Constants.buttonHeight) .contentShape(Rectangle()) } .buttonStyle( ControlBarButtonStyle( - isToggled: viewModel.isScreenShareEnabled, + isToggled: localMedia.isScreenShareEnabled, foregroundColor: .fg1, backgroundColor: .bg2, borderColor: .separator1 ) ) - .disabled(viewModel.agent == nil) + .disabled(!conversation.hasAgents) } @ViewBuilder private func textInputButton() -> some View { - AsyncButton(action: viewModel.toggleTextInput) { + Button { + chat.toggle() + } label: { Image(systemName: "ellipsis.message.fill") .frame(width: Constants.buttonWidth, height: Constants.buttonHeight) .contentShape(Rectangle()) } .buttonStyle( ControlBarButtonStyle( - isToggled: viewModel.interactionMode == .text, + isToggled: chat, foregroundColor: .fg1, backgroundColor: .bg2, borderColor: .separator1 ) ) - .disabled(viewModel.agent == nil) + .disabled(!conversation.hasAgents) } @ViewBuilder private func disconnectButton() -> some View { - AsyncButton(action: viewModel.disconnect) { + AsyncButton { + await conversation.end() + conversation.restoreMessageHistory([]) + } label: { Image(systemName: "phone.down.fill") .frame(width: Constants.buttonWidth, height: Constants.buttonHeight) .contentShape(Rectangle()) @@ -174,11 +186,10 @@ struct ControlBar: View { borderColor: .separatorSerious ) ) - .disabled(viewModel.connectionState == .disconnected) + .disabled(conversation.connectionState == .disconnected) } } #Preview { - ControlBar() - .environment(AppViewModel()) + ControlBar(chat: .constant(false)) } diff --git a/VoiceAgent/ControlBar/Devices/VideoDeviceSelector.swift b/VoiceAgent/ControlBar/VideoDeviceSelector.swift similarity index 72% rename from VoiceAgent/ControlBar/Devices/VideoDeviceSelector.swift rename to VoiceAgent/ControlBar/VideoDeviceSelector.swift index 2f78852..988af4a 100644 --- a/VoiceAgent/ControlBar/Devices/VideoDeviceSelector.swift +++ b/VoiceAgent/ControlBar/VideoDeviceSelector.swift @@ -4,17 +4,17 @@ import SwiftUI #if os(macOS) /// A platform-specific view that shows a list of available video devices. struct VideoDeviceSelector: View { - @Environment(AppViewModel.self) private var viewModel + @EnvironmentObject private var devices: DeviceSwitcher var body: some View { Menu { - ForEach(viewModel.videoDevices, id: \.uniqueID) { device in + ForEach(devices.videoDevices, id: \.uniqueID) { device in AsyncButton { - await viewModel.select(videoDevice: device) + await devices.select(videoDevice: device) } label: { HStack { Text(device.localizedName) - if device.uniqueID == viewModel.selectedVideoDeviceID { + if device.uniqueID == devices.selectedVideoDeviceID { Image(systemName: "checkmark") } } diff --git a/VoiceAgent/DI/Dependencies.swift b/VoiceAgent/DI/Dependencies.swift deleted file mode 100644 index dbc7ab9..0000000 --- a/VoiceAgent/DI/Dependencies.swift +++ /dev/null @@ -1,50 +0,0 @@ -import LiveKit - -/// A minimalistic dependency injection container. -/// It allows sharing common dependencies e.g. `Room` between view models and services. -/// - Note: For production apps, consider using a more flexible approach offered by e.g.: -/// - [Factory](https://github.com/hmlongco/Factory) -/// - [swift-dependencies](https://github.com/pointfreeco/swift-dependencies) -/// - [Needle](https://github.com/uber/needle) -@MainActor -final class Dependencies { - static let shared = Dependencies() - - private init() {} - - // MARK: LiveKit - - lazy var room = Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true))) - - // MARK: Services - - lazy var tokenService = TokenService() - - private lazy var localMessageSender = LocalMessageSender(room: room) - lazy var messageSenders: [any MessageSender] = [ - localMessageSender, - ] - lazy var messageReceivers: [any MessageReceiver] = [ - TranscriptionStreamReceiver(room: room), - localMessageSender, - ] - - // MARK: Error - - lazy var errorHandler: (Error?) -> Void = { _ in } -} - -/// A property wrapper that injects a dependency from the ``Dependencies`` container. -@MainActor -@propertyWrapper -struct Dependency { - let keyPath: KeyPath - - init(_ keyPath: KeyPath) { - self.keyPath = keyPath - } - - var wrappedValue: T { - Dependencies.shared[keyPath: keyPath] - } -} diff --git a/VoiceAgent/Helpers/ObservableObject+.swift b/VoiceAgent/Helpers/ObservableObject+.swift deleted file mode 100644 index 7b60e10..0000000 --- a/VoiceAgent/Helpers/ObservableObject+.swift +++ /dev/null @@ -1,17 +0,0 @@ -import Combine - -extension ObservableObject { - typealias BufferedObjectWillChangePublisher = Publishers.Buffer - - // This is necessary due to ObservableObjectPublisher not respecting the demand. - // See: https://forums.swift.org/t/asyncpublisher-causes-crash-in-rather-simple-situation - private var bufferedObjectWillChange: BufferedObjectWillChangePublisher { - objectWillChange - .buffer(size: 1, prefetch: .byRequest, whenFull: .dropOldest) - } - - /// A publisher that emits the `objectWillChange` events. - var changes: AsyncPublisher { - bufferedObjectWillChange.values - } -} diff --git a/VoiceAgent/Helpers/VideoTrack+.swift b/VoiceAgent/Helpers/VideoTrack+.swift deleted file mode 100644 index 6c30576..0000000 --- a/VoiceAgent/Helpers/VideoTrack+.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation -import LiveKit - -extension VideoTrack { - /// The aspect ratio of the video track or 1 if the dimensions are not available. - var aspectRatio: CGFloat { - guard let dimensions else { return 1 } - return CGFloat(dimensions.width) / CGFloat(dimensions.height) - } -} diff --git a/VoiceAgent/Helpers/View+.swift b/VoiceAgent/Helpers/View+.swift index c2cc263..7767d41 100644 --- a/VoiceAgent/Helpers/View+.swift +++ b/VoiceAgent/Helpers/View+.swift @@ -1,16 +1,5 @@ import SwiftUI -/// A view modifier that flips the view upside down. -/// It may be used to create e.g. an inverted List. -/// - SeeAlso: ``ChatView`` -struct UpsideDown: ViewModifier { - func body(content: Content) -> some View { - content - .rotationEffect(.radians(Double.pi)) - .scaleEffect(x: -1, y: 1, anchor: .center) - } -} - /// A view modifier that slightly blurs the top of the view. struct BlurredTop: ViewModifier { func body(content: Content) -> some View { @@ -50,11 +39,6 @@ struct Shimerring: ViewModifier { } extension View { - /// Flips the view upside down. - func upsideDown() -> some View { - modifier(UpsideDown()) - } - /// Blurs the top of the view. func blurredTop() -> some View { modifier(BlurredTop()) diff --git a/VoiceAgent/Interactions/TextInteractionView.swift b/VoiceAgent/Interactions/TextInteractionView.swift index a60f193..afaf368 100644 --- a/VoiceAgent/Interactions/TextInteractionView.swift +++ b/VoiceAgent/Interactions/TextInteractionView.swift @@ -1,3 +1,4 @@ +import LiveKit import SwiftUI /// A multiplatform view that shows text-specific interaction controls. @@ -9,7 +10,10 @@ import SwiftUI /// /// Additionally, the view shows a complete chat view with text input capabilities. struct TextInteractionView: View { - @Environment(AppViewModel.self) private var viewModel + @LKConversation private var conversation + @LKAgent private var agent + @LKLocalMedia private var localMedia + @FocusState.Binding var keyboardFocus: Bool var body: some View { @@ -37,12 +41,12 @@ struct TextInteractionView: View { HStack { Spacer() AgentParticipantView() - .frame(maxWidth: viewModel.avatarCameraTrack != nil ? 50 * .grid : 25 * .grid) + .frame(maxWidth: agent?.avatarVideoTrack != nil ? 50 * .grid : 25 * .grid) ScreenShareView() LocalParticipantView() Spacer() } - .frame(height: viewModel.isCameraEnabled || viewModel.isScreenShareEnabled || viewModel.avatarCameraTrack != nil ? 50 * .grid : 25 * .grid) + .frame(height: localMedia.isCameraEnabled || localMedia.isScreenShareEnabled || agent?.avatarVideoTrack != nil ? 50 * .grid : 25 * .grid) .safeAreaPadding() } } diff --git a/VoiceAgent/Interactions/VisionInteractionView.swift b/VoiceAgent/Interactions/VisionInteractionView.swift index b06159c..c1be0e0 100644 --- a/VoiceAgent/Interactions/VisionInteractionView.swift +++ b/VoiceAgent/Interactions/VisionInteractionView.swift @@ -3,14 +3,14 @@ import SwiftUI #if os(visionOS) /// A platform-specific view that shows all interaction controls with optional chat. struct VisionInteractionView: View { - @Environment(AppViewModel.self) private var viewModel + var chat: Bool @FocusState.Binding var keyboardFocus: Bool var body: some View { HStack { participants().rotation3DEffect(.degrees(30), axis: .y, anchor: .trailing) agent() - chat().rotation3DEffect(.degrees(-30), axis: .y, anchor: .leading) + chats().rotation3DEffect(.degrees(-30), axis: .y, anchor: .leading) } } @@ -34,9 +34,9 @@ struct VisionInteractionView: View { } @ViewBuilder - private func chat() -> some View { + private func chats() -> some View { VStack { - if case .text = viewModel.interactionMode { + if chat { ChatView() ChatTextInputView(keyboardFocus: _keyboardFocus) } diff --git a/VoiceAgent/Participant/AgentParticipantView.swift b/VoiceAgent/Participant/AgentParticipantView.swift index 1cf12ff..81d3fa2 100644 --- a/VoiceAgent/Participant/AgentParticipantView.swift +++ b/VoiceAgent/Participant/AgentParticipantView.swift @@ -4,7 +4,8 @@ import LiveKitComponents /// or the audio visualizer (if available). /// - Note: If both are unavailable, the view will show a placeholder visualizer. struct AgentParticipantView: View { - @Environment(AppViewModel.self) private var viewModel + @LKConversation private var conversation + @LKAgent private var agent @Environment(\.namespace) private var namespace /// Reveals the avatar camera view when true. @@ -12,11 +13,11 @@ struct AgentParticipantView: View { var body: some View { ZStack { - if let avatarCameraTrack = viewModel.avatarCameraTrack { - SwiftUIVideoView(avatarCameraTrack) + if let avatarVideoTrack = agent?.avatarVideoTrack { + SwiftUIVideoView(avatarVideoTrack) .clipShape(RoundedRectangle(cornerRadius: .cornerRadiusPerPlatform)) - .aspectRatio(avatarCameraTrack.aspectRatio, contentMode: .fit) - .padding(.horizontal, avatarCameraTrack.aspectRatio == 1 ? 4 * .grid : .zero) + .aspectRatio(avatarVideoTrack.aspectRatio, contentMode: .fit) + .padding(.horizontal, agent?.avatarVideoTrack?.aspectRatio == 1 ? 4 * .grid : .zero) .shadow(radius: 20, y: 10) .mask( GeometryReader { proxy in @@ -31,15 +32,15 @@ struct AgentParticipantView: View { .onAppear { videoTransition = true } - } else if let agentAudioTrack = viewModel.agentAudioTrack { - BarAudioVisualizer(audioTrack: agentAudioTrack, - agentState: viewModel.agent?.agentState ?? .listening, + } else if let audioTrack = agent?.audioTrack { + BarAudioVisualizer(audioTrack: audioTrack, + agentState: agent?.state ?? .listening, barCount: 5, barSpacingFactor: 0.05, barMinOpacity: 0.1) .frame(maxWidth: 75 * .grid, maxHeight: 48 * .grid) .transition(.opacity) - } else if viewModel.isInteractive { + } else if conversation.isReady { BarAudioVisualizer(audioTrack: nil, agentState: .listening, barCount: 1, @@ -48,7 +49,20 @@ struct AgentParticipantView: View { .transition(.opacity) } } - .animation(.snappy, value: viewModel.agentAudioTrack?.id) + .animation(.snappy, value: agent?.audioTrack?.id) .matchedGeometryEffect(id: "agent", in: namespace!) } } + +extension BarAudioVisualizer { + init(agent: Agent?, + barColor: Color = .primary, + barCount: Int = 5, + barCornerRadius: CGFloat = 100, + barSpacingFactor: CGFloat = 0.015, + barMinOpacity: CGFloat = 0.16, + isCentered: Bool = true) + { + self.init(audioTrack: agent?.audioTrack, agentState: agent?.state ?? .listening, barColor: barColor, barCount: barCount, barCornerRadius: barCornerRadius, barSpacingFactor: barSpacingFactor, barMinOpacity: barMinOpacity, isCentered: isCentered) + } +} diff --git a/VoiceAgent/Participant/LocalParticipantView.swift b/VoiceAgent/Participant/LocalParticipantView.swift index 8e4c4d0..5b81aa7 100644 --- a/VoiceAgent/Participant/LocalParticipantView.swift +++ b/VoiceAgent/Participant/LocalParticipantView.swift @@ -2,19 +2,19 @@ import LiveKitComponents /// A view that shows the local participant's camera view with flip control. struct LocalParticipantView: View { - @Environment(AppViewModel.self) private var viewModel + @LKLocalMedia private var localMedia @Environment(\.namespace) private var namespace var body: some View { - if let cameraTrack = viewModel.cameraTrack { + if let cameraTrack = localMedia.cameraTrack { SwiftUIVideoView(cameraTrack) .clipShape(RoundedRectangle(cornerRadius: .cornerRadiusPerPlatform)) .aspectRatio(cameraTrack.aspectRatio, contentMode: .fit) .shadow(radius: 20, y: 10) .transition(.scale.combined(with: .opacity)) .overlay(alignment: .bottomTrailing) { - if viewModel.canSwitchCamera { - AsyncButton(action: viewModel.switchCamera) { + if localMedia.canSwitchCamera { + AsyncButton(action: localMedia.switchCamera) { Image(systemName: "arrow.trianglehead.2.clockwise.rotate.90") .padding(2 * .grid) .foregroundStyle(.fg0) diff --git a/VoiceAgent/Participant/ScreenShareView.swift b/VoiceAgent/Participant/ScreenShareView.swift index 9774574..04da221 100644 --- a/VoiceAgent/Participant/ScreenShareView.swift +++ b/VoiceAgent/Participant/ScreenShareView.swift @@ -2,11 +2,11 @@ import LiveKitComponents /// A view that shows the screen share preview. struct ScreenShareView: View { - @Environment(AppViewModel.self) private var viewModel + @LKLocalMedia private var localMedia @Environment(\.namespace) private var namespace var body: some View { - if let screenShareTrack = viewModel.screenShareTrack { + if let screenShareTrack = localMedia.screenShareTrack { SwiftUIVideoView(screenShareTrack) .clipShape(RoundedRectangle(cornerRadius: .cornerRadiusPerPlatform)) .aspectRatio(screenShareTrack.aspectRatio, contentMode: .fit) diff --git a/VoiceAgent/Start/StartView.swift b/VoiceAgent/Start/StartView.swift index 291c63a3..cd859f7 100644 --- a/VoiceAgent/Start/StartView.swift +++ b/VoiceAgent/Start/StartView.swift @@ -1,8 +1,9 @@ +import LiveKit import SwiftUI /// The initial view that is shown when the app is not connected to the server. struct StartView: View { - @Environment(AppViewModel.self) private var viewModel + @LKConversation private var conversation @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Namespace private var button @@ -55,7 +56,9 @@ struct StartView: View { @ViewBuilder private func connectButton() -> some View { - AsyncButton(action: viewModel.connect) { + AsyncButton { + await conversation.start() + } label: { HStack { Spacer() Text("connect.start") @@ -85,5 +88,4 @@ struct StartView: View { #Preview { StartView() - .environment(AppViewModel()) } diff --git a/VoiceAgent/VoiceAgentApp.swift b/VoiceAgent/VoiceAgentApp.swift index 802c0d7..1a645f0 100644 --- a/VoiceAgent/VoiceAgentApp.swift +++ b/VoiceAgent/VoiceAgentApp.swift @@ -1,15 +1,27 @@ import LiveKit import SwiftUI +enum AppFeatures { + static let voice = true + static let video = true + static let text = true +} + @main struct VoiceAgentApp: App { - // Create the root view model - private let viewModel = AppViewModel() + // To use the LiveKit Cloud sandbox (development only) + // - Enable your sandbox here https://cloud.livekit.io/projects/p_/sandbox/templates/token-server + // - Create .env.xcconfig with your LIVEKIT_SANDBOX_ID + private static let sandboxId = Bundle.main.object(forInfoDictionaryKey: "LiveKitSandboxId") as! String + private let conversation = Conversation(credentials: CachingCredentialsProvider(SandboxTokenServer(id: Self.sandboxId)), + // agentName: ... + room: Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)))) var body: some Scene { WindowGroup { AppView() - .environment(viewModel) + .environmentObject(conversation) + .environmentObject(LocalMedia(conversation: conversation)) } #if os(macOS) .defaultSize(width: 900, height: 900) @@ -21,15 +33,3 @@ struct VoiceAgentApp: App { #endif } } - -/// A set of flags that define the features supported by the agent. -/// Enable them based on your agent capabilities. -struct AgentFeatures: OptionSet { - let rawValue: Int - - static let voice = Self(rawValue: 1 << 0) - static let text = Self(rawValue: 1 << 1) - static let video = Self(rawValue: 1 << 2) - - static let current: Self = [.voice, .text] -} diff --git a/VoiceAgentTests/ChatViewModelTests.swift b/VoiceAgentTests/ChatViewModelTests.swift deleted file mode 100644 index a7f3ad7..0000000 --- a/VoiceAgentTests/ChatViewModelTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Testing -@testable import VoiceAgent - -@MainActor -struct ChatViewModelTests { - @Test func multipleReceivers() async throws { - let receiver1 = MockMessageReceiver() - let receiver2 = MockMessageReceiver() - - let message1 = ReceivedMessage( - id: "1", - timestamp: .init(), - content: .userTranscript("Hello") - ) - let message2 = ReceivedMessage( - id: "2", - timestamp: .init(), - content: .agentTranscript("Hi there") - ) - - Dependencies.shared.messageReceivers = [receiver1, receiver2] - let viewModel = ChatViewModel() - - try await Task.sleep(for: .milliseconds(100)) - await receiver1.postMessage(message1) - try await Task.sleep(for: .milliseconds(100)) - await receiver2.postMessage(message2) - try await Task.sleep(for: .milliseconds(100)) - - #expect(viewModel.messages.count == 2) - #expect(viewModel.messages["1"]?.content == .userTranscript("Hello")) - #expect(viewModel.messages["2"]?.content == .agentTranscript("Hi there")) - - let orderedMessages = Array(viewModel.messages.values) - #expect(orderedMessages.count == 2) - #expect(orderedMessages[0].id == "1") - #expect(orderedMessages[1].id == "2") - } -} - -actor MockMessageReceiver: MessageReceiver { - private var continuation: AsyncStream.Continuation? - - func messages() async throws -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream(of: ReceivedMessage.self) - self.continuation = continuation - return stream - } - - func postMessage(_ message: ReceivedMessage) { - continuation?.yield(message) - } -} From 6fec91cf4155978eead129cfba7d9377a57dd527 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:12:01 +0200 Subject: [PATCH 02/21] Extract components --- VoiceAgent/Chat/ChatScrollView.swift | 43 ----------------- VoiceAgent/Chat/ChatTextInputView.swift | 2 +- VoiceAgent/Chat/ChatView.swift | 2 +- VoiceAgent/Helpers/AsyncButton.swift | 47 ------------------- .../Participant/AgentParticipantView.swift | 13 ----- VoiceAgent/Start/StartView.swift | 2 +- 6 files changed, 3 insertions(+), 106 deletions(-) delete mode 100644 VoiceAgent/Chat/ChatScrollView.swift delete mode 100644 VoiceAgent/Helpers/AsyncButton.swift diff --git a/VoiceAgent/Chat/ChatScrollView.swift b/VoiceAgent/Chat/ChatScrollView.swift deleted file mode 100644 index 0071649..0000000 --- a/VoiceAgent/Chat/ChatScrollView.swift +++ /dev/null @@ -1,43 +0,0 @@ -import LiveKit -import SwiftUI - -struct ChatScrollView: View { - typealias MessageBuilder = (ReceivedMessage) -> Content - - @LKConversation private var conversation - let messageBuilder: MessageBuilder - - var body: some View { - ScrollViewReader { scrollView in - ScrollView { - LazyVStack { - ForEach(conversation.messages.values.reversed(), content: { message in - messageBuilder(message) - .upsideDown() - .id(message.id) - }) - } - } - .onChange(of: conversation.messages.count) { - scrollView.scrollTo(conversation.messages.keys.last) - } - .upsideDown() - .scrollIndicators(.never) - .animation(.default, value: conversation.messages) - } - } -} - -private struct UpsideDown: ViewModifier { - func body(content: Content) -> some View { - content - .rotationEffect(.radians(Double.pi)) - .scaleEffect(x: -1, y: 1, anchor: .center) - } -} - -private extension View { - func upsideDown() -> some View { - modifier(UpsideDown()) - } -} diff --git a/VoiceAgent/Chat/ChatTextInputView.swift b/VoiceAgent/Chat/ChatTextInputView.swift index 3886692..28e1985 100644 --- a/VoiceAgent/Chat/ChatTextInputView.swift +++ b/VoiceAgent/Chat/ChatTextInputView.swift @@ -1,4 +1,4 @@ -import LiveKit +import LiveKitComponents import SwiftUI /// A multiplatform view that shows the chat input text field and send button. diff --git a/VoiceAgent/Chat/ChatView.swift b/VoiceAgent/Chat/ChatView.swift index c322a00..153b4e5 100644 --- a/VoiceAgent/Chat/ChatView.swift +++ b/VoiceAgent/Chat/ChatView.swift @@ -1,4 +1,4 @@ -import LiveKit +import LiveKitComponents import SwiftUI struct ChatView: View { diff --git a/VoiceAgent/Helpers/AsyncButton.swift b/VoiceAgent/Helpers/AsyncButton.swift deleted file mode 100644 index 5c26eaa..0000000 --- a/VoiceAgent/Helpers/AsyncButton.swift +++ /dev/null @@ -1,47 +0,0 @@ -import SwiftUI - -/// A drop-in replacement `Button` that executes an async action and shows a busy label when in progress. -/// -/// - Parameters: -/// - action: The async action to execute. -/// - label: The label to show when not busy. -/// - busyLabel: The label to show when busy. Defaults to an empty view. -struct AsyncButton: View { - private let action: () async -> Void - - @ViewBuilder private let label: Label - @ViewBuilder private let busyLabel: BusyLabel - - @State private var isBusy = false - - init( - action: @escaping () async -> Void, - @ViewBuilder label: () -> Label, - @ViewBuilder busyLabel: () -> BusyLabel = EmptyView.init - ) { - self.action = action - self.label = label() - self.busyLabel = busyLabel() - } - - var body: some View { - Button { - isBusy = true - Task { - await action() - isBusy = false - } - } label: { - if isBusy { - if busyLabel is EmptyView { - label - } else { - busyLabel - } - } else { - label - } - } - .disabled(isBusy) - } -} diff --git a/VoiceAgent/Participant/AgentParticipantView.swift b/VoiceAgent/Participant/AgentParticipantView.swift index 81d3fa2..c684ba7 100644 --- a/VoiceAgent/Participant/AgentParticipantView.swift +++ b/VoiceAgent/Participant/AgentParticipantView.swift @@ -53,16 +53,3 @@ struct AgentParticipantView: View { .matchedGeometryEffect(id: "agent", in: namespace!) } } - -extension BarAudioVisualizer { - init(agent: Agent?, - barColor: Color = .primary, - barCount: Int = 5, - barCornerRadius: CGFloat = 100, - barSpacingFactor: CGFloat = 0.015, - barMinOpacity: CGFloat = 0.16, - isCentered: Bool = true) - { - self.init(audioTrack: agent?.audioTrack, agentState: agent?.state ?? .listening, barColor: barColor, barCount: barCount, barCornerRadius: barCornerRadius, barSpacingFactor: barSpacingFactor, barMinOpacity: barMinOpacity, isCentered: isCentered) - } -} diff --git a/VoiceAgent/Start/StartView.swift b/VoiceAgent/Start/StartView.swift index cd859f7..f19a96d 100644 --- a/VoiceAgent/Start/StartView.swift +++ b/VoiceAgent/Start/StartView.swift @@ -1,4 +1,4 @@ -import LiveKit +import LiveKitComponents import SwiftUI /// The initial view that is shown when the app is not connected to the server. From 4927dbaab957fea19af1197273214fb1c9c11c9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:13:41 +0200 Subject: [PATCH 03/21] To revert: Switch branches --- VoiceAgent.xcodeproj/project.pbxproj | 8 ++++---- .../xcshareddata/swiftpm/Package.resolved | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/VoiceAgent.xcodeproj/project.pbxproj b/VoiceAgent.xcodeproj/project.pbxproj index b2fa494..c0730bd 100644 --- a/VoiceAgent.xcodeproj/project.pbxproj +++ b/VoiceAgent.xcodeproj/project.pbxproj @@ -601,16 +601,16 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/livekit/components-swift"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 0.1.5; + branch = "blaze/agent-conversation"; + kind = branch; }; }; B5E1B9102D14E9F500A38CB6 /* XCRemoteSwiftPackageReference "client-sdk-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/livekit/client-sdk-swift"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 2.7.1; + branch = "blaze/agent-conversation"; + kind = branch; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/VoiceAgent.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/VoiceAgent.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index dde732e..3c30fbb 100644 --- a/VoiceAgent.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/VoiceAgent.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -6,8 +6,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/livekit/client-sdk-swift", "state" : { - "revision" : "77b00169920283acd795e46c659ceefc9e4a666e", - "version" : "2.7.1" + "branch" : "blaze/agent-conversation", + "revision" : "6ea1621c9651ce1eb8fc5eca18cc196987317087" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/livekit/components-swift", "state" : { - "revision" : "f756f3696f4a9b208430e0e239ee7b7b337222ce", - "version" : "0.1.5" + "branch" : "blaze/agent-conversation", + "revision" : "6a5061959f90890ac12f8ddd068e06e763b7b56d" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/livekit/webrtc-xcframework.git", "state" : { - "revision" : "609aa5e7dd818ba85eb483153b572fd698785a40", - "version" : "137.7151.4" + "revision" : "5bda55f1f7ba0df114de60b760f5206a07e0fab7", + "version" : "137.7151.5" } } ], From 25ce2cf56af8e0cec016b7f53415aa911eb1d0e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 18 Sep 2025 14:18:00 +0200 Subject: [PATCH 04/21] Remove dependency --- VoiceAgent.xcodeproj/project.pbxproj | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/VoiceAgent.xcodeproj/project.pbxproj b/VoiceAgent.xcodeproj/project.pbxproj index c0730bd..8704328 100644 --- a/VoiceAgent.xcodeproj/project.pbxproj +++ b/VoiceAgent.xcodeproj/project.pbxproj @@ -10,7 +10,6 @@ ACAEBA5B2DE6EE970072E93E /* ReplayKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = ACAEBA5A2DE6EE970072E93E /* ReplayKit.framework */; }; ACAEBA622DE6EE970072E93E /* BroadcastExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = ACAEBA582DE6EE970072E93E /* BroadcastExtension.appex */; platformFilters = (ios, xros, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; ACAEBA692DE6EF4B0072E93E /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = ACAEBA682DE6EF4B0072E93E /* LiveKit */; }; - ACFBA1DB2D8D5CBE0021202B /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = ACFBA1DA2D8D5CBE0021202B /* Collections */; }; B5E1B90F2D14E9EC00A38CB6 /* LiveKitComponents in Frameworks */ = {isa = PBXBuildFile; productRef = B5E1B90E2D14E9EC00A38CB6 /* LiveKitComponents */; }; B5E1B9122D14E9F500A38CB6 /* LiveKit in Frameworks */ = {isa = PBXBuildFile; productRef = B5E1B9112D14E9F500A38CB6 /* LiveKit */; }; /* End PBXBuildFile section */ @@ -98,7 +97,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - ACFBA1DB2D8D5CBE0021202B /* Collections in Frameworks */, B5E1B90F2D14E9EC00A38CB6 /* LiveKitComponents in Frameworks */, B5E1B9122D14E9F500A38CB6 /* LiveKit in Frameworks */, ); @@ -181,7 +179,6 @@ packageProductDependencies = ( B5E1B90E2D14E9EC00A38CB6 /* LiveKitComponents */, B5E1B9112D14E9F500A38CB6 /* LiveKit */, - ACFBA1DA2D8D5CBE0021202B /* Collections */, ); productName = VoiceAgent; productReference = B5B5E3B22D124AE00099C9BE /* VoiceAgent.app */; @@ -217,7 +214,6 @@ packageReferences = ( B5E1B90D2D14E9EC00A38CB6 /* XCRemoteSwiftPackageReference "components-swift" */, B5E1B9102D14E9F500A38CB6 /* XCRemoteSwiftPackageReference "client-sdk-swift" */, - ACFBA1D92D8D5CBE0021202B /* XCRemoteSwiftPackageReference "swift-collections" */, ); preferredProjectObjectVersion = 77; productRefGroup = B5B5E3B32D124AE00099C9BE /* Products */; @@ -589,14 +585,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - ACFBA1D92D8D5CBE0021202B /* XCRemoteSwiftPackageReference "swift-collections" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/apple/swift-collections"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.1.4; - }; - }; B5E1B90D2D14E9EC00A38CB6 /* XCRemoteSwiftPackageReference "components-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/livekit/components-swift"; @@ -620,11 +608,6 @@ isa = XCSwiftPackageProductDependency; productName = LiveKit; }; - ACFBA1DA2D8D5CBE0021202B /* Collections */ = { - isa = XCSwiftPackageProductDependency; - package = ACFBA1D92D8D5CBE0021202B /* XCRemoteSwiftPackageReference "swift-collections" */; - productName = Collections; - }; B5E1B90E2D14E9EC00A38CB6 /* LiveKitComponents */ = { isa = XCSwiftPackageProductDependency; package = B5E1B90D2D14E9EC00A38CB6 /* XCRemoteSwiftPackageReference "components-swift" */; From 3cd722a95e13ff4894c702e5eb66a89593265a39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:02:37 +0200 Subject: [PATCH 05/21] State --- VoiceAgent/VoiceAgentApp.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/VoiceAgent/VoiceAgentApp.swift b/VoiceAgent/VoiceAgentApp.swift index 1a645f0..04a395e 100644 --- a/VoiceAgent/VoiceAgentApp.swift +++ b/VoiceAgent/VoiceAgentApp.swift @@ -13,9 +13,9 @@ struct VoiceAgentApp: App { // - Enable your sandbox here https://cloud.livekit.io/projects/p_/sandbox/templates/token-server // - Create .env.xcconfig with your LIVEKIT_SANDBOX_ID private static let sandboxId = Bundle.main.object(forInfoDictionaryKey: "LiveKitSandboxId") as! String - private let conversation = Conversation(credentials: CachingCredentialsProvider(SandboxTokenServer(id: Self.sandboxId)), - // agentName: ... - room: Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)))) + @StateObject private var conversation = Conversation(credentials: CachingCredentialsProvider(SandboxTokenServer(id: Self.sandboxId)), + // agentName: ... + room: Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)))) var body: some Scene { WindowGroup { From 81a25a69c8bacacc21b9a82427b448b2e12b8a3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:22:24 +0200 Subject: [PATCH 06/21] Fix macOS --- VoiceAgent/App/AppView.swift | 1 - VoiceAgent/Chat/ChatTextInputView.swift | 3 +-- VoiceAgent/ControlBar/AudioDeviceSelector.swift | 9 +++++---- VoiceAgent/ControlBar/VideoDeviceSelector.swift | 9 +++++---- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/VoiceAgent/App/AppView.swift b/VoiceAgent/App/AppView.swift index 3a7c4fc..aeec2e4 100644 --- a/VoiceAgent/App/AppView.swift +++ b/VoiceAgent/App/AppView.swift @@ -6,7 +6,6 @@ struct AppView: View { @LKLocalMedia private var localMedia @State private var chat: Bool = false - @FocusState private var keyboardFocus: Bool @Namespace private var namespace diff --git a/VoiceAgent/Chat/ChatTextInputView.swift b/VoiceAgent/Chat/ChatTextInputView.swift index 28e1985..091dc63 100644 --- a/VoiceAgent/Chat/ChatTextInputView.swift +++ b/VoiceAgent/Chat/ChatTextInputView.swift @@ -4,10 +4,9 @@ import SwiftUI /// A multiplatform view that shows the chat input text field and send button. struct ChatTextInputView: View { @LKConversation private var conversation - @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @FocusState.Binding var keyboardFocus: Bool - @State private var messageText = "" var body: some View { diff --git a/VoiceAgent/ControlBar/AudioDeviceSelector.swift b/VoiceAgent/ControlBar/AudioDeviceSelector.swift index bfa1bb6..e1496b6 100644 --- a/VoiceAgent/ControlBar/AudioDeviceSelector.swift +++ b/VoiceAgent/ControlBar/AudioDeviceSelector.swift @@ -1,19 +1,20 @@ +import LiveKitComponents import SwiftUI #if os(macOS) /// A platform-specific view that shows a list of available audio devices. struct AudioDeviceSelector: View { - @EnvironmentObject private var devices: DeviceSwitcher + @LKLocalMedia private var localMedia var body: some View { Menu { - ForEach(devices.audioDevices, id: \.deviceId) { device in + ForEach(localMedia.audioDevices, id: \.deviceId) { device in Button { - devices.select(audioDevice: device) + localMedia.select(audioDevice: device) } label: { HStack { Text(device.name) - if device.deviceId == devices.selectedAudioDeviceID { + if device.deviceId == localMedia.selectedAudioDeviceID { Image(systemName: "checkmark") } } diff --git a/VoiceAgent/ControlBar/VideoDeviceSelector.swift b/VoiceAgent/ControlBar/VideoDeviceSelector.swift index 988af4a..60bec73 100644 --- a/VoiceAgent/ControlBar/VideoDeviceSelector.swift +++ b/VoiceAgent/ControlBar/VideoDeviceSelector.swift @@ -1,20 +1,21 @@ import AVFoundation +import LiveKitComponents import SwiftUI #if os(macOS) /// A platform-specific view that shows a list of available video devices. struct VideoDeviceSelector: View { - @EnvironmentObject private var devices: DeviceSwitcher + @LKLocalMedia private var localMedia var body: some View { Menu { - ForEach(devices.videoDevices, id: \.uniqueID) { device in + ForEach(localMedia.videoDevices, id: \.uniqueID) { device in AsyncButton { - await devices.select(videoDevice: device) + await localMedia.select(videoDevice: device) } label: { HStack { Text(device.localizedName) - if device.uniqueID == devices.selectedVideoDeviceID { + if device.uniqueID == localMedia.selectedVideoDeviceID { Image(systemName: "checkmark") } } From 318a3515a3bc996e52137968e52065fd64d1d9d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 18 Sep 2025 15:41:31 +0200 Subject: [PATCH 07/21] Cleanup --- VoiceAgent/Interactions/TextInteractionView.swift | 1 - VoiceAgent/Participant/AgentParticipantView.swift | 2 +- VoiceAgent/Participant/LocalParticipantView.swift | 1 + VoiceAgent/Participant/ScreenShareView.swift | 1 + VoiceAgent/Start/StartView.swift | 2 +- 5 files changed, 4 insertions(+), 3 deletions(-) diff --git a/VoiceAgent/Interactions/TextInteractionView.swift b/VoiceAgent/Interactions/TextInteractionView.swift index afaf368..bcc304c 100644 --- a/VoiceAgent/Interactions/TextInteractionView.swift +++ b/VoiceAgent/Interactions/TextInteractionView.swift @@ -10,7 +10,6 @@ import SwiftUI /// /// Additionally, the view shows a complete chat view with text input capabilities. struct TextInteractionView: View { - @LKConversation private var conversation @LKAgent private var agent @LKLocalMedia private var localMedia diff --git a/VoiceAgent/Participant/AgentParticipantView.swift b/VoiceAgent/Participant/AgentParticipantView.swift index c684ba7..907184d 100644 --- a/VoiceAgent/Participant/AgentParticipantView.swift +++ b/VoiceAgent/Participant/AgentParticipantView.swift @@ -6,8 +6,8 @@ import LiveKitComponents struct AgentParticipantView: View { @LKConversation private var conversation @LKAgent private var agent - @Environment(\.namespace) private var namespace + @Environment(\.namespace) private var namespace /// Reveals the avatar camera view when true. @SceneStorage("videoTransition") private var videoTransition = false diff --git a/VoiceAgent/Participant/LocalParticipantView.swift b/VoiceAgent/Participant/LocalParticipantView.swift index 5b81aa7..34d88f1 100644 --- a/VoiceAgent/Participant/LocalParticipantView.swift +++ b/VoiceAgent/Participant/LocalParticipantView.swift @@ -3,6 +3,7 @@ import LiveKitComponents /// A view that shows the local participant's camera view with flip control. struct LocalParticipantView: View { @LKLocalMedia private var localMedia + @Environment(\.namespace) private var namespace var body: some View { diff --git a/VoiceAgent/Participant/ScreenShareView.swift b/VoiceAgent/Participant/ScreenShareView.swift index 04da221..36a438f 100644 --- a/VoiceAgent/Participant/ScreenShareView.swift +++ b/VoiceAgent/Participant/ScreenShareView.swift @@ -3,6 +3,7 @@ import LiveKitComponents /// A view that shows the screen share preview. struct ScreenShareView: View { @LKLocalMedia private var localMedia + @Environment(\.namespace) private var namespace var body: some View { diff --git a/VoiceAgent/Start/StartView.swift b/VoiceAgent/Start/StartView.swift index f19a96d..1a613f4 100644 --- a/VoiceAgent/Start/StartView.swift +++ b/VoiceAgent/Start/StartView.swift @@ -4,8 +4,8 @@ import SwiftUI /// The initial view that is shown when the app is not connected to the server. struct StartView: View { @LKConversation private var conversation - @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Namespace private var button var body: some View { From 6f317d6664bfb32f37f6facf939428d9d33bb0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Fri, 19 Sep 2025 13:37:24 +0200 Subject: [PATCH 08/21] Naming --- VoiceAgent/App/AppView.swift | 4 ++-- VoiceAgent/Chat/ChatTextInputView.swift | 2 +- VoiceAgent/ControlBar/AudioDeviceSelector.swift | 2 +- VoiceAgent/ControlBar/ControlBar.swift | 4 ++-- VoiceAgent/ControlBar/VideoDeviceSelector.swift | 2 +- VoiceAgent/Interactions/TextInteractionView.swift | 4 ++-- VoiceAgent/Participant/AgentParticipantView.swift | 4 ++-- VoiceAgent/Participant/LocalParticipantView.swift | 2 +- VoiceAgent/Participant/ScreenShareView.swift | 2 +- VoiceAgent/Start/StartView.swift | 2 +- 10 files changed, 14 insertions(+), 14 deletions(-) diff --git a/VoiceAgent/App/AppView.swift b/VoiceAgent/App/AppView.swift index aeec2e4..880a187 100644 --- a/VoiceAgent/App/AppView.swift +++ b/VoiceAgent/App/AppView.swift @@ -2,8 +2,8 @@ import LiveKit import SwiftUI struct AppView: View { - @LKConversation private var conversation - @LKLocalMedia private var localMedia + @LiveKitConversation private var conversation + @LiveKitLocalMedia private var localMedia @State private var chat: Bool = false @FocusState private var keyboardFocus: Bool diff --git a/VoiceAgent/Chat/ChatTextInputView.swift b/VoiceAgent/Chat/ChatTextInputView.swift index 091dc63..491ed18 100644 --- a/VoiceAgent/Chat/ChatTextInputView.swift +++ b/VoiceAgent/Chat/ChatTextInputView.swift @@ -3,7 +3,7 @@ import SwiftUI /// A multiplatform view that shows the chat input text field and send button. struct ChatTextInputView: View { - @LKConversation private var conversation + @LiveKitConversation private var conversation @Environment(\.horizontalSizeClass) private var horizontalSizeClass @FocusState.Binding var keyboardFocus: Bool diff --git a/VoiceAgent/ControlBar/AudioDeviceSelector.swift b/VoiceAgent/ControlBar/AudioDeviceSelector.swift index e1496b6..4bb1d4d 100644 --- a/VoiceAgent/ControlBar/AudioDeviceSelector.swift +++ b/VoiceAgent/ControlBar/AudioDeviceSelector.swift @@ -4,7 +4,7 @@ import SwiftUI #if os(macOS) /// A platform-specific view that shows a list of available audio devices. struct AudioDeviceSelector: View { - @LKLocalMedia private var localMedia + @LiveKitLocalMedia private var localMedia var body: some View { Menu { diff --git a/VoiceAgent/ControlBar/ControlBar.swift b/VoiceAgent/ControlBar/ControlBar.swift index 0903fa4..fbf52e9 100644 --- a/VoiceAgent/ControlBar/ControlBar.swift +++ b/VoiceAgent/ControlBar/ControlBar.swift @@ -4,8 +4,8 @@ import LiveKitComponents /// Available controls depend on the agent features and the track availability. /// - SeeAlso: ``AgentFeatures`` struct ControlBar: View { - @LKConversation private var conversation - @LKLocalMedia private var localMedia + @LiveKitConversation private var conversation + @LiveKitLocalMedia private var localMedia @Binding var chat: Bool @Environment(\.horizontalSizeClass) private var horizontalSizeClass diff --git a/VoiceAgent/ControlBar/VideoDeviceSelector.swift b/VoiceAgent/ControlBar/VideoDeviceSelector.swift index 60bec73..2eac179 100644 --- a/VoiceAgent/ControlBar/VideoDeviceSelector.swift +++ b/VoiceAgent/ControlBar/VideoDeviceSelector.swift @@ -5,7 +5,7 @@ import SwiftUI #if os(macOS) /// A platform-specific view that shows a list of available video devices. struct VideoDeviceSelector: View { - @LKLocalMedia private var localMedia + @LiveKitLocalMedia private var localMedia var body: some View { Menu { diff --git a/VoiceAgent/Interactions/TextInteractionView.swift b/VoiceAgent/Interactions/TextInteractionView.swift index bcc304c..12abdb6 100644 --- a/VoiceAgent/Interactions/TextInteractionView.swift +++ b/VoiceAgent/Interactions/TextInteractionView.swift @@ -10,8 +10,8 @@ import SwiftUI /// /// Additionally, the view shows a complete chat view with text input capabilities. struct TextInteractionView: View { - @LKAgent private var agent - @LKLocalMedia private var localMedia + @LiveKitAgent private var agent + @LiveKitLocalMedia private var localMedia @FocusState.Binding var keyboardFocus: Bool diff --git a/VoiceAgent/Participant/AgentParticipantView.swift b/VoiceAgent/Participant/AgentParticipantView.swift index 907184d..7d1e374 100644 --- a/VoiceAgent/Participant/AgentParticipantView.swift +++ b/VoiceAgent/Participant/AgentParticipantView.swift @@ -4,8 +4,8 @@ import LiveKitComponents /// or the audio visualizer (if available). /// - Note: If both are unavailable, the view will show a placeholder visualizer. struct AgentParticipantView: View { - @LKConversation private var conversation - @LKAgent private var agent + @LiveKitConversation private var conversation + @LiveKitAgent private var agent @Environment(\.namespace) private var namespace /// Reveals the avatar camera view when true. diff --git a/VoiceAgent/Participant/LocalParticipantView.swift b/VoiceAgent/Participant/LocalParticipantView.swift index 34d88f1..c8cce3b 100644 --- a/VoiceAgent/Participant/LocalParticipantView.swift +++ b/VoiceAgent/Participant/LocalParticipantView.swift @@ -2,7 +2,7 @@ import LiveKitComponents /// A view that shows the local participant's camera view with flip control. struct LocalParticipantView: View { - @LKLocalMedia private var localMedia + @LiveKitLocalMedia private var localMedia @Environment(\.namespace) private var namespace diff --git a/VoiceAgent/Participant/ScreenShareView.swift b/VoiceAgent/Participant/ScreenShareView.swift index 36a438f..b0d5aa8 100644 --- a/VoiceAgent/Participant/ScreenShareView.swift +++ b/VoiceAgent/Participant/ScreenShareView.swift @@ -2,7 +2,7 @@ import LiveKitComponents /// A view that shows the screen share preview. struct ScreenShareView: View { - @LKLocalMedia private var localMedia + @LiveKitLocalMedia private var localMedia @Environment(\.namespace) private var namespace diff --git a/VoiceAgent/Start/StartView.swift b/VoiceAgent/Start/StartView.swift index 1a613f4..e615c9d 100644 --- a/VoiceAgent/Start/StartView.swift +++ b/VoiceAgent/Start/StartView.swift @@ -3,7 +3,7 @@ import SwiftUI /// The initial view that is shown when the app is not connected to the server. struct StartView: View { - @LKConversation private var conversation + @LiveKitConversation private var conversation @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Namespace private var button From 8e6a4eb448247cdc039716cbe0c87e67c855e23b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 16 Oct 2025 10:37:19 +0200 Subject: [PATCH 09/21] Renaming --- VoiceAgent/App/AppView.swift | 30 +++++++++---------- VoiceAgent/Chat/ChatTextInputView.swift | 4 +-- VoiceAgent/ControlBar/ControlBar.swift | 14 ++++----- .../Participant/AgentParticipantView.swift | 4 +-- VoiceAgent/Start/StartView.swift | 4 +-- VoiceAgent/VoiceAgentApp.swift | 9 +++--- 6 files changed, 32 insertions(+), 33 deletions(-) diff --git a/VoiceAgent/App/AppView.swift b/VoiceAgent/App/AppView.swift index 880a187..a704e71 100644 --- a/VoiceAgent/App/AppView.swift +++ b/VoiceAgent/App/AppView.swift @@ -2,7 +2,7 @@ import LiveKit import SwiftUI struct AppView: View { - @LiveKitConversation private var conversation + @LiveKitSession private var session @LiveKitLocalMedia private var localMedia @State private var chat: Bool = false @@ -11,7 +11,7 @@ struct AppView: View { var body: some View { ZStack(alignment: .top) { - if conversation.isReady { + if session.isReady { interactions() } else { start() @@ -22,18 +22,18 @@ struct AppView: View { .environment(\.namespace, namespace) #if os(visionOS) .ornament(attachmentAnchor: .scene(.bottom)) { - if conversation.isReady { + if session.isReady { ControlBar(chat: $chat) .glassBackgroundEffect() } } - .alert("warning.reconnecting", isPresented: .constant(conversation.connectionState == .reconnecting)) {} - .alert(conversation.error?.localizedDescription ?? "error.title", isPresented: .constant(conversation.error != nil)) { - Button("error.ok") { conversation.resetError() } + .alert("warning.reconnecting", isPresented: .constant(session.connectionState == .reconnecting)) {} + .alert(session.error?.localizedDescription ?? "error.title", isPresented: .constant(session.error != nil)) { + Button("error.ok") { session.resetError() } } #else .safeAreaInset(edge: .bottom) { - if conversation.isReady, !keyboardFocus { + if session.isReady, !keyboardFocus { ControlBar(chat: $chat) .transition(.asymmetric(insertion: .move(edge: .bottom).combined(with: .opacity), removal: .opacity)) } @@ -41,12 +41,12 @@ struct AppView: View { #endif .background(.bg1) .animation(.default, value: chat) - .animation(.default, value: conversation.isReady) - .animation(.default, value: conversation.error?.localizedDescription) + .animation(.default, value: session.isReady) + .animation(.default, value: session.error?.localizedDescription) .animation(.default, value: localMedia.isCameraEnabled) .animation(.default, value: localMedia.isScreenShareEnabled) #if os(iOS) - .sensoryFeedback(.impact, trigger: conversation.isListening) { !$0 && $1 } + .sensoryFeedback(.impact, trigger: session.isListening) { !$0 && $1 } #endif } @@ -82,12 +82,12 @@ struct AppView: View { @ViewBuilder private func errors() -> some View { #if !os(visionOS) - if case .reconnecting = conversation.connectionState { + if case .reconnecting = session.connectionState { WarningView(warning: "warning.reconnecting") } - if let error = conversation.error { - ErrorView(error: error) { conversation.resetError() } + if let error = session.error { + ErrorView(error: error) { session.resetError() } } #endif } @@ -95,14 +95,14 @@ struct AppView: View { @ViewBuilder private func agentListening() -> some View { ZStack { - if conversation.messages.isEmpty, + if session.messages.isEmpty, !localMedia.isCameraEnabled, !localMedia.isScreenShareEnabled { AgentListeningView() } } - .animation(.default, value: conversation.messages.isEmpty) + .animation(.default, value: session.messages.isEmpty) } } diff --git a/VoiceAgent/Chat/ChatTextInputView.swift b/VoiceAgent/Chat/ChatTextInputView.swift index 491ed18..4230b39 100644 --- a/VoiceAgent/Chat/ChatTextInputView.swift +++ b/VoiceAgent/Chat/ChatTextInputView.swift @@ -3,7 +3,7 @@ import SwiftUI /// A multiplatform view that shows the chat input text field and send button. struct ChatTextInputView: View { - @LiveKitConversation private var conversation + @LiveKitSession private var session @Environment(\.horizontalSizeClass) private var horizontalSizeClass @FocusState.Binding var keyboardFocus: Bool @@ -82,6 +82,6 @@ struct ChatTextInputView: View { let text = messageText messageText = "" keyboardFocus = false - await conversation.send(text: text) + await session.send(text: text) } } diff --git a/VoiceAgent/ControlBar/ControlBar.swift b/VoiceAgent/ControlBar/ControlBar.swift index fbf52e9..4efd91c 100644 --- a/VoiceAgent/ControlBar/ControlBar.swift +++ b/VoiceAgent/ControlBar/ControlBar.swift @@ -4,7 +4,7 @@ import LiveKitComponents /// Available controls depend on the agent features and the track availability. /// - SeeAlso: ``AgentFeatures`` struct ControlBar: View { - @LiveKitConversation private var conversation + @LiveKitSession private var session @LiveKitLocalMedia private var localMedia @Binding var chat: Bool @@ -126,7 +126,7 @@ struct ControlBar: View { Spacer() } .frame(width: Constants.buttonWidth) - .disabled(!conversation.hasAgents) + .disabled(!session.hasAgents) } @ViewBuilder @@ -146,7 +146,7 @@ struct ControlBar: View { borderColor: .separator1 ) ) - .disabled(!conversation.hasAgents) + .disabled(!session.hasAgents) } @ViewBuilder @@ -166,14 +166,14 @@ struct ControlBar: View { borderColor: .separator1 ) ) - .disabled(!conversation.hasAgents) + .disabled(!session.hasAgents) } @ViewBuilder private func disconnectButton() -> some View { AsyncButton { - await conversation.end() - conversation.restoreMessageHistory([]) + await session.end() + session.restoreMessageHistory([]) } label: { Image(systemName: "phone.down.fill") .frame(width: Constants.buttonWidth, height: Constants.buttonHeight) @@ -186,7 +186,7 @@ struct ControlBar: View { borderColor: .separatorSerious ) ) - .disabled(conversation.connectionState == .disconnected) + .disabled(session.connectionState == .disconnected) } } diff --git a/VoiceAgent/Participant/AgentParticipantView.swift b/VoiceAgent/Participant/AgentParticipantView.swift index 7d1e374..471815e 100644 --- a/VoiceAgent/Participant/AgentParticipantView.swift +++ b/VoiceAgent/Participant/AgentParticipantView.swift @@ -4,7 +4,7 @@ import LiveKitComponents /// or the audio visualizer (if available). /// - Note: If both are unavailable, the view will show a placeholder visualizer. struct AgentParticipantView: View { - @LiveKitConversation private var conversation + @LiveKitSession private var session @LiveKitAgent private var agent @Environment(\.namespace) private var namespace @@ -40,7 +40,7 @@ struct AgentParticipantView: View { barMinOpacity: 0.1) .frame(maxWidth: 75 * .grid, maxHeight: 48 * .grid) .transition(.opacity) - } else if conversation.isReady { + } else if session.isReady { BarAudioVisualizer(audioTrack: nil, agentState: .listening, barCount: 1, diff --git a/VoiceAgent/Start/StartView.swift b/VoiceAgent/Start/StartView.swift index e615c9d..fa50029 100644 --- a/VoiceAgent/Start/StartView.swift +++ b/VoiceAgent/Start/StartView.swift @@ -3,7 +3,7 @@ import SwiftUI /// The initial view that is shown when the app is not connected to the server. struct StartView: View { - @LiveKitConversation private var conversation + @LiveKitSession private var session @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Namespace private var button @@ -57,7 +57,7 @@ struct StartView: View { @ViewBuilder private func connectButton() -> some View { AsyncButton { - await conversation.start() + await session.start() } label: { HStack { Spacer() diff --git a/VoiceAgent/VoiceAgentApp.swift b/VoiceAgent/VoiceAgentApp.swift index 04a395e..278cf32 100644 --- a/VoiceAgent/VoiceAgentApp.swift +++ b/VoiceAgent/VoiceAgentApp.swift @@ -13,15 +13,14 @@ struct VoiceAgentApp: App { // - Enable your sandbox here https://cloud.livekit.io/projects/p_/sandbox/templates/token-server // - Create .env.xcconfig with your LIVEKIT_SANDBOX_ID private static let sandboxId = Bundle.main.object(forInfoDictionaryKey: "LiveKitSandboxId") as! String - @StateObject private var conversation = Conversation(credentials: CachingCredentialsProvider(SandboxTokenServer(id: Self.sandboxId)), - // agentName: ... - room: Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)))) + @StateObject private var session = Session(tokenSource: SandboxTokenSource(id: Self.sandboxId), + room: Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)))) var body: some Scene { WindowGroup { AppView() - .environmentObject(conversation) - .environmentObject(LocalMedia(conversation: conversation)) + .environmentObject(session) + .environmentObject(LocalMedia(session: session)) } #if os(macOS) .defaultSize(width: 900, height: 900) From 38b36c48b33329d6b1695f04048bd91b5f3671a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:04:38 +0200 Subject: [PATCH 10/21] Pass options --- VoiceAgent/VoiceAgentApp.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/VoiceAgent/VoiceAgentApp.swift b/VoiceAgent/VoiceAgentApp.swift index 278cf32..41fae68 100644 --- a/VoiceAgent/VoiceAgentApp.swift +++ b/VoiceAgent/VoiceAgentApp.swift @@ -13,8 +13,11 @@ struct VoiceAgentApp: App { // - Enable your sandbox here https://cloud.livekit.io/projects/p_/sandbox/templates/token-server // - Create .env.xcconfig with your LIVEKIT_SANDBOX_ID private static let sandboxId = Bundle.main.object(forInfoDictionaryKey: "LiveKitSandboxId") as! String - @StateObject private var session = Session(tokenSource: SandboxTokenSource(id: Self.sandboxId), - room: Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)))) + + @StateObject private var session = Session( + tokenSource: SandboxTokenSource(id: Self.sandboxId), + options: Session.Options(room: Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)))) + ) var body: some Scene { WindowGroup { From 049f3d6d5b5532204478b8e7d7c35ae117c37552 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 16 Oct 2025 13:14:28 +0200 Subject: [PATCH 11/21] Nest --- VoiceAgent/VoiceAgentApp.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/VoiceAgent/VoiceAgentApp.swift b/VoiceAgent/VoiceAgentApp.swift index 41fae68..cffc055 100644 --- a/VoiceAgent/VoiceAgentApp.swift +++ b/VoiceAgent/VoiceAgentApp.swift @@ -16,7 +16,7 @@ struct VoiceAgentApp: App { @StateObject private var session = Session( tokenSource: SandboxTokenSource(id: Self.sandboxId), - options: Session.Options(room: Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)))) + options: SessionOptions(room: Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)))) ) var body: some Scene { From 9fa33d2a1dc18e999f3bdd9d84b5a7b890a0deaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Tue, 21 Oct 2025 12:39:07 +0200 Subject: [PATCH 12/21] Remove wrappers --- VoiceAgent/App/AppView.swift | 4 ++-- VoiceAgent/Chat/ChatTextInputView.swift | 2 +- VoiceAgent/ControlBar/AudioDeviceSelector.swift | 2 +- VoiceAgent/ControlBar/ControlBar.swift | 10 +++++----- VoiceAgent/ControlBar/VideoDeviceSelector.swift | 2 +- VoiceAgent/Helpers/Environment.swift | 2 ++ VoiceAgent/Interactions/TextInteractionView.swift | 4 ++-- VoiceAgent/Participant/AgentParticipantView.swift | 5 ++--- VoiceAgent/Participant/LocalParticipantView.swift | 2 +- VoiceAgent/Participant/ScreenShareView.swift | 2 +- VoiceAgent/Start/StartView.swift | 2 +- VoiceAgent/VoiceAgentApp.swift | 1 + 12 files changed, 20 insertions(+), 18 deletions(-) diff --git a/VoiceAgent/App/AppView.swift b/VoiceAgent/App/AppView.swift index a704e71..df076fe 100644 --- a/VoiceAgent/App/AppView.swift +++ b/VoiceAgent/App/AppView.swift @@ -2,8 +2,8 @@ import LiveKit import SwiftUI struct AppView: View { - @LiveKitSession private var session - @LiveKitLocalMedia private var localMedia + @EnvironmentObject private var session: Session + @EnvironmentObject private var localMedia: LocalMedia @State private var chat: Bool = false @FocusState private var keyboardFocus: Bool diff --git a/VoiceAgent/Chat/ChatTextInputView.swift b/VoiceAgent/Chat/ChatTextInputView.swift index 4230b39..17cede3 100644 --- a/VoiceAgent/Chat/ChatTextInputView.swift +++ b/VoiceAgent/Chat/ChatTextInputView.swift @@ -3,7 +3,7 @@ import SwiftUI /// A multiplatform view that shows the chat input text field and send button. struct ChatTextInputView: View { - @LiveKitSession private var session + @EnvironmentObject private var session: Session @Environment(\.horizontalSizeClass) private var horizontalSizeClass @FocusState.Binding var keyboardFocus: Bool diff --git a/VoiceAgent/ControlBar/AudioDeviceSelector.swift b/VoiceAgent/ControlBar/AudioDeviceSelector.swift index 4bb1d4d..66484d2 100644 --- a/VoiceAgent/ControlBar/AudioDeviceSelector.swift +++ b/VoiceAgent/ControlBar/AudioDeviceSelector.swift @@ -4,7 +4,7 @@ import SwiftUI #if os(macOS) /// A platform-specific view that shows a list of available audio devices. struct AudioDeviceSelector: View { - @LiveKitLocalMedia private var localMedia + @EnvironmentObject private var localMedia: LocalMedia var body: some View { Menu { diff --git a/VoiceAgent/ControlBar/ControlBar.swift b/VoiceAgent/ControlBar/ControlBar.swift index 4efd91c..6432dfc 100644 --- a/VoiceAgent/ControlBar/ControlBar.swift +++ b/VoiceAgent/ControlBar/ControlBar.swift @@ -4,8 +4,8 @@ import LiveKitComponents /// Available controls depend on the agent features and the track availability. /// - SeeAlso: ``AgentFeatures`` struct ControlBar: View { - @LiveKitSession private var session - @LiveKitLocalMedia private var localMedia + @EnvironmentObject private var session: Session + @EnvironmentObject private var localMedia: LocalMedia @Binding var chat: Bool @Environment(\.horizontalSizeClass) private var horizontalSizeClass @@ -126,7 +126,7 @@ struct ControlBar: View { Spacer() } .frame(width: Constants.buttonWidth) - .disabled(!session.hasAgents) + .disabled(!session.hasAgent) } @ViewBuilder @@ -146,7 +146,7 @@ struct ControlBar: View { borderColor: .separator1 ) ) - .disabled(!session.hasAgents) + .disabled(!session.hasAgent) } @ViewBuilder @@ -166,7 +166,7 @@ struct ControlBar: View { borderColor: .separator1 ) ) - .disabled(!session.hasAgents) + .disabled(!session.hasAgent) } @ViewBuilder diff --git a/VoiceAgent/ControlBar/VideoDeviceSelector.swift b/VoiceAgent/ControlBar/VideoDeviceSelector.swift index 2eac179..e7eff4d 100644 --- a/VoiceAgent/ControlBar/VideoDeviceSelector.swift +++ b/VoiceAgent/ControlBar/VideoDeviceSelector.swift @@ -5,7 +5,7 @@ import SwiftUI #if os(macOS) /// A platform-specific view that shows a list of available video devices. struct VideoDeviceSelector: View { - @LiveKitLocalMedia private var localMedia + @EnvironmentObject private var localMedia: LocalMedia var body: some View { Menu { diff --git a/VoiceAgent/Helpers/Environment.swift b/VoiceAgent/Helpers/Environment.swift index 144539d..5aff874 100644 --- a/VoiceAgent/Helpers/Environment.swift +++ b/VoiceAgent/Helpers/Environment.swift @@ -1,5 +1,7 @@ +import LiveKit import SwiftUI extension EnvironmentValues { @Entry var namespace: Namespace.ID? // don't initialize outside View + @Entry var agent: Agent? } diff --git a/VoiceAgent/Interactions/TextInteractionView.swift b/VoiceAgent/Interactions/TextInteractionView.swift index 12abdb6..b4858e1 100644 --- a/VoiceAgent/Interactions/TextInteractionView.swift +++ b/VoiceAgent/Interactions/TextInteractionView.swift @@ -10,8 +10,8 @@ import SwiftUI /// /// Additionally, the view shows a complete chat view with text input capabilities. struct TextInteractionView: View { - @LiveKitAgent private var agent - @LiveKitLocalMedia private var localMedia + @EnvironmentObject private var localMedia: LocalMedia + @Environment(\.agent) private var agent @FocusState.Binding var keyboardFocus: Bool diff --git a/VoiceAgent/Participant/AgentParticipantView.swift b/VoiceAgent/Participant/AgentParticipantView.swift index 471815e..c80054a 100644 --- a/VoiceAgent/Participant/AgentParticipantView.swift +++ b/VoiceAgent/Participant/AgentParticipantView.swift @@ -4,9 +4,8 @@ import LiveKitComponents /// or the audio visualizer (if available). /// - Note: If both are unavailable, the view will show a placeholder visualizer. struct AgentParticipantView: View { - @LiveKitSession private var session - @LiveKitAgent private var agent - + @EnvironmentObject private var session: Session + @Environment(\.agent) private var agent @Environment(\.namespace) private var namespace /// Reveals the avatar camera view when true. @SceneStorage("videoTransition") private var videoTransition = false diff --git a/VoiceAgent/Participant/LocalParticipantView.swift b/VoiceAgent/Participant/LocalParticipantView.swift index c8cce3b..6aa524a 100644 --- a/VoiceAgent/Participant/LocalParticipantView.swift +++ b/VoiceAgent/Participant/LocalParticipantView.swift @@ -2,7 +2,7 @@ import LiveKitComponents /// A view that shows the local participant's camera view with flip control. struct LocalParticipantView: View { - @LiveKitLocalMedia private var localMedia + @EnvironmentObject private var localMedia: LocalMedia @Environment(\.namespace) private var namespace diff --git a/VoiceAgent/Participant/ScreenShareView.swift b/VoiceAgent/Participant/ScreenShareView.swift index b0d5aa8..43f9693 100644 --- a/VoiceAgent/Participant/ScreenShareView.swift +++ b/VoiceAgent/Participant/ScreenShareView.swift @@ -2,7 +2,7 @@ import LiveKitComponents /// A view that shows the screen share preview. struct ScreenShareView: View { - @LiveKitLocalMedia private var localMedia + @EnvironmentObject private var localMedia: LocalMedia @Environment(\.namespace) private var namespace diff --git a/VoiceAgent/Start/StartView.swift b/VoiceAgent/Start/StartView.swift index fa50029..4712bc4 100644 --- a/VoiceAgent/Start/StartView.swift +++ b/VoiceAgent/Start/StartView.swift @@ -3,7 +3,7 @@ import SwiftUI /// The initial view that is shown when the app is not connected to the server. struct StartView: View { - @LiveKitSession private var session + @EnvironmentObject private var session: Session @Environment(\.horizontalSizeClass) private var horizontalSizeClass @Namespace private var button diff --git a/VoiceAgent/VoiceAgentApp.swift b/VoiceAgent/VoiceAgentApp.swift index cffc055..6f51175 100644 --- a/VoiceAgent/VoiceAgentApp.swift +++ b/VoiceAgent/VoiceAgentApp.swift @@ -23,6 +23,7 @@ struct VoiceAgentApp: App { WindowGroup { AppView() .environmentObject(session) + .environment(\.agent, session.agent) .environmentObject(LocalMedia(session: session)) } #if os(macOS) From 7d619546614a3d025075ad6ef23c0628dca67400 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 23 Oct 2025 09:58:50 +0200 Subject: [PATCH 13/21] Remove agent key, use new props --- VoiceAgent/App/AppView.swift | 8 ++++---- VoiceAgent/ControlBar/ControlBar.swift | 8 ++++---- VoiceAgent/Helpers/Environment.swift | 1 - VoiceAgent/Interactions/TextInteractionView.swift | 6 +++--- VoiceAgent/Participant/AgentParticipantView.swift | 13 ++++++------- VoiceAgent/VoiceAgentApp.swift | 3 +-- 6 files changed, 18 insertions(+), 21 deletions(-) diff --git a/VoiceAgent/App/AppView.swift b/VoiceAgent/App/AppView.swift index df076fe..493e441 100644 --- a/VoiceAgent/App/AppView.swift +++ b/VoiceAgent/App/AppView.swift @@ -11,7 +11,7 @@ struct AppView: View { var body: some View { ZStack(alignment: .top) { - if session.isReady { + if session.isConnected { interactions() } else { start() @@ -33,7 +33,7 @@ struct AppView: View { } #else .safeAreaInset(edge: .bottom) { - if session.isReady, !keyboardFocus { + if session.isConnected, !keyboardFocus { ControlBar(chat: $chat) .transition(.asymmetric(insertion: .move(edge: .bottom).combined(with: .opacity), removal: .opacity)) } @@ -41,12 +41,12 @@ struct AppView: View { #endif .background(.bg1) .animation(.default, value: chat) - .animation(.default, value: session.isReady) + .animation(.default, value: session.isConnected) .animation(.default, value: session.error?.localizedDescription) .animation(.default, value: localMedia.isCameraEnabled) .animation(.default, value: localMedia.isScreenShareEnabled) #if os(iOS) - .sensoryFeedback(.impact, trigger: session.isListening) { !$0 && $1 } + .sensoryFeedback(.impact, trigger: session.agent.agentState) { $0 == nil && $1 == .listening } #endif } diff --git a/VoiceAgent/ControlBar/ControlBar.swift b/VoiceAgent/ControlBar/ControlBar.swift index 6432dfc..5759f3b 100644 --- a/VoiceAgent/ControlBar/ControlBar.swift +++ b/VoiceAgent/ControlBar/ControlBar.swift @@ -126,7 +126,7 @@ struct ControlBar: View { Spacer() } .frame(width: Constants.buttonWidth) - .disabled(!session.hasAgent) + .disabled(!session.agent.isConnected) } @ViewBuilder @@ -146,7 +146,7 @@ struct ControlBar: View { borderColor: .separator1 ) ) - .disabled(!session.hasAgent) + .disabled(!session.agent.isConnected) } @ViewBuilder @@ -166,7 +166,7 @@ struct ControlBar: View { borderColor: .separator1 ) ) - .disabled(!session.hasAgent) + .disabled(!session.agent.isConnected) } @ViewBuilder @@ -186,7 +186,7 @@ struct ControlBar: View { borderColor: .separatorSerious ) ) - .disabled(session.connectionState == .disconnected) + .disabled(!session.isConnected) } } diff --git a/VoiceAgent/Helpers/Environment.swift b/VoiceAgent/Helpers/Environment.swift index 5aff874..8229288 100644 --- a/VoiceAgent/Helpers/Environment.swift +++ b/VoiceAgent/Helpers/Environment.swift @@ -3,5 +3,4 @@ import SwiftUI extension EnvironmentValues { @Entry var namespace: Namespace.ID? // don't initialize outside View - @Entry var agent: Agent? } diff --git a/VoiceAgent/Interactions/TextInteractionView.swift b/VoiceAgent/Interactions/TextInteractionView.swift index b4858e1..45fad4e 100644 --- a/VoiceAgent/Interactions/TextInteractionView.swift +++ b/VoiceAgent/Interactions/TextInteractionView.swift @@ -10,8 +10,8 @@ import SwiftUI /// /// Additionally, the view shows a complete chat view with text input capabilities. struct TextInteractionView: View { + @EnvironmentObject private var session: Session @EnvironmentObject private var localMedia: LocalMedia - @Environment(\.agent) private var agent @FocusState.Binding var keyboardFocus: Bool @@ -40,12 +40,12 @@ struct TextInteractionView: View { HStack { Spacer() AgentParticipantView() - .frame(maxWidth: agent?.avatarVideoTrack != nil ? 50 * .grid : 25 * .grid) + .frame(maxWidth: session.agent.avatarVideoTrack != nil ? 50 * .grid : 25 * .grid) ScreenShareView() LocalParticipantView() Spacer() } - .frame(height: localMedia.isCameraEnabled || localMedia.isScreenShareEnabled || agent?.avatarVideoTrack != nil ? 50 * .grid : 25 * .grid) + .frame(height: localMedia.isCameraEnabled || localMedia.isScreenShareEnabled || session.agent.avatarVideoTrack != nil ? 50 * .grid : 25 * .grid) .safeAreaPadding() } } diff --git a/VoiceAgent/Participant/AgentParticipantView.swift b/VoiceAgent/Participant/AgentParticipantView.swift index c80054a..1025a4d 100644 --- a/VoiceAgent/Participant/AgentParticipantView.swift +++ b/VoiceAgent/Participant/AgentParticipantView.swift @@ -5,18 +5,17 @@ import LiveKitComponents /// - Note: If both are unavailable, the view will show a placeholder visualizer. struct AgentParticipantView: View { @EnvironmentObject private var session: Session - @Environment(\.agent) private var agent @Environment(\.namespace) private var namespace /// Reveals the avatar camera view when true. @SceneStorage("videoTransition") private var videoTransition = false var body: some View { ZStack { - if let avatarVideoTrack = agent?.avatarVideoTrack { + if let avatarVideoTrack = session.agent.avatarVideoTrack { SwiftUIVideoView(avatarVideoTrack) .clipShape(RoundedRectangle(cornerRadius: .cornerRadiusPerPlatform)) .aspectRatio(avatarVideoTrack.aspectRatio, contentMode: .fit) - .padding(.horizontal, agent?.avatarVideoTrack?.aspectRatio == 1 ? 4 * .grid : .zero) + .padding(.horizontal, session.agent.avatarVideoTrack?.aspectRatio == 1 ? 4 * .grid : .zero) .shadow(radius: 20, y: 10) .mask( GeometryReader { proxy in @@ -31,15 +30,15 @@ struct AgentParticipantView: View { .onAppear { videoTransition = true } - } else if let audioTrack = agent?.audioTrack { + } else if let audioTrack = session.agent.audioTrack { BarAudioVisualizer(audioTrack: audioTrack, - agentState: agent?.state ?? .listening, + agentState: session.agent.agentState ?? .listening, barCount: 5, barSpacingFactor: 0.05, barMinOpacity: 0.1) .frame(maxWidth: 75 * .grid, maxHeight: 48 * .grid) .transition(.opacity) - } else if session.isReady { + } else if session.isConnected { BarAudioVisualizer(audioTrack: nil, agentState: .listening, barCount: 1, @@ -48,7 +47,7 @@ struct AgentParticipantView: View { .transition(.opacity) } } - .animation(.snappy, value: agent?.audioTrack?.id) + .animation(.snappy, value: session.agent.audioTrack?.id) .matchedGeometryEffect(id: "agent", in: namespace!) } } diff --git a/VoiceAgent/VoiceAgentApp.swift b/VoiceAgent/VoiceAgentApp.swift index 6f51175..da6b2ea 100644 --- a/VoiceAgent/VoiceAgentApp.swift +++ b/VoiceAgent/VoiceAgentApp.swift @@ -14,7 +14,7 @@ struct VoiceAgentApp: App { // - Create .env.xcconfig with your LIVEKIT_SANDBOX_ID private static let sandboxId = Bundle.main.object(forInfoDictionaryKey: "LiveKitSandboxId") as! String - @StateObject private var session = Session( + private let session = Session( tokenSource: SandboxTokenSource(id: Self.sandboxId), options: SessionOptions(room: Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)))) ) @@ -23,7 +23,6 @@ struct VoiceAgentApp: App { WindowGroup { AppView() .environmentObject(session) - .environment(\.agent, session.agent) .environmentObject(LocalMedia(session: session)) } #if os(macOS) From b329d36bea6fa698547c69f65fc525ce39cddcb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 23 Oct 2025 11:59:04 +0200 Subject: [PATCH 14/21] Errors --- VoiceAgent/App/AppView.swift | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/VoiceAgent/App/AppView.swift b/VoiceAgent/App/AppView.swift index 493e441..b10c699 100644 --- a/VoiceAgent/App/AppView.swift +++ b/VoiceAgent/App/AppView.swift @@ -22,14 +22,20 @@ struct AppView: View { .environment(\.namespace, namespace) #if os(visionOS) .ornament(attachmentAnchor: .scene(.bottom)) { - if session.isReady { + if session.isConnected { ControlBar(chat: $chat) .glassBackgroundEffect() } } .alert("warning.reconnecting", isPresented: .constant(session.connectionState == .reconnecting)) {} .alert(session.error?.localizedDescription ?? "error.title", isPresented: .constant(session.error != nil)) { - Button("error.ok") { session.resetError() } + Button("error.ok") { session.dismissError() } + } + .alert(session.agent.error?.localizedDescription ?? "error.title", isPresented: .constant(session.agent.error != nil)) { + Button("error.ok") { Task { await session.end() } } + } + .alert(localMedia.error?.localizedDescription ?? "error.title", isPresented: .constant(localMedia.error != nil)) { + Button("error.ok") { localMedia.dismissError() } } #else .safeAreaInset(edge: .bottom) { @@ -43,8 +49,10 @@ struct AppView: View { .animation(.default, value: chat) .animation(.default, value: session.isConnected) .animation(.default, value: session.error?.localizedDescription) + .animation(.default, value: session.agent.error?.localizedDescription) .animation(.default, value: localMedia.isCameraEnabled) .animation(.default, value: localMedia.isScreenShareEnabled) + .animation(.default, value: localMedia.error?.localizedDescription) #if os(iOS) .sensoryFeedback(.impact, trigger: session.agent.agentState) { $0 == nil && $1 == .listening } #endif @@ -87,7 +95,15 @@ struct AppView: View { } if let error = session.error { - ErrorView(error: error) { session.resetError() } + ErrorView(error: error) { session.dismissError() } + } + + if let agentError = session.agent.error { + ErrorView(error: agentError) { Task { await session.end() }} + } + + if let mediaError = localMedia.error { + ErrorView(error: mediaError) { localMedia.dismissError() } } #endif } From 3d5922c0712e1299b0b8a5d93d8c3068586357fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:34:25 +0200 Subject: [PATCH 15/21] Remove reconnect --- VoiceAgent/App/AppView.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/VoiceAgent/App/AppView.swift b/VoiceAgent/App/AppView.swift index b10c699..706e927 100644 --- a/VoiceAgent/App/AppView.swift +++ b/VoiceAgent/App/AppView.swift @@ -90,10 +90,6 @@ struct AppView: View { @ViewBuilder private func errors() -> some View { #if !os(visionOS) - if case .reconnecting = session.connectionState { - WarningView(warning: "warning.reconnecting") - } - if let error = session.error { ErrorView(error: error) { session.dismissError() } } From 702a5681c1e067dcf4a1c5a4c73b1fbac1187ec9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:41:27 +0200 Subject: [PATCH 16/21] Cleanup --- VoiceAgent/App/AppView.swift | 1 - VoiceAgent/ControlBar/AudioDeviceSelector.swift | 2 +- VoiceAgent/Helpers/Environment.swift | 1 - VoiceAgent/Interactions/VisionInteractionView.swift | 4 ++-- VoiceAgent/Participant/AgentParticipantView.swift | 1 + 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/VoiceAgent/App/AppView.swift b/VoiceAgent/App/AppView.swift index 706e927..4216866 100644 --- a/VoiceAgent/App/AppView.swift +++ b/VoiceAgent/App/AppView.swift @@ -27,7 +27,6 @@ struct AppView: View { .glassBackgroundEffect() } } - .alert("warning.reconnecting", isPresented: .constant(session.connectionState == .reconnecting)) {} .alert(session.error?.localizedDescription ?? "error.title", isPresented: .constant(session.error != nil)) { Button("error.ok") { session.dismissError() } } diff --git a/VoiceAgent/ControlBar/AudioDeviceSelector.swift b/VoiceAgent/ControlBar/AudioDeviceSelector.swift index 66484d2..025c05f 100644 --- a/VoiceAgent/ControlBar/AudioDeviceSelector.swift +++ b/VoiceAgent/ControlBar/AudioDeviceSelector.swift @@ -1,4 +1,4 @@ -import LiveKitComponents +import LiveKit import SwiftUI #if os(macOS) diff --git a/VoiceAgent/Helpers/Environment.swift b/VoiceAgent/Helpers/Environment.swift index 8229288..144539d 100644 --- a/VoiceAgent/Helpers/Environment.swift +++ b/VoiceAgent/Helpers/Environment.swift @@ -1,4 +1,3 @@ -import LiveKit import SwiftUI extension EnvironmentValues { diff --git a/VoiceAgent/Interactions/VisionInteractionView.swift b/VoiceAgent/Interactions/VisionInteractionView.swift index c1be0e0..d8bcf12 100644 --- a/VoiceAgent/Interactions/VisionInteractionView.swift +++ b/VoiceAgent/Interactions/VisionInteractionView.swift @@ -10,7 +10,7 @@ struct VisionInteractionView: View { HStack { participants().rotation3DEffect(.degrees(30), axis: .y, anchor: .trailing) agent() - chats().rotation3DEffect(.degrees(-30), axis: .y, anchor: .leading) + chatView().rotation3DEffect(.degrees(-30), axis: .y, anchor: .leading) } } @@ -34,7 +34,7 @@ struct VisionInteractionView: View { } @ViewBuilder - private func chats() -> some View { + private func chatView() -> some View { VStack { if chat { ChatView() diff --git a/VoiceAgent/Participant/AgentParticipantView.swift b/VoiceAgent/Participant/AgentParticipantView.swift index 1025a4d..0d2bb7b 100644 --- a/VoiceAgent/Participant/AgentParticipantView.swift +++ b/VoiceAgent/Participant/AgentParticipantView.swift @@ -5,6 +5,7 @@ import LiveKitComponents /// - Note: If both are unavailable, the view will show a placeholder visualizer. struct AgentParticipantView: View { @EnvironmentObject private var session: Session + @Environment(\.namespace) private var namespace /// Reveals the avatar camera view when true. @SceneStorage("videoTransition") private var videoTransition = false From 146a97cb6b25de5069caea8539dc6ef4f960c1a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 23 Oct 2025 15:54:14 +0200 Subject: [PATCH 17/21] Unused view --- VoiceAgent/Error/WarningView.swift | 34 ------------------------------ 1 file changed, 34 deletions(-) delete mode 100644 VoiceAgent/Error/WarningView.swift diff --git a/VoiceAgent/Error/WarningView.swift b/VoiceAgent/Error/WarningView.swift deleted file mode 100644 index 80e24bc..0000000 --- a/VoiceAgent/Error/WarningView.swift +++ /dev/null @@ -1,34 +0,0 @@ -import SwiftUI - -/// A view that shows a warning snackbar. -struct WarningView: View { - let warning: LocalizedStringKey - - var body: some View { - VStack(spacing: 2 * .grid) { - HStack(spacing: 2 * .grid) { - Image(systemName: "exclamationmark.triangle") - Text("warning.title") - Spacer() - } - .font(.system(size: 15, weight: .semibold)) - - Text(warning) - .font(.system(size: 15)) - .frame(maxWidth: .infinity, alignment: .leading) - } - .padding(3 * .grid) - .foregroundStyle(.fgModerate) - .background(.bgModerate) - .clipShape(RoundedRectangle(cornerRadius: .cornerRadiusSmall)) - .overlay( - RoundedRectangle(cornerRadius: .cornerRadiusSmall) - .stroke(.separatorModerate, lineWidth: 1) - ) - .safeAreaPadding(4 * .grid) - } -} - -#Preview { - WarningView(warning: "Sample warning message") -} From 403b5e9b4acce747cdba052a528156bacd41eeeb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Thu, 23 Oct 2025 16:01:18 +0200 Subject: [PATCH 18/21] Naming --- VoiceAgent/App/AppView.swift | 5 ++++- .../Interactions/TextInteractionView.swift | 2 +- .../Interactions/VisionInteractionView.swift | 2 +- .../Interactions/VoiceInteractionView.swift | 4 ++-- .../AgentView.swift} | 2 +- .../LocalParticipantView.swift | 0 .../{Participant => Media}/ScreenShareView.swift | 0 VoiceAgent/Participant/AgentListeningView.swift | 16 ---------------- VoiceAgent/VoiceAgentApp.swift | 4 ++-- 9 files changed, 11 insertions(+), 24 deletions(-) rename VoiceAgent/{Participant/AgentParticipantView.swift => Media/AgentView.swift} (98%) rename VoiceAgent/{Participant => Media}/LocalParticipantView.swift (100%) rename VoiceAgent/{Participant => Media}/ScreenShareView.swift (100%) delete mode 100644 VoiceAgent/Participant/AgentListeningView.swift diff --git a/VoiceAgent/App/AppView.swift b/VoiceAgent/App/AppView.swift index 4216866..df40750 100644 --- a/VoiceAgent/App/AppView.swift +++ b/VoiceAgent/App/AppView.swift @@ -110,7 +110,10 @@ struct AppView: View { !localMedia.isCameraEnabled, !localMedia.isScreenShareEnabled { - AgentListeningView() + Text("agent.listening") + .font(.system(size: 15)) + .shimmering() + .transition(.blurReplace) } } .animation(.default, value: session.messages.isEmpty) diff --git a/VoiceAgent/Interactions/TextInteractionView.swift b/VoiceAgent/Interactions/TextInteractionView.swift index 45fad4e..3d29b9a 100644 --- a/VoiceAgent/Interactions/TextInteractionView.swift +++ b/VoiceAgent/Interactions/TextInteractionView.swift @@ -39,7 +39,7 @@ struct TextInteractionView: View { private func participants() -> some View { HStack { Spacer() - AgentParticipantView() + AgentView() .frame(maxWidth: session.agent.avatarVideoTrack != nil ? 50 * .grid : 25 * .grid) ScreenShareView() LocalParticipantView() diff --git a/VoiceAgent/Interactions/VisionInteractionView.swift b/VoiceAgent/Interactions/VisionInteractionView.swift index d8bcf12..2661c57 100644 --- a/VoiceAgent/Interactions/VisionInteractionView.swift +++ b/VoiceAgent/Interactions/VisionInteractionView.swift @@ -27,7 +27,7 @@ struct VisionInteractionView: View { @ViewBuilder private func agent() -> some View { - AgentParticipantView() + AgentView() .frame(width: 175 * .grid) .frame(maxHeight: .infinity) .glassBackgroundEffect() diff --git a/VoiceAgent/Interactions/VoiceInteractionView.swift b/VoiceAgent/Interactions/VoiceInteractionView.swift index 942d050..e36f6c6 100644 --- a/VoiceAgent/Interactions/VoiceInteractionView.swift +++ b/VoiceAgent/Interactions/VoiceInteractionView.swift @@ -24,7 +24,7 @@ struct VoiceInteractionView: View { HStack { Spacer() .frame(width: 50 * .grid) - AgentParticipantView() + AgentView() VStack { Spacer() ScreenShareView() @@ -39,7 +39,7 @@ struct VoiceInteractionView: View { @ViewBuilder private func compact() -> some View { ZStack(alignment: .bottom) { - AgentParticipantView() + AgentView() .frame(maxWidth: .infinity, maxHeight: .infinity) .ignoresSafeArea() HStack { diff --git a/VoiceAgent/Participant/AgentParticipantView.swift b/VoiceAgent/Media/AgentView.swift similarity index 98% rename from VoiceAgent/Participant/AgentParticipantView.swift rename to VoiceAgent/Media/AgentView.swift index 0d2bb7b..a052a91 100644 --- a/VoiceAgent/Participant/AgentParticipantView.swift +++ b/VoiceAgent/Media/AgentView.swift @@ -3,7 +3,7 @@ import LiveKitComponents /// A view that combines the avatar camera view (if available) /// or the audio visualizer (if available). /// - Note: If both are unavailable, the view will show a placeholder visualizer. -struct AgentParticipantView: View { +struct AgentView: View { @EnvironmentObject private var session: Session @Environment(\.namespace) private var namespace diff --git a/VoiceAgent/Participant/LocalParticipantView.swift b/VoiceAgent/Media/LocalParticipantView.swift similarity index 100% rename from VoiceAgent/Participant/LocalParticipantView.swift rename to VoiceAgent/Media/LocalParticipantView.swift diff --git a/VoiceAgent/Participant/ScreenShareView.swift b/VoiceAgent/Media/ScreenShareView.swift similarity index 100% rename from VoiceAgent/Participant/ScreenShareView.swift rename to VoiceAgent/Media/ScreenShareView.swift diff --git a/VoiceAgent/Participant/AgentListeningView.swift b/VoiceAgent/Participant/AgentListeningView.swift deleted file mode 100644 index f2f9416..0000000 --- a/VoiceAgent/Participant/AgentListeningView.swift +++ /dev/null @@ -1,16 +0,0 @@ -import SwiftUI - -/// A tooltip that indicates that the audio is being recorded -/// e.g. while using pre-connect audio feature to initiate a conversation. -struct AgentListeningView: View { - var body: some View { - Text("agent.listening") - .font(.system(size: 15)) - .shimmering() - .transition(.blurReplace) - } -} - -#Preview { - AgentListeningView() -} diff --git a/VoiceAgent/VoiceAgentApp.swift b/VoiceAgent/VoiceAgentApp.swift index da6b2ea..9c325cd 100644 --- a/VoiceAgent/VoiceAgentApp.swift +++ b/VoiceAgent/VoiceAgentApp.swift @@ -12,10 +12,10 @@ struct VoiceAgentApp: App { // To use the LiveKit Cloud sandbox (development only) // - Enable your sandbox here https://cloud.livekit.io/projects/p_/sandbox/templates/token-server // - Create .env.xcconfig with your LIVEKIT_SANDBOX_ID - private static let sandboxId = Bundle.main.object(forInfoDictionaryKey: "LiveKitSandboxId") as! String + private static let sandboxID = Bundle.main.object(forInfoDictionaryKey: "LiveKitSandboxId") as! String private let session = Session( - tokenSource: SandboxTokenSource(id: Self.sandboxId), + tokenSource: SandboxTokenSource(id: Self.sandboxID), options: SessionOptions(room: Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)))) ) From db16da93fd4d84cfc4a28807bc0ef6bedf50759d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 27 Oct 2025 10:09:48 +0100 Subject: [PATCH 19/21] Rename --- VoiceAgent/Helpers/{View+.swift => ViewModifiers.swift} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename VoiceAgent/Helpers/{View+.swift => ViewModifiers.swift} (100%) diff --git a/VoiceAgent/Helpers/View+.swift b/VoiceAgent/Helpers/ViewModifiers.swift similarity index 100% rename from VoiceAgent/Helpers/View+.swift rename to VoiceAgent/Helpers/ViewModifiers.swift From ea3003255fd8965cd41b68a4bc2c9066a0033d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 27 Oct 2025 10:11:23 +0100 Subject: [PATCH 20/21] Update readme --- README.md | 24 ++++++++++++------------ VoiceAgent/Chat/ChatView.swift | 3 +++ VoiceAgent/ControlBar/ControlBar.swift | 6 +++--- VoiceAgent/VoiceAgentApp.swift | 16 +++++++++------- 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index bd49f41..7dd77ae 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ lk app create --template agent-starter-swift --sandbox Then, build and run the app from Xcode by opening `VoiceAgent.xcodeproj`. You may need to adjust your app signing settings to run the app on your device. > [!NOTE] -> To setup without the LiveKit CLI, clone the repository and then either create a `VoiceAgent/.env.xcconfig` with a `LIVEKIT_SANDBOX_ID` (if using a [Sandbox Token Server](https://cloud.livekit.io/projects/p_/sandbox/templates/token-server)), or open `TokenService.swift` and add your [manually generated](#token-generation) URL and token. +> To setup without the LiveKit CLI, clone the repository and then either create a `VoiceAgent/.env.xcconfig` with a `LIVEKIT_SANDBOX_ID` (if using a [Sandbox Token Server](https://cloud.livekit.io/projects/p_/sandbox/templates/token-server)), or modify `VoiceAgent/VoiceAgentApp.swift` to replace the `SandboxTokenSource` with a custom token source implementation. ## Feature overview @@ -32,9 +32,9 @@ This starter app has support for a number of features of the agents framework, a ### Text, video, and voice input -This app supports text, video, and/or voice input according to the needs of your agent. To update the features enabled in the app, edit `VoiceAgent/VoiceAgentApp.swift` and update `AgentFeatures.current` to include or exclude the features you need. +This app supports text, video, and/or voice input according to the needs of your agent. To update the features enabled in the app, edit `VoiceAgent/VoiceAgentApp.swift` and update `Features` to include or exclude the features you need. -By default, only voice and text input are enabled. +By default, all features (voice, video, and text input) are enabled. Available input types: - `.voice`: Allows the user to speak to the agent using their microphone. **Requires microphone permissions.** @@ -43,23 +43,23 @@ Available input types: If you have trouble with screensharing, refer to [the docs](https://docs.livekit.io/home/client/tracks/screenshare/) for more setup instructions. -### Preconnect audio buffer +### Session + +The app is built on top of two main observable components from the [LiveKit Swift SDK](https://github.com/livekit/client-sdk-swift): +- `Session` object to connect to the LiveKit infrastructure, interact with the `Agent`, its local state, and send/receive text messages. +- `LocalMedia` object to manage the local media tracks (audio, video, screen sharing) and their lifecycle. -This app uses `withPreConnectAudio` to capture and buffer audio before the room connection completes. This allows the connection to appear "instant" from the user's perspective and makes your app more responsive. To disable this feature, remove the call to `withPreConnectAudio` as below: +### Preconnect audio buffer -- Location: `VoiceAgent/App/AppViewModel.swift` → `connectWithVoice()` -- To disable preconnect buffering but keep voice: - - Replace the `withPreConnectAudio { ... }` block with a standard `room.connect` call and enable the microphone after connect, for example: - - Connect with `connectOptions: .init(enableMicrophone: true)` without wrapping in `withPreConnectAudio`, or - - Connect with microphone disabled and call `room.localParticipant.setMicrophone(enabled: true)` after connection. +This app enables `preConnectAudio` by default to capture and buffer audio before the room connection completes. This allows the connection to appear "instant" from the user's perspective and makes your app more responsive. To disable this feature, set `preConnectAudio` to `false` in `SessionOptions` when creating the `Session`. ### Virtual avatar support -If your agent publishes a [virtual avatar](https://docs.livekit.io/agents/integrations/avatar/), this app will automatically render the avatar’s camera feed in `AgentParticipantView` when available. +If your agent publishes a [virtual avatar](https://docs.livekit.io/agents/integrations/avatar/), this app will automatically render the avatar's camera feed in `AgentView` when available. ## Token generation in production -In a production environment, you will be responsible for developing a solution to [generate tokens for your users](https://docs.livekit.io/home/server/generating-tokens/) which is integrated with your authentication solution. You should disable your sandbox token server and modify `TokenService.swift` to use your own token server. +In a production environment, you will be responsible for developing a solution to [generate tokens for your users](https://docs.livekit.io/home/server/generating-tokens/) which is integrated with your authentication solution. You should replace your `SandboxTokenSource` with an `EndpointTokenSource` or your own `TokenSourceFixed` or `TokenSourceConfigurable` implementation. Additionally, you can use the `.cached()` extension to cache valid tokens and avoid unnecessary token requests. ## Running on Simulator diff --git a/VoiceAgent/Chat/ChatView.swift b/VoiceAgent/Chat/ChatView.swift index 153b4e5..2b35e56 100644 --- a/VoiceAgent/Chat/ChatView.swift +++ b/VoiceAgent/Chat/ChatView.swift @@ -2,9 +2,12 @@ import LiveKitComponents import SwiftUI struct ChatView: View { + @EnvironmentObject private var session: Session + var body: some View { ChatScrollView(messageBuilder: message) .padding(.horizontal) + .animation(.default, value: session.messages) } @ViewBuilder diff --git a/VoiceAgent/ControlBar/ControlBar.swift b/VoiceAgent/ControlBar/ControlBar.swift index 5759f3b..16e2c48 100644 --- a/VoiceAgent/ControlBar/ControlBar.swift +++ b/VoiceAgent/ControlBar/ControlBar.swift @@ -18,17 +18,17 @@ struct ControlBar: View { var body: some View { HStack(spacing: .zero) { biggerSpacer() - if AppFeatures.voice { + if VoiceAgentApp.Features.voice { audioControls() flexibleSpacer() } - if AppFeatures.video { + if VoiceAgentApp.Features.video { videoControls() flexibleSpacer() screenShareButton() flexibleSpacer() } - if AppFeatures.text { + if VoiceAgentApp.Features.text { textInputButton() flexibleSpacer() } diff --git a/VoiceAgent/VoiceAgentApp.swift b/VoiceAgent/VoiceAgentApp.swift index 9c325cd..3abe8bc 100644 --- a/VoiceAgent/VoiceAgentApp.swift +++ b/VoiceAgent/VoiceAgentApp.swift @@ -1,21 +1,23 @@ import LiveKit import SwiftUI -enum AppFeatures { - static let voice = true - static let video = true - static let text = true -} - @main struct VoiceAgentApp: App { + /// Enable or disable input modes in the app based on the supported agent features. + enum Features { + static let voice = true + static let video = true + static let text = true + } + // To use the LiveKit Cloud sandbox (development only) // - Enable your sandbox here https://cloud.livekit.io/projects/p_/sandbox/templates/token-server // - Create .env.xcconfig with your LIVEKIT_SANDBOX_ID private static let sandboxID = Bundle.main.object(forInfoDictionaryKey: "LiveKitSandboxId") as! String + /// For production use, replace the `SandboxTokenSource` with an `EndpointTokenSource` or your own `TokenSourceConfigurable` implementation. private let session = Session( - tokenSource: SandboxTokenSource(id: Self.sandboxID), + tokenSource: SandboxTokenSource(id: Self.sandboxID).cached(), options: SessionOptions(room: Room(roomOptions: RoomOptions(defaultScreenShareCaptureOptions: ScreenShareCaptureOptions(useBroadcastExtension: true)))) ) From 943f73159fac9bf47af5da7d013ff4c710a84e73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82az=CC=87ej=20Pankowski?= <86720177+pblazej@users.noreply.github.com> Date: Mon, 27 Oct 2025 10:24:46 +0100 Subject: [PATCH 21/21] Move to env --- README.md | 10 ++++++++-- VoiceAgent/ControlBar/ControlBar.swift | 9 ++++++--- VoiceAgent/Helpers/Environment.swift | 3 +++ VoiceAgent/VoiceAgentApp.swift | 10 +++------- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 7dd77ae..daa2dd4 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,15 @@ This starter app has support for a number of features of the agents framework, a ### Text, video, and voice input -This app supports text, video, and/or voice input according to the needs of your agent. To update the features enabled in the app, edit `VoiceAgent/VoiceAgentApp.swift` and update `Features` to include or exclude the features you need. +This app supports text, video, and/or voice input according to the needs of your agent. To update the features enabled in the app, edit `VoiceAgent/VoiceAgentApp.swift` and modify the `.environment()` modifiers to enable or disable features. -By default, all features (voice, video, and text input) are enabled. +By default, all features (voice, video, and text input) are enabled. To disable a feature, change the value from `true` to `false`: + +```swift +.environment(\.voiceEnabled, true) // Enable voice input +.environment(\.videoEnabled, false) // Disable video input +.environment(\.textEnabled, true) // Enable text input +``` Available input types: - `.voice`: Allows the user to speak to the agent using their microphone. **Requires microphone permissions.** diff --git a/VoiceAgent/ControlBar/ControlBar.swift b/VoiceAgent/ControlBar/ControlBar.swift index 16e2c48..34c140b 100644 --- a/VoiceAgent/ControlBar/ControlBar.swift +++ b/VoiceAgent/ControlBar/ControlBar.swift @@ -9,6 +9,9 @@ struct ControlBar: View { @Binding var chat: Bool @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.voiceEnabled) private var voiceEnabled + @Environment(\.videoEnabled) private var videoEnabled + @Environment(\.textEnabled) private var textEnabled private enum Constants { static let buttonWidth: CGFloat = 16 * .grid @@ -18,17 +21,17 @@ struct ControlBar: View { var body: some View { HStack(spacing: .zero) { biggerSpacer() - if VoiceAgentApp.Features.voice { + if voiceEnabled { audioControls() flexibleSpacer() } - if VoiceAgentApp.Features.video { + if videoEnabled { videoControls() flexibleSpacer() screenShareButton() flexibleSpacer() } - if VoiceAgentApp.Features.text { + if textEnabled { textInputButton() flexibleSpacer() } diff --git a/VoiceAgent/Helpers/Environment.swift b/VoiceAgent/Helpers/Environment.swift index 144539d..164a9d9 100644 --- a/VoiceAgent/Helpers/Environment.swift +++ b/VoiceAgent/Helpers/Environment.swift @@ -1,5 +1,8 @@ import SwiftUI extension EnvironmentValues { + @Entry var voiceEnabled: Bool = true + @Entry var videoEnabled: Bool = true + @Entry var textEnabled: Bool = true @Entry var namespace: Namespace.ID? // don't initialize outside View } diff --git a/VoiceAgent/VoiceAgentApp.swift b/VoiceAgent/VoiceAgentApp.swift index 3abe8bc..b9de9f0 100644 --- a/VoiceAgent/VoiceAgentApp.swift +++ b/VoiceAgent/VoiceAgentApp.swift @@ -3,13 +3,6 @@ import SwiftUI @main struct VoiceAgentApp: App { - /// Enable or disable input modes in the app based on the supported agent features. - enum Features { - static let voice = true - static let video = true - static let text = true - } - // To use the LiveKit Cloud sandbox (development only) // - Enable your sandbox here https://cloud.livekit.io/projects/p_/sandbox/templates/token-server // - Create .env.xcconfig with your LIVEKIT_SANDBOX_ID @@ -26,6 +19,9 @@ struct VoiceAgentApp: App { AppView() .environmentObject(session) .environmentObject(LocalMedia(session: session)) + .environment(\.voiceEnabled, true) + .environment(\.videoEnabled, true) + .environment(\.textEnabled, true) } #if os(macOS) .defaultSize(width: 900, height: 900)