Android UI: Settings tab, compact recording view, scrollable config

- new Settings tab holds Transcribe on, Server URL, Library URL, Model,
  Language, Live model and the Download/Load engine actions (all
  persisted); Record tab keeps agenda, record, tools and transcripts
- in-recording view hides all inputs: only level meter, agenda items,
  status, live transcript and the Stop button
- idle config is scrollable with status + Record button fixed at the
  bottom
- fix: the loaded engine and transcript lived in per-screen remember
  state and were silently lost on tab switches; now shared via AppState
  (engine is a heavyweight native object)
This commit is contained in:
2026-09-08 16:56:24 +02:00
parent 2cf785746e
commit 6a400e842f
4 changed files with 471 additions and 304 deletions
@@ -0,0 +1,23 @@
package com.meetrec.android
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import com.meetrec.core.whisper.WhisperEngine
/**
* App-wide state that must survive tab switches. Composables are disposed
* when switching tabs, so per-screen remember state would lose the loaded
* engine (a heavyweight native object) and the current transcript.
*/
object AppState {
/** The locally loaded whisper engine (null = none loaded). */
var engine: WhisperEngine? by mutableStateOf(null)
/** Last final transcript (manual transcribe or finished recording). */
var segments: List<WhisperEngine.Segment> by mutableStateOf(emptyList())
/** Manually picked WAV for the re-transcribe path. */
var wavName: String? by mutableStateOf(null)
var wavSamples: FloatArray? by mutableStateOf(null)
}
@@ -43,13 +43,20 @@ fun MeetRecApp() {
icon = { Text("") },
label = { Text("Library") },
)
NavigationBarItem(
selected = tab == 2,
onClick = { tab = 2 },
icon = { Text("") },
label = { Text("Settings") },
)
}
},
) { padding ->
Box(Modifier.fillMaxSize().padding(padding)) {
when (tab) {
0 -> RecordScreen()
else -> LibraryScreen()
1 -> LibraryScreen()
else -> SettingsScreen()
}
}
}
@@ -13,16 +13,16 @@ 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.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -43,83 +43,43 @@ import com.meetrec.core.recording.WavReader
import com.meetrec.core.whisper.RemoteWhisperEngine
import com.meetrec.core.whisper.Transcriber
import com.meetrec.core.whisper.WhisperEngine
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
/**
* Record a meeting with live transcript (cheap model, rolling window) and
* an automatic final pass with the selected model on stop — mirroring the
* desktop app's two-pass design.
* Record a meeting: agenda input, record/stop, live transcript and the
* automatic final pass. All engine/server configuration lives on the
* Settings tab (persisted); this screen reads it from SharedPreferences.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RecordScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val prefs = remember { context.getSharedPreferences("meetrec", Context.MODE_PRIVATE) }
var transcribeOn by remember {
mutableStateOf(prefs.getString("transcribe_on", "phone") ?: "phone")
}
var transcribeMenu by remember { mutableStateOf(false) }
var serverUrl by remember {
mutableStateOf(
prefs.getString("server_url", "http://100.103.83.12:8085")
?: "http://100.103.83.12:8085",
)
}
var storageUrl by remember {
mutableStateOf(
prefs.getString("storage_url", "http://100.103.83.12:8090")
?: "http://100.103.83.12:8090",
)
}
var agendaText by remember { mutableStateOf(prefs.getString("agenda", "") ?: "") }
LaunchedEffect(transcribeOn) {
prefs.edit().putString("transcribe_on", transcribeOn).apply()
}
LaunchedEffect(serverUrl) {
prefs.edit().putString("server_url", serverUrl).apply()
}
LaunchedEffect(storageUrl) {
prefs.edit().putString("storage_url", storageUrl).apply()
}
// configuration from the Settings tab — read at composition time; tabs
// are disposed on switch, so returning here always sees fresh values
val transcribeOn = prefs.getString("transcribe_on", "phone") ?: "phone"
val serverUrl = prefs.getString("server_url", "") ?: ""
val storageUrl = prefs.getString("storage_url", "") ?: ""
val language = prefs.getString("language", "auto") ?: "auto"
val liveModel = prefs.getString("live_model", "tiny") ?: "tiny"
var agendaText by remember { mutableStateOf(prefs.getString("agenda", "") ?: "") }
var status by remember { mutableStateOf("Ready.") }
var busy by remember { mutableStateOf(false) }
LaunchedEffect(agendaText) {
prefs.edit().putString("agenda", agendaText).apply()
}
var model by remember { mutableStateOf("tiny") }
var modelMenu by remember { mutableStateOf(false) }
var language by remember { mutableStateOf("auto") }
var langMenu by remember { mutableStateOf(false) }
var liveModel by remember { mutableStateOf("tiny") }
var liveMenu by remember { mutableStateOf(false) }
var status by remember { mutableStateOf("Select a model, download and load it.") }
var busy by remember { mutableStateOf(false) }
var engine by remember { mutableStateOf<WhisperEngine?>(null) }
var wavName by remember { mutableStateOf<String?>(null) }
var wavSamples by remember { mutableStateOf<FloatArray?>(null) }
var segments by remember { mutableStateOf<List<WhisperEngine.Segment>>(emptyList()) }
val modelsDir = remember { File(context.filesDir, "models") }
val modelFile = ModelDownloader.modelFile(model, modelsDir)
val recState by RecorderService.state.collectAsState()
val live by RecorderService.liveLines.collectAsState()
val upload by RecorderService.uploadState.collectAsState()
LaunchedEffect(upload) {
when (val u = upload) {
is UploadState.Uploading -> status = "Uploading recording to library…"
is UploadState.Done -> status = "Uploaded to library (${u.id})"
is UploadState.Error -> status = "Upload failed: ${u.message}"
UploadState.None -> {}
}
}
fun <T> runBusy(block: suspend () -> T) {
if (busy) return
busy = true
@@ -138,18 +98,18 @@ fun RecordScreen() {
val samples = withContext(Dispatchers.IO) {
file.inputStream().use { WavReader.read(it) }
}
wavName = file.name
wavSamples = samples
AppState.wavName = file.name
AppState.wavSamples = samples
status = "${file.name} loaded — press Transcribe"
}
// Final result: show the transcript and note the saved files.
LaunchedEffect(recState) {
val fin = recState as? RecordingState.Finished ?: return@LaunchedEffect
segments = emptyList()
wavSamples = null
AppState.segments = emptyList()
AppState.wavSamples = null
if (fin.segments.isNotEmpty()) {
segments = fin.segments
AppState.segments = fin.segments
status = if (fin.error != null) {
"Final pass failed: ${fin.error}"
} else {
@@ -164,6 +124,16 @@ fun RecordScreen() {
}
}
// upload progress (recording library)
LaunchedEffect(upload) {
when (val u = upload) {
is UploadState.Uploading -> status = "Uploading recording to library…"
is UploadState.Done -> status = "Uploaded to library (${u.id})"
is UploadState.Error -> status = "Upload failed: ${u.message}"
UploadState.None -> {}
}
}
fun startRecording() = context.startForegroundService(
Intent(context, RecorderService::class.java)
.setAction(RecorderService.ACTION_START),
@@ -198,7 +168,7 @@ fun RecordScreen() {
}
if (missing.isEmpty()) {
RecorderService.session = SessionConfig(
finalEngine = if (transcribeOn == "phone") engine else null,
finalEngine = if (transcribeOn == "phone") AppState.engine else null,
serverUrl = if (transcribeOn == "server") {
serverUrl.trim().ifBlank { null }
} else {
@@ -215,8 +185,8 @@ fun RecordScreen() {
)
if (transcribeOn == "server") {
status = "Recording — live and final pass on ${serverUrl.trim()}"
} else if (engine == null) {
status = "Recording without transcription — load an engine first " +
} else if (AppState.engine == null) {
status = "Recording without transcription — load an engine in Settings " +
"for the automatic final pass"
}
startRecording()
@@ -237,9 +207,9 @@ fun RecordScreen() {
val samples = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)!!.use { WavReader.read(it) }
}
wavName = name
wavSamples = samples
segments = emptyList()
AppState.wavName = name
AppState.wavSamples = samples
AppState.segments = emptyList()
status = "$name: ${samples.size / 16000} s @ 16 kHz"
}
}
@@ -249,11 +219,12 @@ fun RecordScreen() {
if (recState is RecordingState.Recording) {
live.map { it.startMs to it.text }
} else {
segments.map { it.startMs to it.text }
AppState.segments.map { it.startMs to it.text }
}
val recording = recState as? RecordingState.Recording
val finalizing = recState is RecordingState.Finalizing
val agendaItems = agendaText.lines().map { it.trim() }.filter { it.isNotBlank() }
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
@@ -261,169 +232,134 @@ fun RecordScreen() {
) {
Text("MeetRec", style = MaterialTheme.typography.headlineSmall)
// transcription target: the phone's local engines or a remote server
ExposedDropdownMenuBox(
expanded = transcribeMenu,
onExpandedChange = { transcribeMenu = it },
modifier = Modifier.fillMaxWidth(0.6f),
) {
OutlinedTextField(
value = transcribeOn,
onValueChange = {},
readOnly = true,
label = { Text("Transcribe on") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(transcribeMenu) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = transcribeMenu,
onDismissRequest = { transcribeMenu = false },
if (recording != null || finalizing) {
// ---- in-recording view: only the essentials ----
if (recording != null) {
LinearProgressIndicator(
progress = { recording.level.coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
)
}
if (agendaItems.isNotEmpty()) {
Text("Agenda", style = MaterialTheme.typography.titleSmall)
agendaItems.forEach { item -> Text("$item") }
}
Text(status, style = MaterialTheme.typography.bodySmall)
if (finalizing) CircularProgressIndicator()
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) {
items(transcript) { (startMs, text) ->
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(fmtMs(startMs), style = MaterialTheme.typography.labelSmall)
Text(text, style = MaterialTheme.typography.bodyMedium)
}
}
}
} else {
// ---- idle view: agenda + tools, scrollable ----
Column(
modifier = Modifier
.fillMaxWidth()
.weight(1f)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
listOf("phone", "server").forEach { choice ->
DropdownMenuItem(
text = { Text(choice) },
onClick = { transcribeOn = choice; transcribeMenu = false },
// agenda: one item per line, checked against the transcript after upload
OutlinedTextField(
value = agendaText,
onValueChange = { agendaText = it },
label = { Text("Agenda (one item per line)") },
placeholder = { Text("Budget\nZeitplan\n") },
minLines = 1,
maxLines = 4,
modifier = Modifier.fillMaxWidth(),
)
// manual transcribe path (or re-transcribe of picked files)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(
onClick = { pickWav.launch(arrayOf("audio/*", "application/octet-stream")) },
enabled = !busy,
) { Text(if (AppState.wavName == null) "Pick WAV file" else AppState.wavName ?: "WAV") }
Button(
onClick = {
val samples = AppState.wavSamples
if (samples != null &&
(AppState.engine != null || transcribeOn == "server")
) {
runBusy {
val t: Transcriber = if (transcribeOn == "server") {
RemoteWhisperEngine(serverUrl.trim())
} else {
AppState.engine!!
}
val start = System.currentTimeMillis()
val segs = withContext(Dispatchers.Default) {
t.transcribe(
samples,
WhisperEngine.DEFAULT_THREADS,
beamSize = 5,
language = language.takeIf { it != "auto" },
)
}
AppState.segments = segs
val secs = (System.currentTimeMillis() - start) / 1000.0
status = "${segs.size} segments in $secs s " +
"(audio ${samples.size / 16000} s)"
}
}
},
enabled = !busy && AppState.wavSamples != null &&
(AppState.engine != null || transcribeOn == "server"),
) { Text("Transcribe") }
}
if (transcript.isNotEmpty()) {
OutlinedButton(onClick = {
val text = transcript.joinToString("\n") {
"[${fmtMs(it.first)}] ${it.second}"
}
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_TEXT, text),
"Share transcript",
),
)
}) { Text("Share transcript") }
Text(
"Transcript (${transcript.size})",
style = MaterialTheme.typography.titleSmall,
)
}
}
}
if (transcribeOn == "server") {
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server URL") },
placeholder = { Text("http://100.103.83.12:8085") },
modifier = Modifier.fillMaxWidth(),
)
}
// recording library (leave empty to disable upload)
OutlinedTextField(
value = storageUrl,
onValueChange = { storageUrl = it },
label = { Text("Library URL") },
placeholder = { Text("http://100.103.83.12:8090") },
modifier = Modifier.fillMaxWidth(),
)
// agenda: one item per line, checked against the transcript after upload
OutlinedTextField(
value = agendaText,
onValueChange = { agendaText = it },
label = { Text("Agenda (one item per line)") },
placeholder = { Text("Budget\nZeitplan\n") },
minLines = 1,
maxLines = 4,
modifier = Modifier.fillMaxWidth(),
)
// model + language
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ExposedDropdownMenuBox(
expanded = modelMenu,
onExpandedChange = { modelMenu = it },
modifier = Modifier.weight(1f),
) {
OutlinedTextField(
value = model,
onValueChange = {},
readOnly = true,
enabled = transcribeOn == "phone",
label = { Text(if (transcribeOn == "server") "Model (n/a)" else "Model") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(modelMenu) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(expanded = modelMenu, onDismissRequest = { modelMenu = false }) {
ModelDownloader.MODELS.keys.forEach { name ->
DropdownMenuItem(
text = { Text(name) },
onClick = { model = name; modelMenu = false },
)
}
}
}
ExposedDropdownMenuBox(
expanded = langMenu,
onExpandedChange = { langMenu = it },
modifier = Modifier.weight(1f),
) {
OutlinedTextField(
value = language,
onValueChange = {},
readOnly = true,
label = { Text("Language") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(langMenu) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(expanded = langMenu, onDismissRequest = { langMenu = false }) {
LANGUAGES.forEach { code ->
DropdownMenuItem(
text = { Text(code) },
onClick = { language = code; langMenu = false },
)
}
}
}
}
// live model (local engine only; the server owns its own models)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ExposedDropdownMenuBox(
expanded = liveMenu,
onExpandedChange = { liveMenu = it },
modifier = Modifier.weight(1f),
) {
OutlinedTextField(
value = liveModel,
onValueChange = {},
readOnly = true,
enabled = transcribeOn == "phone",
label = {
Text(if (transcribeOn == "server") "Live model (n/a)" else "Live model")
},
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(liveMenu) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(expanded = liveMenu, onDismissRequest = { liveMenu = false }) {
LIVE_MODELS.forEach { name ->
DropdownMenuItem(
text = { Text(name) },
onClick = { liveModel = name; liveMenu = false },
)
}
}
}
}
// model actions
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
runBusy {
ModelDownloader.download(model, modelsDir) { done, total ->
status = if (total > 0) "Downloading $model: $done / $total bytes"
else "Downloading $model: $done bytes"
LazyColumn(
modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp),
) {
items(transcript) { (startMs, text) ->
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(
fmtMs(startMs),
style = MaterialTheme.typography.labelSmall,
)
Text(text, style = MaterialTheme.typography.bodyMedium)
}
}
status = "Model $model ready at ${modelFile.name}"
}
},
enabled = !busy && !modelFile.isFile && transcribeOn == "phone",
) { Text("Download") }
Button(
onClick = {
runBusy {
engine?.close()
withContext(Dispatchers.Default) {
engine = WhisperEngine.load(modelFile.absolutePath)
}
status = "$model loaded (${WhisperEngine.DEFAULT_THREADS} threads)"
}
},
enabled = !busy && modelFile.isFile && transcribeOn == "phone",
) { Text("Load engine") }
}
} // scrollable column
} // idle branch
// status stays visible above the record/stop button
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
) {
Text(status, style = MaterialTheme.typography.bodySmall)
if (busy || finalizing) CircularProgressIndicator()
}
// recording
// record / stop button, always at the bottom
Button(
onClick = { toggleRecording() },
enabled = !finalizing && (!busy || recording != null),
@@ -444,84 +380,9 @@ fun RecordScreen() {
},
)
}
if (recording != null) {
LinearProgressIndicator(
progress = { recording.level.coerceIn(0f, 1f) },
modifier = Modifier.fillMaxWidth(),
)
}
// manual transcribe path (or re-transcribe of picked files)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(
onClick = { pickWav.launch(arrayOf("audio/*", "application/octet-stream")) },
enabled = !busy,
) { Text(if (wavName == null) "Pick WAV file" else wavName ?: "WAV") }
Button(
onClick = {
val samples = wavSamples
if (samples != null && (engine != null || transcribeOn == "server")) {
runBusy {
val t: Transcriber = if (transcribeOn == "server") {
RemoteWhisperEngine(serverUrl.trim())
} else {
engine!!
}
val start = System.currentTimeMillis()
val segs = withContext(Dispatchers.Default) {
t.transcribe(
samples,
WhisperEngine.DEFAULT_THREADS,
beamSize = 5,
language = language.takeIf { it != "auto" },
)
}
segments = segs
val secs = (System.currentTimeMillis() - start) / 1000.0
status = "${segs.size} segments in $secs s " +
"(audio ${samples.size / 16000} s)"
}
}
},
enabled = !busy && wavSamples != null &&
(engine != null || transcribeOn == "server"),
) { Text("Transcribe") }
}
if (transcript.isNotEmpty()) {
OutlinedButton(onClick = {
val text = transcript.joinToString("\n") { "[${fmtMs(it.first)}] ${it.second}" }
context.startActivity(
Intent.createChooser(
Intent(Intent.ACTION_SEND)
.setType("text/plain")
.putExtra(Intent.EXTRA_TEXT, text),
"Share transcript",
),
)
}) { Text("Share transcript") }
}
Text(status, style = MaterialTheme.typography.bodySmall)
if (busy || finalizing) CircularProgressIndicator()
LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) {
items(transcript) { (startMs, text) ->
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(
fmtMs(startMs),
style = MaterialTheme.typography.labelSmall,
)
Text(text, style = MaterialTheme.typography.bodyMedium)
}
}
}
}
}
private val LANGUAGES = listOf("auto", "en", "de")
private val LIVE_MODELS = listOf("off", "tiny", "base")
internal fun fmtMs(ms: Long): String {
val total = ms / 1000
val h = total / 3600
@@ -0,0 +1,276 @@
package com.meetrec.android
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
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.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import com.meetrec.core.whisper.WhisperEngine
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
private val LANGUAGES = listOf("auto", "en", "de")
private val LIVE_MODELS = listOf("off", "tiny", "base")
/**
* Configuration: transcription target, server/library URLs, model,
* language, live model — plus the Download/Load actions for the local
* engine. All values persist in SharedPreferences.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen() {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val prefs = remember { context.getSharedPreferences("meetrec", Context.MODE_PRIVATE) }
var transcribeOn by remember {
mutableStateOf(prefs.getString("transcribe_on", "phone") ?: "phone")
}
var transcribeMenu by remember { mutableStateOf(false) }
var serverUrl by remember {
mutableStateOf(
prefs.getString("server_url", "http://100.103.83.12:8085")
?: "http://100.103.83.12:8085",
)
}
var storageUrl by remember {
mutableStateOf(
prefs.getString("storage_url", "http://100.103.83.12:8090")
?: "http://100.103.83.12:8090",
)
}
var model by remember { mutableStateOf(prefs.getString("model", "tiny") ?: "tiny") }
var modelMenu by remember { mutableStateOf(false) }
var language by remember { mutableStateOf(prefs.getString("language", "auto") ?: "auto") }
var langMenu by remember { mutableStateOf(false) }
var liveModel by remember { mutableStateOf(prefs.getString("live_model", "tiny") ?: "tiny") }
var liveMenu by remember { mutableStateOf(false) }
var status by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
val modelsDir = remember { File(context.filesDir, "models") }
val modelFile = ModelDownloader.modelFile(model, modelsDir)
LaunchedEffect(transcribeOn) { prefs.edit().putString("transcribe_on", transcribeOn).apply() }
LaunchedEffect(serverUrl) { prefs.edit().putString("server_url", serverUrl).apply() }
LaunchedEffect(storageUrl) { prefs.edit().putString("storage_url", storageUrl).apply() }
LaunchedEffect(model) { prefs.edit().putString("model", model).apply() }
LaunchedEffect(language) { prefs.edit().putString("language", language).apply() }
LaunchedEffect(liveModel) { prefs.edit().putString("live_model", liveModel).apply() }
fun <T> runBusy(block: suspend () -> T) {
if (busy) return
busy = true
scope.launch {
try {
block()
} catch (e: Exception) {
status = "Failed: ${e.message}"
} finally {
busy = false
}
}
}
Column(
modifier = Modifier.fillMaxSize().padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text("Settings", style = MaterialTheme.typography.headlineSmall)
ExposedDropdownMenuBox(
expanded = transcribeMenu,
onExpandedChange = { transcribeMenu = it },
modifier = Modifier.fillMaxWidth(0.6f),
) {
OutlinedTextField(
value = transcribeOn,
onValueChange = {},
readOnly = true,
label = { Text("Transcribe on") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(transcribeMenu) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = transcribeMenu,
onDismissRequest = { transcribeMenu = false },
) {
listOf("phone", "server").forEach { choice ->
DropdownMenuItem(
text = { Text(choice) },
onClick = { transcribeOn = choice; transcribeMenu = false },
)
}
}
}
if (transcribeOn == "server") {
OutlinedTextField(
value = serverUrl,
onValueChange = { serverUrl = it },
label = { Text("Server URL") },
placeholder = { Text("http://100.103.83.12:8085") },
modifier = Modifier.fillMaxWidth(),
)
}
OutlinedTextField(
value = storageUrl,
onValueChange = { storageUrl = it },
label = { Text("Library URL") },
placeholder = { Text("http://100.103.83.12:8090") },
modifier = Modifier.fillMaxWidth(),
)
// model + download/load (local engine)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
ExposedDropdownMenuBox(
expanded = modelMenu,
onExpandedChange = { modelMenu = it },
modifier = Modifier.weight(1f),
) {
OutlinedTextField(
value = model,
onValueChange = {},
readOnly = true,
enabled = transcribeOn == "phone",
label = { Text(if (transcribeOn == "server") "Model (n/a)" else "Model") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(modelMenu) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = modelMenu,
onDismissRequest = { modelMenu = false },
) {
ModelDownloader.MODELS.keys.forEach { name ->
DropdownMenuItem(
text = { Text(name) },
onClick = { model = name; modelMenu = false },
)
}
}
}
ExposedDropdownMenuBox(
expanded = langMenu,
onExpandedChange = { langMenu = it },
modifier = Modifier.weight(1f),
) {
OutlinedTextField(
value = language,
onValueChange = {},
readOnly = true,
label = { Text("Language") },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(langMenu) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = langMenu,
onDismissRequest = { langMenu = false },
) {
LANGUAGES.forEach { code ->
DropdownMenuItem(
text = { Text(code) },
onClick = { language = code; langMenu = false },
)
}
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(
onClick = {
runBusy {
ModelDownloader.download(model, modelsDir) { done, total ->
status = if (total > 0) {
"Downloading $model: $done / $total bytes"
} else {
"Downloading $model: $done bytes"
}
}
status = "Model $model ready at ${modelFile.name}"
}
},
enabled = !busy && !modelFile.isFile && transcribeOn == "phone",
) { Text("Download") }
Button(
onClick = {
runBusy {
AppState.engine?.close()
withContext(Dispatchers.Default) {
AppState.engine = WhisperEngine.load(modelFile.absolutePath)
}
status = "$model loaded (${WhisperEngine.DEFAULT_THREADS} threads)"
}
},
enabled = !busy && modelFile.isFile && transcribeOn == "phone",
) { Text("Load engine") }
}
ExposedDropdownMenuBox(
expanded = liveMenu,
onExpandedChange = { liveMenu = it },
modifier = Modifier.fillMaxWidth(0.6f),
) {
OutlinedTextField(
value = liveModel,
onValueChange = {},
readOnly = true,
enabled = transcribeOn == "phone",
label = {
Text(if (transcribeOn == "server") "Live model (n/a)" else "Live model")
},
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(liveMenu) },
modifier = Modifier.menuAnchor().fillMaxWidth(),
)
ExposedDropdownMenu(
expanded = liveMenu,
onDismissRequest = { liveMenu = false },
) {
LIVE_MODELS.forEach { name ->
DropdownMenuItem(
text = { Text(name) },
onClick = { liveModel = name; liveMenu = false },
)
}
}
}
Text(status, style = MaterialTheme.typography.bodySmall)
if (busy) CircularProgressIndicator()
Text(
"Transcribe on: phone uses the local engine (download + load), " +
"server sends both passes to the whisper.cpp server. Recordings " +
"upload to the library when the Library URL is set.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}