diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 52150ae..e9a318d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -8,6 +8,9 @@ + + + Unit = {}, onDelete: ((String) -> Unit)? = null, onRegenerate: ((String) -> Unit)? = null, + onReadAloud: ((String) -> Unit)? = null, + isPlayingTts: Boolean = false, ) { val cs = MaterialTheme.colorScheme val isDark = isSystemInDarkTheme() @@ -461,6 +463,14 @@ fun MessageRow( copied = false } } + onReadAloud?.let { readAloud -> + MessageActionButton( + icon = KaizenIcons.Volume2, + tint = if (isPlayingTts) accent.primary else cs.onSurfaceVariant.copy(alpha = 0.35f), + ) { + haptics.tick(); readAloud(message.content) + } + } onRegenerate?.let { regen -> MessageActionButton(KaizenIcons.RefreshCw, cs.onSurfaceVariant.copy(alpha = 0.35f)) { haptics.tick(); regen(message.wireId) @@ -527,6 +537,8 @@ fun ChatInput( enabled: Boolean, isStreaming: Boolean = false, onStop: () -> Unit = {}, + isRecording: Boolean = false, + onMicClick: () -> Unit = {}, pendingFiles: List = emptyList(), onAddClick: () -> Unit = {}, onRemoveFile: (String) -> Unit = {}, @@ -584,10 +596,16 @@ fun ChatInput( } Spacer(Modifier.width(8.dp)) Box( - Modifier.size(38.dp).clip(KaizenShapes.circle).background(iconBg), + Modifier.size(38.dp).clip(KaizenShapes.circle) + .background(if (isRecording) Color(0xFFEF4444).copy(alpha = if (isDark) 0.85f else 0.80f) else iconBg) + .clickable(onClick = onMicClick), contentAlignment = Alignment.Center, ) { - Icon(KaizenIcons.Mic, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(19.dp)) + if (isRecording) { + Icon(KaizenIcons.Square, stringResource(R.string.chat_recording), tint = Color.White, modifier = Modifier.size(14.dp)) + } else { + Icon(KaizenIcons.Mic, stringResource(R.string.chat_attachment), tint = cs.onSurfaceVariant, modifier = Modifier.size(19.dp)) + } } Spacer(Modifier.weight(1f)) diff --git a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt index 3506414..b8eab89 100644 --- a/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt +++ b/app/src/main/java/dev/kaizen/app/chat/ChatScreen.kt @@ -82,10 +82,17 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.LifecycleResumeEffect +import android.Manifest +import android.content.pm.PackageManager +import android.media.MediaPlayer +import android.media.MediaRecorder +import android.widget.Toast +import androidx.core.content.ContextCompat import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.launch import java.io.ByteArrayOutputStream +import java.io.File // Screen enumeration for modular state-driven navigation (Zero Technical Debt) enum class AppScreen { Chat, Settings } @@ -111,6 +118,15 @@ fun ChatScreen( var chatModel by remember { mutableStateOf(null) } var streamJob by remember { mutableStateOf(null) } + // Audio recording state + var isRecording by remember { mutableStateOf(false) } + val recorderRef = remember { mutableStateOf(null) } + val audioFileRef = remember { mutableStateOf(null) } + + // TTS playback state + var playingTtsForId by remember { mutableStateOf(null) } + val ttsPlayerRef = remember { mutableStateOf(null) } + // Navigation State var currentScreen by remember { mutableStateOf(AppScreen.Chat) } @@ -191,6 +207,67 @@ fun ChatScreen( uploadPickedFile("photo_${System.currentTimeMillis()}.jpg", "image/jpeg", stream.toByteArray()) } + val micPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + if (granted) startRecording(context, recorderRef, audioFileRef) { isRecording = true } + else Toast.makeText(context, context.getString(R.string.chat_mic_permission), Toast.LENGTH_SHORT).show() + } + + fun toggleRecording() { + if (isRecording) { + stopRecording(recorderRef, audioFileRef) { name, mime, bytes -> + isRecording = false + uploadPickedFile(name, mime, bytes) + } + } else { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + startRecording(context, recorderRef, audioFileRef) { isRecording = true } + } else { + micPermission.launch(Manifest.permission.RECORD_AUDIO) + } + } + } + + fun readAloud(content: String, messageId: Long) { + val cfg = session.config ?: return + if (playingTtsForId == messageId) { + ttsPlayerRef.value?.release() + ttsPlayerRef.value = null + playingTtsForId = null + return + } + ttsPlayerRef.value?.release() + playingTtsForId = messageId + scope.launch { + val url = KaizenApi.generateSpeech(cfg.baseUrl, cfg.token, content) + if (url == null) { + playingTtsForId = null + Toast.makeText(context, context.getString(R.string.chat_tts_error), Toast.LENGTH_SHORT).show() + return@launch + } + try { + val player = MediaPlayer().apply { + setDataSource(url) + prepareAsync() + setOnPreparedListener { start() } + setOnCompletionListener { + release() + ttsPlayerRef.value = null + playingTtsForId = null + } + setOnErrorListener { _, _, _ -> + release() + ttsPlayerRef.value = null + playingTtsForId = null + true + } + } + ttsPlayerRef.value = player + } catch (_: Exception) { + playingTtsForId = null + } + } + } + LaunchedEffect(session.config?.baseUrl, session.config?.token) { val cfg = session.config ?: return@LaunchedEffect loadError = null @@ -659,6 +736,10 @@ fun ChatScreen( send(userContent) } } else null, + onReadAloud = if (message.role == Role.Assistant && !message.streaming && message.content.isNotBlank()) { content -> + readAloud(content, message.id) + } else null, + isPlayingTts = playingTtsForId == message.id, ) } } @@ -858,6 +939,8 @@ fun ChatScreen( enabled = !isStreaming, isStreaming = isStreaming, onStop = { streamJob?.cancel() }, + isRecording = isRecording, + onMicClick = { toggleRecording() }, pendingFiles = pendingFiles, onAddClick = { showAttachMenu = !showAttachMenu }, onRemoveFile = { id -> pendingFiles.removeAll { it.id == id } }, @@ -950,3 +1033,53 @@ private fun chatErrorText(e: Throwable, ctx: android.content.Context): String = e is ChatHttpException -> ctx.getString(R.string.error_server, e.code) else -> ctx.getString(R.string.error_no_connection) } + +@Suppress("DEPRECATION") +private fun startRecording( + context: android.content.Context, + recorderRef: androidx.compose.runtime.MutableState, + audioFileRef: androidx.compose.runtime.MutableState, + onStarted: () -> Unit, +) { + try { + val file = File(context.cacheDir, "voice_${System.currentTimeMillis()}.m4a") + val recorder = MediaRecorder(context).apply { + setAudioSource(MediaRecorder.AudioSource.MIC) + setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + setAudioSamplingRate(44100) + setAudioEncodingBitRate(128000) + setOutputFile(file.absolutePath) + prepare() + start() + } + recorderRef.value = recorder + audioFileRef.value = file + onStarted() + } catch (_: Exception) { + recorderRef.value = null + audioFileRef.value = null + } +} + +private fun stopRecording( + recorderRef: androidx.compose.runtime.MutableState, + audioFileRef: androidx.compose.runtime.MutableState, + onRecorded: (String, String, ByteArray) -> Unit, +) { + val recorder = recorderRef.value ?: return + val file = audioFileRef.value ?: return + try { + recorder.stop() + recorder.release() + } catch (_: Exception) { + recorder.release() + } + recorderRef.value = null + audioFileRef.value = null + if (file.exists() && file.length() > 0) { + val bytes = file.readBytes() + file.delete() + onRecorded("voice_${System.currentTimeMillis()}.m4a", "audio/mp4", bytes) + } +} diff --git a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt index 92255e9..587c3f0 100644 --- a/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt +++ b/app/src/main/java/dev/kaizen/app/net/KaizenApi.kt @@ -572,6 +572,35 @@ object KaizenApi { } } + @Serializable private data class SpeechRequest(val prompt: String, val kind: String = "speech", val voice: String? = null) + @Serializable private data class SpeechResponse(val url: String = "", val cost: Double? = null) + + /** POST /api/v1/audio-gen — generate speech from text via Gemini-TTS. Returns the audio URL or null. */ + suspend fun generateSpeech(baseUrl: String, token: String, text: String, voice: String? = null): String? = + withContext(Dispatchers.IO) { + val body = json.encodeToString(SpeechRequest( + prompt = text.take(2000), + voice = voice, + )).toRequestBody(jsonMedia) + val req = Request.Builder() + .url("$baseUrl/api/v1/audio-gen") + .post(body) + .header("Authorization", "Bearer $token") + .build() + try { + client.newCall(req).execute().use { resp -> + if (!resp.isSuccessful) { + Log.w(TAG, "generateSpeech: HTTP ${resp.code}") + return@use null + } + json.decodeFromString(resp.body!!.string()).url.takeIf { it.isNotBlank() } + } + } catch (e: Exception) { + Log.w(TAG, "generateSpeech: ${e.javaClass.simpleName}: ${e.message}") + null + } + } + suspend fun prewarm(baseUrl: String) = withContext(Dispatchers.IO) { try { val req = Request.Builder().url("$baseUrl/api/v1/meta").build() diff --git a/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt b/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt index bff6107..107ee9b 100644 --- a/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt +++ b/app/src/main/java/dev/kaizen/app/ui/icon/KaizenIcons.kt @@ -91,6 +91,9 @@ object KaizenIcons { val ArrowDown: ImageVector get() = Lucide.ArrowDown val ArrowUp: ImageVector get() = Lucide.ArrowUp + // ── Audio ────────────────────────────────────────────────────────────── + val Volume2: ImageVector get() = Lucide.Volume2 + // ── Custom (brand overrides) ──────────────────────────────────────────── // Add hand-drawn SVGs here as ImageVector literals when ready. // Example: diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index fbcf14d..7049add 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -19,6 +19,11 @@ Error Open menu Stop response + Recording … + Microphone access required + Read aloud + Read aloud failed + Read aloud not available (Vertex required) Take Photo Photo & Video Choose File diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cfb8fbf..5d682c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,6 +21,11 @@ Fehler Menü öffnen Antwort stoppen + Aufnahme läuft … + Mikrofonzugriff wird benötigt + Vorlesen + Vorlesen fehlgeschlagen + Vorlesen nicht verfügbar (Vertex benötigt) Foto aufnehmen Foto & Video Datei auswählen