Skip to content

Commit 09a76c5

Browse files
committed
Move media to Devices
1 parent 6ecc362 commit 09a76c5

File tree

8 files changed

+92
-73
lines changed

8 files changed

+92
-73
lines changed

VoiceAgent/Agent/AgentSession+Types.swift

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ extension AgentSession {
88
case agentNotConnected
99
case failedToConnect(Swift.Error)
1010
case failedToSend(Swift.Error)
11-
case mediaDevice(Swift.Error)
1211

1312
// var errorDescription: String? {
1413
// switch self {

VoiceAgent/Agent/AgentSession.swift

Lines changed: 2 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,6 @@ final class AgentSession: ObservableObject {
2424
}
2525
}
2626

27-
@Published private(set) var localAudioTrack: (any AudioTrack)?
28-
@Published private(set) var localCameraTrack: (any VideoTrack)?
29-
@Published private(set) var localScreenShareTrack: (any VideoTrack)?
30-
31-
// TODO: Move camera switching here (vs Devices)?
32-
33-
var isMicrophoneEnabled: Bool { localAudioTrack != nil }
34-
var isCameraEnabled: Bool { localCameraTrack != nil }
35-
var isScreenShareEnabled: Bool { localScreenShareTrack != nil }
36-
3727
@Published private(set) var agentAudioTrack: (any AudioTrack)?
3828
@Published private(set) var avatarCameraTrack: (any VideoTrack)?
3929

@@ -75,10 +65,6 @@ final class AgentSession: ObservableObject {
7565
connectionState = room.connectionState
7666
agent = room.agentParticipant
7767

78-
localAudioTrack = room.localParticipant.firstAudioTrack
79-
localCameraTrack = room.localParticipant.firstCameraVideoTrack
80-
localScreenShareTrack = room.localParticipant.firstScreenShareVideoTrack
81-
8268
agentAudioTrack = room.agentParticipant?.audioTracks.first(where: { $0.source == .microphone })?.track as? AudioTrack // remove bg audio tracks
8369
avatarCameraTrack = room.agentParticipant?.avatarWorker?.firstCameraVideoTrack
8470
}
@@ -136,6 +122,8 @@ final class AgentSession: ObservableObject {
136122
error = nil
137123
}
138124

125+
// MARK: - Messages
126+
139127
@discardableResult
140128
func send(text: String) async -> SentMessage {
141129
let message = SentMessage(id: UUID().uuidString, timestamp: Date(), content: .userText(text))
@@ -156,41 +144,4 @@ final class AgentSession: ObservableObject {
156144
func restoreMessageHistory(_ messages: [ReceivedMessage]) {
157145
self.messages = .init(uniqueKeysWithValues: messages.sorted(by: { $0.timestamp < $1.timestamp }).map { ($0.id, $0) })
158146
}
159-
160-
func toggleMicrophone() async {
161-
do {
162-
try await room.localParticipant.setMicrophone(enabled: !isMicrophoneEnabled)
163-
} catch {
164-
self.error = .mediaDevice(error)
165-
}
166-
}
167-
168-
func toggleCamera() async {
169-
let enable = !isCameraEnabled
170-
do {
171-
// One video track at a time
172-
if enable, isScreenShareEnabled {
173-
try await room.localParticipant.setScreenShare(enabled: false)
174-
}
175-
176-
// Hm???
177-
// let device = try await CameraCapturer.captureDevices().first(where: { $0.uniqueID == selectedVideoDeviceID })
178-
try await room.localParticipant.setCamera(enabled: enable) // captureOptions: CameraCaptureOptions(device: device))
179-
} catch {
180-
self.error = .mediaDevice(error)
181-
}
182-
}
183-
184-
func toggleScreenShare() async {
185-
let enable = !isScreenShareEnabled
186-
do {
187-
// One video track at a time
188-
if enable, isCameraEnabled {
189-
try await room.localParticipant.setCamera(enabled: false)
190-
}
191-
try await room.localParticipant.setScreenShare(enabled: enable)
192-
} catch {
193-
self.error = .mediaDevice(error)
194-
}
195-
}
196147
}

VoiceAgent/App/AppView.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import SwiftUI
22

33
struct AppView: View {
44
@EnvironmentObject private var session: AgentSession
5+
@EnvironmentObject private var devices: DeviceSwitcher
56
@State private var chat: Bool = false
67

78
@FocusState private var keyboardFocus: Bool
@@ -40,8 +41,8 @@ struct AppView: View {
4041
.background(.bg1)
4142
.animation(.default, value: chat)
4243
.animation(.default, value: session.isReady)
43-
.animation(.default, value: session.isCameraEnabled)
44-
.animation(.default, value: session.isScreenShareEnabled)
44+
.animation(.default, value: devices.isCameraEnabled)
45+
.animation(.default, value: devices.isScreenShareEnabled)
4546
.animation(.default, value: session.error?.localizedDescription)
4647
#if os(iOS)
4748
.sensoryFeedback(.impact, trigger: session.isListening) { !$0 && $1 }
@@ -94,8 +95,8 @@ struct AppView: View {
9495
private func agentListening() -> some View {
9596
ZStack {
9697
if session.messages.isEmpty,
97-
!session.isCameraEnabled,
98-
!session.isScreenShareEnabled
98+
!devices.isCameraEnabled,
99+
!devices.isScreenShareEnabled
99100
{
100101
AgentListeningView()
101102
}

VoiceAgent/ControlBar/ControlBar.swift

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import LiveKitComponents
55
/// - SeeAlso: ``AgentFeatures``
66
struct ControlBar: View {
77
@EnvironmentObject private var session: AgentSession
8+
@EnvironmentObject private var devices: DeviceSwitcher
89
@Binding var chat: Bool
910
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
1011

@@ -80,14 +81,14 @@ struct ControlBar: View {
8081
private func audioControls() -> some View {
8182
HStack(spacing: 2 * .grid) {
8283
Spacer()
83-
AsyncButton(action: session.toggleMicrophone) {
84+
AsyncButton(action: devices.toggleMicrophone) {
8485
HStack(spacing: .grid) {
85-
Image(systemName: session.isMicrophoneEnabled ? "microphone.fill" : "microphone.slash.fill")
86+
Image(systemName: devices.isMicrophoneEnabled ? "microphone.fill" : "microphone.slash.fill")
8687
.transition(.symbolEffect)
87-
BarAudioVisualizer(audioTrack: session.localAudioTrack, barColor: .fg1, barCount: 3, barSpacingFactor: 0.1)
88+
BarAudioVisualizer(audioTrack: devices.localAudioTrack, barColor: .fg1, barCount: 3, barSpacingFactor: 0.1)
8889
.frame(width: 2 * .grid, height: 0.5 * Constants.buttonHeight)
8990
.frame(maxHeight: .infinity)
90-
.id(session.localAudioTrack?.id)
91+
.id(devices.localAudioTrack?.id)
9192
}
9293
.frame(height: Constants.buttonHeight)
9394
}
@@ -105,8 +106,8 @@ struct ControlBar: View {
105106
private func videoControls() -> some View {
106107
HStack(spacing: 2 * .grid) {
107108
Spacer()
108-
AsyncButton(action: session.toggleCamera) {
109-
Image(systemName: session.isCameraEnabled ? "video.fill" : "video.slash.fill")
109+
AsyncButton(action: devices.toggleCamera) {
110+
Image(systemName: devices.isCameraEnabled ? "video.fill" : "video.slash.fill")
110111
.transition(.symbolEffect)
111112
.frame(height: Constants.buttonHeight)
112113
}
@@ -123,13 +124,13 @@ struct ControlBar: View {
123124

124125
@ViewBuilder
125126
private func screenShareButton() -> some View {
126-
AsyncButton(action: session.toggleScreenShare) {
127+
AsyncButton(action: devices.toggleScreenShare) {
127128
Image(systemName: "arrow.up.square.fill")
128129
.frame(width: Constants.buttonWidth, height: Constants.buttonHeight)
129130
}
130131
.buttonStyle(
131132
ControlBarButtonStyle(
132-
isToggled: session.isScreenShareEnabled,
133+
isToggled: devices.isScreenShareEnabled,
133134
foregroundColor: .fg1,
134135
backgroundColor: .bg2,
135136
borderColor: .separator1

VoiceAgent/Devices/DeviceSwitcher.swift

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,24 @@ import LiveKit
33

44
@MainActor
55
final class DeviceSwitcher: ObservableObject {
6+
// MARK: Error
7+
8+
enum Error: LocalizedError {
9+
case mediaDevice(Swift.Error)
10+
}
11+
612
// MARK: Devices
713

14+
@Published private(set) var error: Error?
15+
16+
@Published private(set) var localAudioTrack: (any AudioTrack)?
17+
@Published private(set) var localCameraTrack: (any VideoTrack)?
18+
@Published private(set) var localScreenShareTrack: (any VideoTrack)?
19+
20+
var isMicrophoneEnabled: Bool { localAudioTrack != nil }
21+
var isCameraEnabled: Bool { localCameraTrack != nil }
22+
var isScreenShareEnabled: Bool { localScreenShareTrack != nil }
23+
824
@Published private(set) var audioDevices: [AudioDevice] = AudioManager.shared.inputDevices
925
@Published private(set) var selectedAudioDeviceID: String = AudioManager.shared.inputDevice.deviceId
1026

@@ -22,9 +38,23 @@ final class DeviceSwitcher: ObservableObject {
2238
init(room: Room) {
2339
self.room = room
2440

41+
observeRoom()
2542
observeDevices()
2643
}
2744

45+
private func observeRoom() {
46+
Task { [weak self] in
47+
guard let changes = self?.room.changes else { return }
48+
for await _ in changes {
49+
guard let self else { return }
50+
51+
localAudioTrack = room.localParticipant.firstAudioTrack
52+
localCameraTrack = room.localParticipant.firstCameraVideoTrack
53+
localScreenShareTrack = room.localParticipant.firstScreenShareVideoTrack
54+
}
55+
}
56+
}
57+
2858
private func observeDevices() {
2959
try? AudioManager.shared.set(microphoneMuteMode: .inputMixer) // don't play mute sound effect
3060
Task {
@@ -49,9 +79,46 @@ final class DeviceSwitcher: ObservableObject {
4979
AudioManager.shared.onDeviceUpdate = nil
5080
}
5181

52-
// MARK: - Actions
82+
// MARK: - Toggle
83+
84+
func toggleMicrophone() async {
85+
do {
86+
try await room.localParticipant.setMicrophone(enabled: !isMicrophoneEnabled)
87+
} catch {
88+
self.error = .mediaDevice(error)
89+
}
90+
}
91+
92+
func toggleCamera() async {
93+
let enable = !isCameraEnabled
94+
do {
95+
// One video track at a time
96+
if enable, isScreenShareEnabled {
97+
try await room.localParticipant.setScreenShare(enabled: false)
98+
}
99+
100+
let device = try await CameraCapturer.captureDevices().first(where: { $0.uniqueID == selectedVideoDeviceID })
101+
try await room.localParticipant.setCamera(enabled: enable, captureOptions: CameraCaptureOptions(device: device))
102+
} catch {
103+
self.error = .mediaDevice(error)
104+
}
105+
}
106+
107+
func toggleScreenShare() async {
108+
let enable = !isScreenShareEnabled
109+
do {
110+
// One video track at a time
111+
if enable, isCameraEnabled {
112+
try await room.localParticipant.setCamera(enabled: false)
113+
}
114+
try await room.localParticipant.setScreenShare(enabled: enable)
115+
} catch {
116+
self.error = .mediaDevice(error)
117+
}
118+
}
119+
120+
// MARK: - Select
53121

54-
#if os(macOS)
55122
func select(audioDevice: AudioDevice) {
56123
selectedAudioDeviceID = audioDevice.deviceId
57124

@@ -66,14 +133,14 @@ final class DeviceSwitcher: ObservableObject {
66133
let captureOptions = CameraCaptureOptions(device: videoDevice)
67134
_ = try? await cameraCapturer.set(options: captureOptions)
68135
}
69-
#endif
70136

71-
// TODO: Move that to session?
72137
func switchCamera() async {
73138
guard let cameraCapturer = getCameraCapturer() else { return }
74139
_ = try? await cameraCapturer.switchCameraPosition()
75140
}
76141

142+
// MARK: - Private
143+
77144
private func getCameraCapturer() -> CameraCapturer? {
78145
guard let cameraTrack = room.localParticipant.firstCameraVideoTrack as? LocalVideoTrack else { return nil }
79146
return cameraTrack.capturer as? CameraCapturer

VoiceAgent/Interactions/TextInteractionView.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import SwiftUI
1010
/// Additionally, the view shows a complete chat view with text input capabilities.
1111
struct TextInteractionView: View {
1212
@EnvironmentObject private var session: AgentSession
13+
@EnvironmentObject private var devices: DeviceSwitcher
1314
@FocusState.Binding var keyboardFocus: Bool
1415

1516
var body: some View {
@@ -42,7 +43,7 @@ struct TextInteractionView: View {
4243
LocalParticipantView()
4344
Spacer()
4445
}
45-
.frame(height: session.isCameraEnabled || session.isScreenShareEnabled || session.avatarCameraTrack != nil ? 50 * .grid : 25 * .grid)
46+
.frame(height: devices.isCameraEnabled || devices.isScreenShareEnabled || session.avatarCameraTrack != nil ? 50 * .grid : 25 * .grid)
4647
.safeAreaPadding()
4748
}
4849
}

VoiceAgent/Participant/LocalParticipantView.swift

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@ import LiveKitComponents
22

33
/// A view that shows the local participant's camera view with flip control.
44
struct LocalParticipantView: View {
5-
@EnvironmentObject private var session: AgentSession
65
@EnvironmentObject private var devices: DeviceSwitcher
76
@Environment(\.namespace) private var namespace
87

98
var body: some View {
10-
if let cameraTrack = session.localCameraTrack {
9+
if let cameraTrack = devices.localCameraTrack {
1110
SwiftUIVideoView(cameraTrack)
1211
.clipShape(RoundedRectangle(cornerRadius: .cornerRadiusPerPlatform))
1312
.aspectRatio(cameraTrack.aspectRatio, contentMode: .fit)

VoiceAgent/Participant/ScreenShareView.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@ import LiveKitComponents
22

33
/// A view that shows the screen share preview.
44
struct ScreenShareView: View {
5-
@EnvironmentObject private var session: AgentSession
5+
@EnvironmentObject private var devices: DeviceSwitcher
66
@Environment(\.namespace) private var namespace
77

88
var body: some View {
9-
if let screenShareTrack = session.localScreenShareTrack {
9+
if let screenShareTrack = devices.localScreenShareTrack {
1010
SwiftUIVideoView(screenShareTrack)
1111
.clipShape(RoundedRectangle(cornerRadius: .cornerRadiusPerPlatform))
1212
.aspectRatio(screenShareTrack.aspectRatio, contentMode: .fit)

0 commit comments

Comments
 (0)