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,36 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:name=".RTVIApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/Theme.RTVIClient">
<activity
android:name=".MainActivity"
android:exported="true"
android:windowSoftInputMode="adjustResize"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize"
android:theme="@style/Theme.RTVIClient">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,257 @@
package ai.pipecat.simple_chatbot_client
import ai.pipecat.simple_chatbot_client.ui.InCallLayout
import ai.pipecat.simple_chatbot_client.ui.PermissionScreen
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import ai.pipecat.simple_chatbot_client.ui.theme.RTVIClientTheme
import ai.pipecat.simple_chatbot_client.ui.theme.TextStyles
import ai.pipecat.simple_chatbot_client.ui.theme.textFieldColors
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val voiceClientManager = VoiceClientManager(this)
setContent {
RTVIClientTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Box(
Modifier
.fillMaxSize()
.padding(innerPadding)
) {
PermissionScreen()
val vcState = voiceClientManager.state.value
if (vcState != null) {
InCallLayout(voiceClientManager)
} else {
ConnectSettings(voiceClientManager)
}
voiceClientManager.errors.firstOrNull()?.let { errorText ->
val dismiss: () -> Unit = { voiceClientManager.errors.removeAt(0) }
AlertDialog(
onDismissRequest = dismiss,
confirmButton = {
Button(onClick = dismiss) {
Text(
text = "OK",
fontSize = 14.sp,
fontWeight = FontWeight.W700,
color = Color.White,
style = TextStyles.base
)
}
},
containerColor = Color.White,
title = {
Text(
text = "Error",
fontSize = 22.sp,
fontWeight = FontWeight.W600,
color = Color.Black,
style = TextStyles.base
)
},
text = {
Text(
text = errorText.message,
fontSize = 16.sp,
fontWeight = FontWeight.W400,
color = Color.Black,
style = TextStyles.base
)
}
)
}
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ConnectSettings(
voiceClientManager: VoiceClientManager,
) {
val scrollState = rememberScrollState()
val start = {
val backendUrl = Preferences.backendUrl.value
voiceClientManager.start(baseUrl = backendUrl!!)
}
Box(
modifier = Modifier
.fillMaxSize()
.verticalScroll(scrollState)
.imePadding()
.padding(20.dp),
contentAlignment = Alignment.Center
) {
Box(
Modifier
.fillMaxWidth()
.shadow(2.dp, RoundedCornerShape(16.dp))
.clip(RoundedCornerShape(16.dp))
.background(Colors.mainSurfaceBackground)
) {
Column(
Modifier
.fillMaxWidth()
.padding(
vertical = 24.dp,
horizontal = 28.dp
)
) {
Spacer(modifier = Modifier.height(12.dp))
Text(
modifier = Modifier.align(Alignment.CenterHorizontally),
text = "Connect to an RTVI server",
fontSize = 22.sp,
fontWeight = FontWeight.W700,
style = TextStyles.base
)
Spacer(modifier = Modifier.height(36.dp))
Text(
text = "Backend URL",
fontSize = 16.sp,
fontWeight = FontWeight.W400,
style = TextStyles.base
)
Spacer(modifier = Modifier.height(12.dp))
TextField(
modifier = Modifier
.fillMaxWidth()
.border(1.dp, Colors.textFieldBorder, RoundedCornerShape(12.dp)),
value = Preferences.backendUrl.value ?: "",
onValueChange = { Preferences.backendUrl.value = it },
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Uri,
imeAction = ImeAction.Next
),
colors = textFieldColors(),
shape = RoundedCornerShape(12.dp)
)
Spacer(modifier = Modifier.height(36.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
ConnectDialogButton(
modifier = Modifier.weight(1f),
onClick = start,
text = "Connect",
foreground = Color.White,
background = Colors.buttonNormal,
border = Colors.buttonNormal
)
}
}
}
}
}
@Composable
private fun ConnectDialogButton(
onClick: () -> Unit,
text: String,
foreground: Color,
background: Color,
border: Color,
modifier: Modifier = Modifier,
@DrawableRes icon: Int? = null,
) {
val shape = RoundedCornerShape(8.dp)
Row(
modifier
.border(1.dp, border, shape)
.clip(shape)
.background(background)
.clickable(onClick = onClick)
.padding(vertical = 10.dp, horizontal = 24.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
if (icon != null) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(icon),
tint = foreground,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = text,
fontSize = 16.sp,
fontWeight = FontWeight.W500,
color = foreground
)
}
}

View File

@@ -0,0 +1,75 @@
package ai.pipecat.simple_chatbot_client
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.mutableStateOf
import kotlinx.serialization.KSerializer
import kotlinx.serialization.json.Json
private val JSON_INSTANCE = Json { ignoreUnknownKeys = true }
object Preferences {
private const val PREF_BACKEND_URL = "backend_url"
private lateinit var prefs: SharedPreferences
fun initAppStart(context: Context) {
prefs = context.applicationContext.getSharedPreferences("prefs", Context.MODE_PRIVATE)
listOf(backendUrl).forEach { it.init() }
}
private fun getString(key: String): String? = prefs.getString(key, null)
interface BasePref {
fun init()
}
class StringPref(private val key: String): BasePref {
private val cachedValue = mutableStateOf<String?>(null)
override fun init() {
cachedValue.value = getString(key)
prefs.registerOnSharedPreferenceChangeListener { _, changedKey ->
if (key == changedKey) {
cachedValue.value = getString(key)
}
}
}
var value: String?
get() = cachedValue.value
set(newValue) {
cachedValue.value = newValue
prefs.edit().putString(key, newValue).apply()
}
}
class JsonPref<E>(private val key: String, private var serializer: KSerializer<E>): BasePref {
private val cachedValue = mutableStateOf<E?>(null)
private fun lookupValue(): E? =
getString(key)?.let { JSON_INSTANCE.decodeFromString(serializer, it) }
override fun init() {
cachedValue.value = lookupValue()
prefs.registerOnSharedPreferenceChangeListener { _, changedKey ->
if (key == changedKey) {
cachedValue.value = lookupValue()
}
}
}
var value: E?
get() = cachedValue.value
set(newValue) {
cachedValue.value = newValue
prefs.edit()
.putString(key, newValue?.let { JSON_INSTANCE.encodeToString(serializer, it) })
.apply()
}
}
val backendUrl = StringPref(PREF_BACKEND_URL)
}

View File

@@ -0,0 +1,10 @@
package ai.pipecat.simple_chatbot_client
import android.app.Application
class RTVIApplication : Application() {
override fun onCreate() {
super.onCreate()
Preferences.initAppStart(this)
}
}

View File

@@ -0,0 +1,193 @@
package ai.pipecat.simple_chatbot_client
import ai.pipecat.client.RTVIClient
import ai.pipecat.client.RTVIClientOptions
import ai.pipecat.client.RTVIClientParams
import ai.pipecat.client.RTVIEventCallbacks
import ai.pipecat.client.daily.DailyTransport
import ai.pipecat.client.result.Future
import ai.pipecat.client.result.RTVIError
import ai.pipecat.client.result.Result
import ai.pipecat.client.types.ActionDescription
import ai.pipecat.client.types.Participant
import ai.pipecat.client.types.PipecatMetrics
import ai.pipecat.client.types.RTVIURLEndpoints
import ai.pipecat.client.types.ServiceConfig
import ai.pipecat.client.types.Tracks
import ai.pipecat.client.types.Transcript
import ai.pipecat.client.types.TransportState
import ai.pipecat.simple_chatbot_client.utils.Timestamp
import android.content.Context
import android.util.Log
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
@Immutable
data class Error(val message: String)
@Stable
class VoiceClientManager(private val context: Context) {
companion object {
private const val TAG = "VoiceClientManager"
}
private val client = mutableStateOf<RTVIClient?>(null)
val state = mutableStateOf<TransportState?>(null)
val errors = mutableStateListOf<Error>()
val actionDescriptions =
mutableStateOf<Result<List<ActionDescription>, RTVIError>?>(null)
val expiryTime = mutableStateOf<Timestamp?>(null)
val botReady = mutableStateOf(false)
val botIsTalking = mutableStateOf(false)
val userIsTalking = mutableStateOf(false)
val botAudioLevel = mutableFloatStateOf(0f)
val userAudioLevel = mutableFloatStateOf(0f)
val mic = mutableStateOf(false)
val camera = mutableStateOf(false)
val tracks = mutableStateOf<Tracks?>(null)
private fun <E> Future<E, RTVIError>.displayErrors() = withErrorCallback {
Log.e(TAG, "Future resolved with error: ${it.description}", it.exception)
errors.add(Error(it.description))
}
fun start(baseUrl: String) {
if (client.value != null) {
return
}
val options = RTVIClientOptions(
params = RTVIClientParams(
baseUrl = baseUrl,
endpoints = RTVIURLEndpoints(),
)
)
state.value = TransportState.Disconnected
val callbacks = object : RTVIEventCallbacks() {
override fun onTransportStateChanged(state: TransportState) {
this@VoiceClientManager.state.value = state
}
override fun onBackendError(message: String) {
"Error from backend: $message".let {
Log.e(TAG, it)
errors.add(Error(it))
}
}
override fun onBotReady(version: String, config: List<ServiceConfig>) {
Log.i(TAG, "Bot ready. Version $version, config: $config")
botReady.value = true
client.value?.describeActions()?.withCallback {
actionDescriptions.value = it
}
}
override fun onPipecatMetrics(data: PipecatMetrics) {
Log.i(TAG, "Pipecat metrics: $data")
}
override fun onUserTranscript(data: Transcript) {
Log.i(TAG, "User transcript: $data")
}
override fun onBotTranscript(text: String) {
Log.i(TAG, "Bot transcript: $text")
}
override fun onBotStartedSpeaking() {
Log.i(TAG, "Bot started speaking")
botIsTalking.value = true
}
override fun onBotStoppedSpeaking() {
Log.i(TAG, "Bot stopped speaking")
botIsTalking.value = false
}
override fun onUserStartedSpeaking() {
Log.i(TAG, "User started speaking")
userIsTalking.value = true
}
override fun onUserStoppedSpeaking() {
Log.i(TAG, "User stopped speaking")
userIsTalking.value = false
}
override fun onTracksUpdated(tracks: Tracks) {
this@VoiceClientManager.tracks.value = tracks
}
override fun onInputsUpdated(camera: Boolean, mic: Boolean) {
this@VoiceClientManager.camera.value = camera
this@VoiceClientManager.mic.value = mic
}
override fun onConnected() {
expiryTime.value = client.value?.expiry?.let(Timestamp::ofEpochSecs)
}
override fun onDisconnected() {
expiryTime.value = null
actionDescriptions.value = null
botIsTalking.value = false
userIsTalking.value = false
state.value = null
actionDescriptions.value = null
botReady.value = false
tracks.value = null
client.value?.release()
client.value = null
}
override fun onUserAudioLevel(level: Float) {
userAudioLevel.floatValue = level
}
override fun onRemoteAudioLevel(level: Float, participant: Participant) {
botAudioLevel.floatValue = level
}
}
val client = RTVIClient(DailyTransport.Factory(context), callbacks, options)
client.connect().displayErrors().withErrorCallback {
callbacks.onDisconnected()
}
this.client.value = client
}
fun enableCamera(enabled: Boolean) {
client.value?.enableCam(enabled)?.displayErrors()
}
fun enableMic(enabled: Boolean) {
client.value?.enableMic(enabled)?.displayErrors()
}
fun toggleCamera() = enableCamera(!camera.value)
fun toggleMic() = enableMic(!mic.value)
fun stop() {
client.value?.disconnect()?.displayErrors()
}
}

View File

@@ -0,0 +1,70 @@
package ai.pipecat.simple_chatbot_client.ui
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.semantics.clearAndSetSemantics
@Composable
fun ListeningAnimation(
modifier: Modifier,
active: Boolean,
level: Float,
color: Color,
) {
val infiniteTransition = rememberInfiniteTransition("listeningAnimation")
val loopState by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = Math.PI.toFloat() * 2f,
animationSpec = infiniteRepeatable(tween(durationMillis = 1000, easing = LinearEasing)),
label = "listeningAnimationLoopState"
)
val activeFraction by animateFloatAsState(
if (active) {
Math.pow(level.toDouble(), 0.3).toFloat()
} else {
0f
}
)
Canvas(modifier.clearAndSetSemantics { }) {
val strokeWidthPx = size.width / 12
val lineCount = 5
for (i in 1..lineCount) {
val sine = Math.sin(loopState + 0.9 * i)
val fraction = activeFraction * ((sine + 1) / 2).toFloat()
val x = (size.width / (lineCount + 1)) * i
val yMax = size.height * 0.25f
val yMin = size.height * 0.5f
val y = yMin + (yMax - yMin) * fraction
val yEnd = size.height - y
this.drawLine(
start = Offset(x, y),
end = Offset(x, yEnd),
color = color,
strokeWidth = strokeWidthPx,
cap = StrokeCap.Round
)
}
}
}

View File

@@ -0,0 +1,93 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.FloatState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun BotIndicator(
modifier: Modifier,
isReady: Boolean,
isTalking: State<Boolean>,
audioLevel: FloatState,
) {
Box(
modifier = modifier.padding(15.dp),
contentAlignment = Alignment.Center
) {
val color by animateColorAsState(if (isTalking.value || !isReady) {
Color.Black
} else {
Colors.botIndicatorBackground
})
Box(
Modifier
.aspectRatio(1f)
.fillMaxSize()
.shadow(20.dp, CircleShape)
.border(12.dp, Color.White, CircleShape)
.border(1.dp, Colors.lightGrey, CircleShape)
.clip(CircleShape)
.background(color)
.padding(50.dp),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = isReady
) { isReadyVal ->
if (isReadyVal) {
ListeningAnimation(
modifier = Modifier.fillMaxSize(),
active = isTalking.value,
level = audioLevel.floatValue,
color = Color.White
)
} else {
CircularProgressIndicator(
modifier = Modifier.size(180.dp),
color = Color.White,
strokeWidth = 12.dp,
strokeCap = StrokeCap.Round,
trackColor = color
)
}
}
}
}
}
@Composable
@Preview
fun PreviewBotIndicator() {
BotIndicator(
modifier = Modifier,
isReady = false,
isTalking = remember { mutableStateOf(true) },
audioLevel = remember { mutableFloatStateOf(1.0f) }
)
}

View File

@@ -0,0 +1,89 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.R
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import androidx.annotation.DrawableRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
private fun FooterButton(
modifier: Modifier,
onClick: () -> Unit,
@DrawableRes icon: Int,
text: String,
foreground: Color,
background: Color,
border: Color,
) {
val shape = RoundedCornerShape(12.dp)
Row(
modifier
.border(1.dp, border, shape)
.clip(shape)
.background(background)
.clickable(onClick = onClick)
.padding(vertical = 10.dp, horizontal = 18.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
Icon(
modifier = Modifier.size(24.dp),
painter = painterResource(icon),
tint = foreground,
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = text,
fontSize = 14.sp,
fontWeight = FontWeight.W600,
color = foreground
)
}
}
@Composable
fun ColumnScope.InCallFooter(
onClickEnd: () -> Unit,
) {
Row(Modifier
.fillMaxWidth(0.5f)
.padding(15.dp)
.align(Alignment.CenterHorizontally)
) {
FooterButton(
modifier = Modifier.weight(1f),
onClick = onClickEnd,
icon = R.drawable.phone_hangup,
text = "End",
foreground = Color.White,
background = Colors.endButton,
border = Colors.endButton
)
}
}

View File

@@ -0,0 +1,49 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.utils.Timestamp
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.constraintlayout.compose.ConstraintLayout
@Composable
fun InCallHeader(
expiryTime: Timestamp?
) {
ConstraintLayout(
Modifier
.fillMaxWidth()
.padding(vertical = 15.dp)
) {
val refTimer = createRef()
AnimatedContent(
modifier = Modifier.constrainAs(refTimer) {
top.linkTo(parent.top)
bottom.linkTo(parent.bottom)
end.linkTo(parent.end)
},
targetState = expiryTime,
transitionSpec = { fadeIn() togetherWith fadeOut() }
) { expiryTimeVal ->
if (expiryTimeVal != null) {
Timer(expiryTime = expiryTimeVal, modifier = Modifier)
}
}
}
}
@Composable
@Preview
fun PreviewInCallHeader() {
InCallHeader(
Timestamp.now() + java.time.Duration.ofMinutes(3)
)
}

View File

@@ -0,0 +1,70 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.VoiceClientManager
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun InCallLayout(voiceClientManager: VoiceClientManager) {
val localCam by remember { derivedStateOf { voiceClientManager.tracks.value?.local?.video } }
Column(Modifier.fillMaxSize()) {
InCallHeader(expiryTime = voiceClientManager.expiryTime.value)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically)
) {
BotIndicator(
modifier = Modifier,
isReady = voiceClientManager.botReady.value,
isTalking = voiceClientManager.botIsTalking,
audioLevel = voiceClientManager.botAudioLevel
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
UserMicButton(
onClick = voiceClientManager::toggleMic,
micEnabled = voiceClientManager.mic.value,
modifier = Modifier,
isTalking = voiceClientManager.userIsTalking,
audioLevel = voiceClientManager.userAudioLevel
)
UserCamButton(
onClick = voiceClientManager::toggleCamera,
camEnabled = voiceClientManager.camera.value,
camTrackId = localCam,
modifier = Modifier
)
}
}
}
InCallFooter(
onClickEnd = voiceClientManager::stop
)
}
}

View File

@@ -0,0 +1,99 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import ai.pipecat.simple_chatbot_client.ui.theme.TextStyles
import android.Manifest
import android.util.Log
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun PermissionScreen() {
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
val micPermission = rememberPermissionState(Manifest.permission.RECORD_AUDIO)
val requestPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { isGranted ->
Log.i("MainActivity", "Permissions granted: $isGranted")
}
if (!cameraPermission.status.isGranted || !micPermission.status.isGranted) {
Dialog(
onDismissRequest = {},
) {
val dialogShape = RoundedCornerShape(16.dp)
Column(
Modifier
.shadow(6.dp, dialogShape)
.border(2.dp, Colors.logoBorder, dialogShape)
.clip(dialogShape)
.background(Color.White)
.padding(28.dp)
) {
Text(
text = "Permissions",
fontSize = 24.sp,
fontWeight = FontWeight.W700,
style = TextStyles.base
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Please grant camera and mic permissions to continue",
fontSize = 18.sp,
fontWeight = FontWeight.W400,
style = TextStyles.base
)
Spacer(modifier = Modifier.height(36.dp))
Button(
modifier = Modifier.align(Alignment.End),
shape = RoundedCornerShape(12.dp),
onClick = {
requestPermissionLauncher.launch(
arrayOf(
Manifest.permission.CAMERA,
Manifest.permission.RECORD_AUDIO
)
)
}
) {
Text(
text = "Grant permissions",
fontSize = 16.sp,
fontWeight = FontWeight.W700,
style = TextStyles.base
)
}
}
}
}
}

View File

@@ -0,0 +1,72 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.R
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import ai.pipecat.simple_chatbot_client.utils.Timestamp
import ai.pipecat.simple_chatbot_client.utils.formatTimer
import ai.pipecat.simple_chatbot_client.utils.rtcStateSecs
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import java.time.Duration
@Composable
fun Timer(
expiryTime: Timestamp,
modifier: Modifier,
) {
val now by rtcStateSecs()
val shape = RoundedCornerShape(
topStart = 12.dp,
bottomStart = 12.dp,
)
Row(
modifier = modifier
.widthIn(min = 100.dp)
.clip(shape)
.background(Colors.lightGrey)
.padding(top = 12.dp, bottom = 12.dp, start = 12.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
painter = painterResource(id = R.drawable.timer_outline),
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = Colors.expiryTimerForeground
)
Spacer(Modifier.width(8.dp))
Text(
text = formatTimer(duration = expiryTime - now),
fontSize = 16.sp,
fontWeight = FontWeight.W600,
color = Colors.expiryTimerForeground
)
}
}
@Composable
@Preview
fun PreviewExpiryTimer() {
Timer(Timestamp.now() + Duration.ofMinutes(5), Modifier)
}

View File

@@ -0,0 +1,110 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.client.daily.VoiceClientVideoView
import ai.pipecat.client.types.MediaTrackId
import ai.pipecat.simple_chatbot_client.R
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
@Composable
fun UserCamButton(
onClick: () -> Unit,
camEnabled: Boolean,
camTrackId: MediaTrackId?,
modifier: Modifier,
) {
Box(
modifier = modifier.padding(15.dp).size(96.dp),
contentAlignment = Alignment.Center
) {
val color by animateColorAsState(
if (camEnabled) {
Colors.unmutedMicBackground
} else {
Colors.mutedMicBackground
}
)
Box(
Modifier
.fillMaxSize()
.shadow(3.dp, CircleShape)
.border(6.dp, Color.White, CircleShape)
.border(1.dp, Colors.lightGrey, CircleShape)
.clip(CircleShape)
.background(color)
.clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
if (camTrackId != null) {
AndroidView(
factory = { context ->
VoiceClientVideoView(context)
},
update = { view ->
view.voiceClientTrack = camTrackId
}
)
} else {
Icon(
modifier = Modifier.size(30.dp),
painter = painterResource(
if (camEnabled) {
R.drawable.video
} else {
R.drawable.video_off
}
),
tint = Color.White,
contentDescription = if (camEnabled) {
"Disable camera"
} else {
"Enable camera"
},
)
}
}
}
}
@Composable
@Preview
fun PreviewUserCamButton() {
UserCamButton(
onClick = {},
camTrackId = null,
camEnabled = true,
modifier = Modifier,
)
}
@Composable
@Preview
fun PreviewUserCamButtonMuted() {
UserCamButton(
onClick = {},
camTrackId = null,
camEnabled = false,
modifier = Modifier,
)
}

View File

@@ -0,0 +1,114 @@
package ai.pipecat.simple_chatbot_client.ui
import ai.pipecat.simple_chatbot_client.R
import ai.pipecat.simple_chatbot_client.ui.theme.Colors
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.FloatState
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun UserMicButton(
onClick: () -> Unit,
micEnabled: Boolean,
modifier: Modifier,
isTalking: State<Boolean>,
audioLevel: FloatState,
) {
Box(
modifier = modifier.padding(15.dp),
contentAlignment = Alignment.Center
) {
val borderThickness by animateDpAsState(
if (isTalking.value) {
(24.dp * Math.pow(audioLevel.floatValue.toDouble(), 0.3).toFloat()) + 3.dp
} else {
6.dp
}
)
val color by animateColorAsState(
if (!micEnabled) {
Colors.mutedMicBackground
} else if (isTalking.value) {
Color.Black
} else {
Colors.unmutedMicBackground
}
)
Box(
Modifier
.shadow(3.dp, CircleShape)
.border(borderThickness, Color.White, CircleShape)
.border(1.dp, Colors.lightGrey, CircleShape)
.clip(CircleShape)
.background(color)
.clickable(onClick = onClick)
.padding(36.dp),
contentAlignment = Alignment.Center,
) {
Icon(
modifier = Modifier.size(48.dp),
painter = painterResource(
if (micEnabled) {
R.drawable.microphone
} else {
R.drawable.microphone_off
}
),
tint = Color.White,
contentDescription = if (micEnabled) {
"Mute microphone"
} else {
"Unmute microphone"
},
)
}
}
}
@Composable
@Preview
fun PreviewUserMicButton() {
UserMicButton(
onClick = {},
micEnabled = true,
modifier = Modifier,
isTalking = remember { mutableStateOf(false) },
audioLevel = remember { mutableFloatStateOf(1.0f) }
)
}
@Composable
@Preview
fun PreviewUserMicButtonMuted() {
UserMicButton(
onClick = {},
micEnabled = false,
modifier = Modifier,
isTalking = remember { mutableStateOf(false) },
audioLevel = remember { mutableFloatStateOf(1.0f) }
)
}

View File

@@ -0,0 +1,22 @@
package ai.pipecat.simple_chatbot_client.ui.theme
import androidx.compose.ui.graphics.Color
object Colors {
val buttonNormal = Color(0xFF374151)
val buttonWarning = Color(0xFFE53935)
val buttonSection = Color(0xFFDFF1FF)
val activityBackground = Color(0xFFF9FAFB)
val mainSurfaceBackground = Color.White
val lightGrey = Color(0x7FE5E7EB)
val expiryTimerForeground = Color.Black
val logoBorder = Color(0xFFE2E8F0)
val endButton = Color(0xFF0F172A)
val textFieldBorder = Color(0xFFDFE6EF)
val botIndicatorBackground = Color(0xFF374151)
val mutedMicBackground = Color(0xFFF04A4A)
val unmutedMicBackground = Color(0xFF616978)
}

View File

@@ -0,0 +1,36 @@
package ai.pipecat.simple_chatbot_client.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
private val LightColorScheme = lightColorScheme(
primary = Colors.buttonNormal,
secondary = Colors.buttonWarning,
background = Colors.activityBackground,
surface = Colors.mainSurfaceBackground
)
@Composable
fun RTVIClientTheme(
content: @Composable () -> Unit
) {
val colorScheme = LightColorScheme
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@Composable
fun textFieldColors() = TextFieldDefaults.colors().copy(
unfocusedContainerColor = Colors.activityBackground,
focusedContainerColor = Colors.activityBackground,
focusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
)

View File

@@ -0,0 +1,40 @@
package ai.pipecat.simple_chatbot_client.ui.theme
import ai.pipecat.simple_chatbot_client.R
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
object TextStyles {
val base = TextStyle(fontFamily = FontFamily(Font(R.font.inter)))
}
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)

View File

@@ -0,0 +1,21 @@
package ai.pipecat.simple_chatbot_client.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
private val rtcFlowSecs = flow {
while(true) {
val now = Timestamp.now().toEpochMilli()
val rounded = ((now + 500) / 1000) * 1000
emit(Timestamp.ofEpochMilli(rounded))
val target = rounded + 1000
delay(target - now)
}
}
@Composable
fun rtcStateSecs() = rtcFlowSecs.collectAsState(initial = Timestamp.now())

View File

@@ -0,0 +1,64 @@
package ai.pipecat.simple_chatbot_client.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import java.time.Duration
import java.time.Instant
import java.time.format.DateTimeFormatter
import java.util.Date
// Wrapper for Compose stability
@Immutable
@JvmInline
value class Timestamp(
val value: Instant
) : Comparable<Timestamp> {
val isInPast: Boolean
get() = value < Instant.now()
val isInFuture: Boolean
get() = value > Instant.now()
fun toEpochMilli() = value.toEpochMilli()
operator fun plus(duration: Duration) = Timestamp(value + duration)
operator fun minus(duration: Duration) = Timestamp(value - duration)
operator fun minus(rhs: Timestamp) = Duration.between(rhs.value, value)
override operator fun compareTo(other: Timestamp) = value.compareTo(other.value)
fun toISOString(): String = DateTimeFormatter.ISO_INSTANT.format(value)
override fun toString() = toISOString()
companion object {
fun now() = Timestamp(Instant.now())
fun ofEpochMilli(value: Long) = Timestamp(Instant.ofEpochMilli(value))
fun ofEpochSecs(value: Long) = ofEpochMilli(value * 1000)
fun parse(value: CharSequence) = Timestamp(Instant.parse(value))
fun from(date: Date) = Timestamp(date.toInstant())
}
}
@Composable
fun formatTimer(duration: Duration): String {
if (duration.seconds < 0) {
return "0s"
}
val mins = duration.seconds / 60
val secs = duration.seconds % 60
return if (mins == 0L) {
"${secs}s"
} else {
"${mins}m ${secs}s"
}
}

View File

@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@@ -0,0 +1 @@
<!-- drawable/microphone.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M12,2A3,3 0 0,1 15,5V11A3,3 0 0,1 12,14A3,3 0 0,1 9,11V5A3,3 0 0,1 12,2M19,11C19,14.53 16.39,17.44 13,17.93V21H11V17.93C7.61,17.44 5,14.53 5,11H7A5,5 0 0,0 12,16A5,5 0 0,0 17,11H19Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/microphone_off.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M19,11C19,12.19 18.66,13.3 18.1,14.28L16.87,13.05C17.14,12.43 17.3,11.74 17.3,11H19M15,11.16L9,5.18V5A3,3 0 0,1 12,2A3,3 0 0,1 15,5V11L15,11.16M4.27,3L21,19.73L19.73,21L15.54,16.81C14.77,17.27 13.91,17.58 13,17.72V21H11V17.72C7.72,17.23 5,14.41 5,11H6.7C6.7,14 9.24,16.1 12,16.1C12.81,16.1 13.6,15.91 14.31,15.58L12.65,13.92L12,14A3,3 0 0,1 9,11V10.28L3,4.27L4.27,3Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/phone_hangup.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M12,9C10.4,9 8.85,9.25 7.4,9.72V12.82C7.4,13.22 7.17,13.56 6.84,13.72C5.86,14.21 4.97,14.84 4.17,15.57C4,15.75 3.75,15.86 3.5,15.86C3.2,15.86 2.95,15.74 2.77,15.56L0.29,13.08C0.11,12.9 0,12.65 0,12.38C0,12.1 0.11,11.85 0.29,11.67C3.34,8.77 7.46,7 12,7C16.54,7 20.66,8.77 23.71,11.67C23.89,11.85 24,12.1 24,12.38C24,12.65 23.89,12.9 23.71,13.08L21.23,15.56C21.05,15.74 20.8,15.86 20.5,15.86C20.25,15.86 20,15.75 19.82,15.57C19.03,14.84 18.14,14.21 17.16,13.72C16.83,13.56 16.6,13.22 16.6,12.82V9.72C15.15,9.25 13.6,9 12,9Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/timer_outline.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M12,20A7,7 0 0,1 5,13A7,7 0 0,1 12,6A7,7 0 0,1 19,13A7,7 0 0,1 12,20M19.03,7.39L20.45,5.97C20,5.46 19.55,5 19.04,4.56L17.62,6C16.07,4.74 14.12,4 12,4A9,9 0 0,0 3,13A9,9 0 0,0 12,22C17,22 21,17.97 21,13C21,10.88 20.26,8.93 19.03,7.39M11,14H13V8H11M15,1H9V3H15V1Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/video.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M17,10.5V7A1,1 0 0,0 16,6H4A1,1 0 0,0 3,7V17A1,1 0 0,0 4,18H16A1,1 0 0,0 17,17V13.5L21,17.5V6.5L17,10.5Z" /></vector>

View File

@@ -0,0 +1 @@
<!-- drawable/video_off.xml --><vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:width="24dp" android:viewportWidth="24" android:viewportHeight="24"><path android:fillColor="#000000" android:pathData="M3.27,2L2,3.27L4.73,6H4A1,1 0 0,0 3,7V17A1,1 0 0,0 4,18H16C16.2,18 16.39,17.92 16.54,17.82L19.73,21L21,19.73M21,6.5L17,10.5V7A1,1 0 0,0 16,6H9.82L21,17.18V6.5Z" /></vector>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">Pipecat Simple Chatbot Client</string>
</resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.RTVIClient" parent="android:Theme.Material.Light.NoActionBar" />
</resources>