iOS demo for the p2p-webrtc video-transform example
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import SwiftUI
|
||||
import PipecatClientIOSSmallWebrtc
|
||||
|
||||
struct MeetingView: View {
|
||||
|
||||
@State private var showingSettings = false
|
||||
@EnvironmentObject private var model: CallContainerModel
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ZStack {
|
||||
SmallWebRTCVideoViewSwiftUI(videoTrack: self.model.botCamId, videoScaleMode: .fill)
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
|
||||
VStack {
|
||||
ChatView()
|
||||
.frame(maxHeight: .infinity)
|
||||
|
||||
HStack {
|
||||
MicrophoneView(audioLevel: 0, isMuted: !self.model.isMicEnabled)
|
||||
.frame(width: 100, height: 100)
|
||||
.onTapGesture {
|
||||
self.model.toggleMicInput()
|
||||
}
|
||||
CameraButtonView(trackId: self.model.localCamId, isMuted: !self.model.isCamEnabled)
|
||||
.frame(width: 120, height: 120)
|
||||
.onTapGesture {
|
||||
self.model.toggleCamInput()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
.foregroundColor(.black)
|
||||
.background(Color.white)
|
||||
.border(Color.buttonsBorder, width: 1)
|
||||
.cornerRadius(12)
|
||||
.padding([.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)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let mockModel = MockCallContainerModel()
|
||||
let result = MeetingView().environmentObject(mockModel as CallContainerModel)
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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") {
|
||||
Task {
|
||||
await 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)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import SwiftUI
|
||||
import PipecatClientIOS
|
||||
import PipecatClientIOSSmallWebrtc
|
||||
|
||||
struct CameraButtonView: View {
|
||||
var trackId: MediaTrackId?
|
||||
var isMuted: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geometry in
|
||||
let width = geometry.size.width
|
||||
let circleSize = width * 0.9
|
||||
let innerCircleSize = width * 0.82
|
||||
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(Color.gray, lineWidth: 1)
|
||||
.frame(width: circleSize)
|
||||
|
||||
if (!isMuted){
|
||||
SmallWebRTCVideoViewSwiftUI(videoTrack: trackId, videoScaleMode: .fill)
|
||||
.aspectRatio(1, contentMode: .fit)
|
||||
.clipShape(Circle())
|
||||
} else {
|
||||
Circle()
|
||||
.fill(Color.disabledVision)
|
||||
.frame(width: innerCircleSize)
|
||||
Image("vision")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(width: width * 0.3)
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity) // Ensures the ZStack is centered
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
CameraButtonView(trackId: nil, isMuted: true)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ChatView: View {
|
||||
@EnvironmentObject private var model: CallContainerModel
|
||||
@State private var timer = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
ScrollViewReader { scrollViewProxy in
|
||||
ScrollView {
|
||||
VStack(spacing: 10) {
|
||||
ForEach(self.model.messages) { message in
|
||||
MessageView(message: message)
|
||||
.frame(maxWidth: .infinity, alignment: messageAlignment(for: message.type))
|
||||
.padding(.horizontal)
|
||||
.id(message.id)
|
||||
}
|
||||
}
|
||||
.onChange(of: self.model.messages) { _, _ in
|
||||
scrollToLastMessage(scrollViewProxy)
|
||||
}
|
||||
}
|
||||
.onReceive(timer) { _ in
|
||||
scrollToLastMessage(scrollViewProxy)
|
||||
}
|
||||
.onAppear {
|
||||
scrollToLastMessage(scrollViewProxy)
|
||||
}
|
||||
}
|
||||
}
|
||||
.edgesIgnoringSafeArea(.bottom)
|
||||
}
|
||||
|
||||
private func messageAlignment(for type: MessageType) -> Alignment {
|
||||
switch type {
|
||||
case .bot: return .leading
|
||||
case .user: return .trailing
|
||||
case .system: return .center
|
||||
}
|
||||
}
|
||||
|
||||
private func scrollToLastMessage(_ scrollViewProxy: ScrollViewProxy) {
|
||||
if let lastMessageId = self.model.messages.last?.id {
|
||||
withAnimation {
|
||||
scrollViewProxy.scrollTo(lastMessageId, anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageView: View {
|
||||
@ObservedObject var message: LiveMessage
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if message.type == .bot {
|
||||
Image(systemName: "gearshape")
|
||||
.resizable()
|
||||
.frame(width: 24, height: 24)
|
||||
}
|
||||
|
||||
Text(message.content)
|
||||
.padding(message.type == .system ? 5 : 10)
|
||||
.foregroundColor(.white)
|
||||
.background(messageBackgroundColor(for: message.type))
|
||||
.cornerRadius(15)
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 15)
|
||||
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
.padding(messagePadding(for: message.type))
|
||||
}
|
||||
|
||||
private func messageBackgroundColor(for type: MessageType) -> Color {
|
||||
switch type {
|
||||
case .bot: return .black
|
||||
case .user: return .gray
|
||||
case .system: return .blue.opacity(0.6)
|
||||
}
|
||||
}
|
||||
|
||||
private func messagePadding(for type: MessageType) -> EdgeInsets {
|
||||
switch type {
|
||||
case .bot: return EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 40)
|
||||
case .user: return EdgeInsets(top: 0, leading: 40, bottom: 0, trailing: 0)
|
||||
case .system: return EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let mockModel = MockCallContainerModel()
|
||||
let result = ChatView().environmentObject(mockModel as CallContainerModel)
|
||||
return result
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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 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)
|
||||
}
|
||||
}
|
||||
@@ -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, enableCam: true, backendURL: "http://YOUR_IP:7860")
|
||||
}
|
||||
}
|
||||
|
||||
static func updateSettings(settings: SettingsPreference) {
|
||||
if let data = try? JSONEncoder().encode(settings) {
|
||||
UserDefaults.standard.set(data, forKey: preferencesKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
struct SettingsPreference: Codable {
|
||||
var selectedMic: String?
|
||||
var enableMic: Bool
|
||||
var enableCam: Bool
|
||||
var backendURL: String
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
|
||||
@EnvironmentObject private var model: CallContainerModel
|
||||
|
||||
@Binding var showingSettings: Bool
|
||||
|
||||
@State private var isMicEnabled: Bool = true
|
||||
@State private var isCamEnabled: Bool = true
|
||||
@State private var backendURL: String = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section {
|
||||
List(model.availableMics, id: \.self.id.id) { mic in
|
||||
Button(action: {
|
||||
model.selectMic(mic.id)
|
||||
}) {
|
||||
HStack {
|
||||
Text(mic.name)
|
||||
Spacer()
|
||||
if mic.id == model.selectedMic {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
VStack(alignment: .leading) {
|
||||
Text("Audio Settings")
|
||||
Text("(No selection = system default)")
|
||||
}
|
||||
}
|
||||
Section(header: Text("Start options")) {
|
||||
Toggle("Enable Microphone", isOn: $isMicEnabled)
|
||||
Toggle("Enable Cam", isOn: $isCamEnabled)
|
||||
}
|
||||
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 saveSettings() {
|
||||
let newSettings = SettingsPreference(
|
||||
selectedMic: model.selectedMic?.id,
|
||||
enableMic: isMicEnabled,
|
||||
enableCam: isCamEnabled,
|
||||
backendURL: backendURL
|
||||
)
|
||||
SettingsManager.updateSettings(settings: newSettings)
|
||||
}
|
||||
|
||||
private func loadSettings() {
|
||||
let savedSettings = SettingsManager.getSettings()
|
||||
self.isMicEnabled = savedSettings.enableMic
|
||||
self.isCamEnabled = savedSettings.enableCam
|
||||
self.backendURL = savedSettings.backendURL
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let mockModel = MockCallContainerModel()
|
||||
let result = SettingsView(showingSettings: .constant(true)).environmentObject(mockModel as CallContainerModel)
|
||||
return result
|
||||
}
|
||||
Reference in New Issue
Block a user