examples/simple-chatbot: move clients to client directory

This commit is contained in:
Aleix Conchillo Flaqué
2025-01-11 19:10:59 -08:00
parent a8ae79831e
commit a04a920e54
113 changed files with 6 additions and 6 deletions

View File

@@ -0,0 +1,107 @@
import SwiftUI
struct MeetingView: View {
@State private var showingSettings = false
@EnvironmentObject private var model: CallContainerModel
var body: some View {
VStack {
// Header Toolbar
HStack {
Image("dailyBot")
.resizable()
.frame(width: 48, height: 48)
Spacer()
HStack {
Image(systemName: "stopwatch")
.resizable()
.frame(width: 24, height: 24)
Text(timerString(from: self.model.timerCount))
.font(.headline)
}.padding()
.background(Color.timer)
.cornerRadius(12)
}
.padding()
// Main Panel
VStack {
VStack {
WaveformView(audioLevel: model.remoteAudioLevel, isBotReady: model.isBotReady, voiceClientStatus: model.voiceClientStatus)
}
.frame(maxHeight: .infinity)
VStack {
HStack {
MicrophoneView(audioLevel: model.localAudioLevel, isMuted: !self.model.isMicEnabled)
.frame(width: 160, height: 160)
.onTapGesture {
self.model.toggleMicInput()
}
}
}
.frame(height: 120)
}
.frame(maxHeight: .infinity)
.padding()
// Bottom Panel
VStack {
HStack {
Button(action: {
self.showingSettings = true
}) {
HStack {
Image(systemName: "gearshape")
.resizable()
.frame(width: 24, height: 24)
Text("Settings")
}
.frame(maxWidth: .infinity)
.padding()
.sheet(isPresented: $showingSettings) {
SettingsView(showingSettings: $showingSettings).environmentObject(self.model)
}
}
.border(Color.buttonsBorder, width: 1)
.cornerRadius(12)
}
.foregroundColor(.black)
.padding([.top, .horizontal])
Button(action: {
self.model.disconnect()
}) {
HStack {
Image(systemName: "rectangle.portrait.and.arrow.right")
.resizable()
.frame(width: 24, height: 24)
Text("End")
}
.frame(maxWidth: .infinity)
.padding()
}
.foregroundColor(.white)
.background(Color.black)
.cornerRadius(12)
.padding([.bottom, .horizontal])
}
}
.background(Color.backgroundApp)
.toast(message: model.toastMessage, isShowing: model.showToast)
}
func timerString(from count: Int) -> String {
let hours = count / 3600
let minutes = (count % 3600) / 60
let seconds = count % 60
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
}
#Preview {
let mockModel = MockCallContainerModel()
let result = MeetingView().environmentObject(mockModel as CallContainerModel)
mockModel.startAudioLevelSimulation()
return result
}

View File

@@ -0,0 +1,42 @@
import SwiftUI
struct PreJoinView: View {
@State var backendURL: String
@EnvironmentObject private var model: CallContainerModel
init() {
let currentSettings = SettingsManager.getSettings()
self.backendURL = currentSettings.backendURL
}
var body: some View {
VStack(spacing: 20) {
Image("pipecat")
.resizable()
.frame(width: 80, height: 80)
Text("Pipecat Client iOS.")
.font(.headline)
TextField("Server URL", text: $backendURL)
.textFieldStyle(RoundedBorderTextFieldStyle())
.frame(maxWidth: .infinity)
.padding([.bottom, .horizontal])
Button("Connect") {
self.model.connect(backendURL: self.backendURL)
}
.padding()
.background(Color.black)
.foregroundColor(.white)
.cornerRadius(8)
}
.padding()
.frame(maxHeight: .infinity)
.background(Color.backgroundApp)
.toast(message: model.toastMessage, isShowing: model.showToast)
}
}
#Preview {
PreJoinView().environmentObject(MockCallContainerModel() as CallContainerModel)
}

View File

@@ -0,0 +1,44 @@
import SwiftUI
struct MicrophoneView: View {
var audioLevel: Float // Current audio level
var isMuted: Bool // Muted state
var body: some View {
GeometryReader { geometry in
let width = geometry.size.width
let circleSize = width * 0.9
let innerCircleSize = width * 0.82
let audioCircleSize = CGFloat(audioLevel) * (width * 0.95)
ZStack {
Circle()
.stroke(Color.gray, lineWidth: 1)
.frame(width: circleSize)
Circle()
.fill(isMuted ? Color.disabledMic : Color.backgroundCircle)
.frame(width: innerCircleSize)
if !isMuted {
Circle()
.fill(Color.micVolume)
.opacity(0.5)
.frame(width: audioCircleSize)
.animation(.easeInOut(duration: 0.2), value: audioLevel)
}
Image(systemName: isMuted ? "mic.slash.fill" : "mic.fill")
.resizable()
.scaledToFit()
.frame(width: width * 0.2)
.foregroundColor(.white)
}
.frame(maxWidth: .infinity, maxHeight: .infinity) // Ensures the ZStack is centered
}
}
}
#Preview {
MicrophoneView(audioLevel: 1, isMuted: false)
}

View File

@@ -0,0 +1,31 @@
import SwiftUI
struct ToastModifier: ViewModifier {
var message: String?
var isShowing: Bool
func body(content: Content) -> some View {
ZStack {
content
if isShowing, let message = message {
VStack {
Text(message)
.padding()
.background(Color.black.opacity(0.7))
.foregroundColor(.white)
.cornerRadius(8)
.transition(.slide)
.padding(.top, 50)
Spacer()
}
.animation(.easeInOut(duration: 0.5), value: isShowing)
}
}
}
}
extension View {
func toast(message: String?, isShowing: Bool) -> some View {
self.modifier(ToastModifier(message: message, isShowing: isShowing))
}
}

View File

@@ -0,0 +1,93 @@
import SwiftUI
struct WaveformView: View {
var audioLevel: Float
var isBotReady: Bool
var voiceClientStatus: String
@State
private var audioLevels: [Float] = Array(repeating: 0, count: 5)
private let dotCount = 5
var body: some View {
GeometryReader { geometry in
VStack {
Spacer()
HStack {
Spacer()
ZStack {
// Outer gray border
Circle()
.stroke(Color.gray, lineWidth: 1)
.frame(width: geometry.size.width * 0.9, height: geometry.size.width * 0.9)
// Gray middle
Circle()
.fill(isBotReady ? Color.backgroundCircle : Color.backgroundCircleNotConnected)
.frame(width: geometry.size.width * 0.82, height: geometry.size.width * 0.82)
if isBotReady {
if audioLevel > 0 {
// Waveform bars inside the circle
HStack(spacing: 10) {
ForEach(0..<dotCount, id: \.self) { index in
Rectangle()
.fill(Color.white)
.frame(height: CGFloat(audioLevels[index]) * (geometry.size.height))
.cornerRadius(12)
.animation(.easeInOut(duration: 0.2), value: audioLevels[index])
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 5)
}
.frame(width: geometry.size.width * 0.5, height: geometry.size.width * 0.5)
.mask(Circle().frame(width: geometry.size.width * 0.82, height: geometry.size.width * 0.82))
} else {
// Dots inside the circle
HStack(spacing: 10) {
ForEach(0..<dotCount, id: \.self) { _ in
Circle()
.fill(Color.white)
.frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity)
}
.frame(width: geometry.size.width * 0.5, height: geometry.size.height * 0.5)
}
} else {
// Gray circle with loading icon when not connected
VStack {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .white))
.scaleEffect(2) // Adjust size of the loading spinner
.padding()
Text(voiceClientStatus)
.foregroundColor(.white)
.font(.headline)
}
}
}
Spacer()
}
Spacer()
}
}
.onChange(of: audioLevel) { oldLevel, newLevel in
// The audio level that we receive from the bot is usually too low
// so just increasing it so we can see a better graph but
// making sure that it is not higher than the maximum 1
var audioLevel = audioLevel + 0.4
if(audioLevel > 1) {
audioLevel = 1
}
// Update the array and shift values
audioLevels.removeFirst()
audioLevels.append(newLevel)
}
}
}
#Preview {
WaveformView(audioLevel: 0, isBotReady: false, voiceClientStatus: "idle")
}

View File

@@ -0,0 +1,27 @@
import SwiftUI
public extension Color {
static let backgroundCircle = Color(hex: "#374151")
static let backgroundCircleNotConnected = Color(hex: "#D1D5DB")
static let backgroundApp = Color(hex: "#F9FAFB")
static let buttonsBorder = Color(hex: "#E5E7EB")
static let micVolume = Color(hex: "#86EFAC")
static let timer = Color(hex: "#E5E7EB")
static let disabledMic = Color(hex: "#ee6b6e")
static let disabledVision = Color(hex: "#BBF7D0")
init(hex: String) {
let scanner = Scanner(string: hex)
_ = scanner.scanString("#")
var rgb: UInt64 = 0
scanner.scanHexInt64(&rgb)
let red = Double((rgb >> 16) & 0xFF) / 255.0
let green = Double((rgb >> 8) & 0xFF) / 255.0
let blue = Double(rgb & 0xFF) / 255.0
self.init(red: red, green: green, blue: blue)
}
}

View File

@@ -0,0 +1,21 @@
import Foundation
class SettingsManager {
private static let preferencesKey = "settingsPreference"
static func getSettings() -> SettingsPreference {
if let data = UserDefaults.standard.data(forKey: preferencesKey),
let settings = try? JSONDecoder().decode(SettingsPreference.self, from: data) {
return settings
} else {
// default values in case we don't have any settings
return SettingsPreference(enableMic: true, backendURL: "http://YOUR_IP:7860")
}
}
static func updateSettings(settings: SettingsPreference) {
if let data = try? JSONEncoder().encode(settings) {
UserDefaults.standard.set(data, forKey: preferencesKey)
}
}
}

View File

@@ -0,0 +1,8 @@
import Foundation
struct SettingsPreference: Codable {
var selectedMic: String?
var enableMic: Bool
var backendURL: String
}

View File

@@ -0,0 +1,87 @@
import SwiftUI
import RTVIClientIOS
struct SettingsView: View {
@EnvironmentObject private var model: CallContainerModel
@Binding var showingSettings: Bool
@State private var selectedMic: MediaDeviceId? = nil
@State private var isMicEnabled: Bool = true
@State private var backendURL: String = ""
var body: some View {
let microphones = self.model.rtviClientIOS?.getAllMics() ?? []
NavigationView {
Form {
Section(header: Text("Audio Settings")) {
List(microphones, id: \.self.id.id) { mic in
Button(action: {
self.selectMic(mic.id)
}) {
HStack {
Text(mic.name)
Spacer()
if mic.id == self.selectedMic {
Image(systemName: "checkmark")
}
}
}
}
}
Section(header: Text("Start options")) {
Toggle("Enable Microphone", isOn: $isMicEnabled)
}
Section(header: Text("Server")) {
TextField("Backend URL", text: $backendURL)
.keyboardType(.URL)
}
}
.navigationTitle("Settings")
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
self.saveSettings()
self.showingSettings = false
}
}
}
.onAppear {
self.loadSettings()
}
}
}
private func selectMic(_ mic: MediaDeviceId) {
self.selectedMic = mic
self.model.rtviClientIOS?.updateMic(micId: mic, completion: nil)
}
private func saveSettings() {
let newSettings = SettingsPreference(
selectedMic: selectedMic?.id,
enableMic: isMicEnabled,
backendURL: backendURL
)
SettingsManager.updateSettings(settings: newSettings)
}
private func loadSettings() {
let savedSettings = SettingsManager.getSettings()
if let selectedMic = savedSettings.selectedMic {
self.selectedMic = MediaDeviceId(id: selectedMic)
} else {
self.selectedMic = nil
}
self.isMicEnabled = savedSettings.enableMic
self.backendURL = savedSettings.backendURL
}
}
#Preview {
let mockModel = MockCallContainerModel()
let result = SettingsView(showingSettings: .constant(true)).environmentObject(mockModel as CallContainerModel)
mockModel.startAudioLevelSimulation()
return result
}