Android M3b: Library tab — browse, play, share, delete recordings
- new bottom navigation: Record | Library; existing screen moved to RecordScreen.kt, new MainActivity holds the tab scaffold - LibraryScreen lists recordings 'on this phone' (local files, showing which have transcripts) and 'on the server' (meetrec-server index with date, duration, language, device), with a refresh button - detail view: timestamped transcript from meeting.json (txt fallback), streaming audio playback via MediaPlayer (server WAV or local file), share as timestamped text, delete with confirmation (server API or local files) - StorageClient gains list/fetchFile/delete; verified against the live server on-device (list, detail, playback of the recovered 68-min meeting)
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
package com.meetrec.android
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.MediaPlayer
|
||||
import androidx.compose.foundation.clickable
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
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 java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
|
||||
/** One browsable recording: on the server, or only on the phone. */
|
||||
sealed interface LibraryItem {
|
||||
data class Server(val meta: StorageClient.RecordingMeta) : LibraryItem
|
||||
|
||||
data class Local(val wav: File, val hasTranscript: Boolean) : LibraryItem
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun LibraryScreen() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var localItems by remember { mutableStateOf<List<LibraryItem.Local>>(emptyList()) }
|
||||
var serverItems by remember { mutableStateOf<List<LibraryItem.Server>?>(null) }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var selected by remember { mutableStateOf<LibraryItem?>(null) }
|
||||
var refresh by remember { mutableStateOf(0) }
|
||||
|
||||
val storageUrl = remember {
|
||||
context.getSharedPreferences("meetrec", Context.MODE_PRIVATE)
|
||||
.getString("storage_url", "") ?: ""
|
||||
}
|
||||
|
||||
LaunchedEffect(refresh) {
|
||||
error = null
|
||||
localItems = withContext(Dispatchers.IO) { scanLocal(context) }
|
||||
if (storageUrl.isNotBlank()) {
|
||||
try {
|
||||
serverItems = withContext(Dispatchers.IO) {
|
||||
StorageClient.list(storageUrl).map { LibraryItem.Server(it) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
error = e.message
|
||||
serverItems = emptyList()
|
||||
}
|
||||
} else {
|
||||
serverItems = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
val item = selected
|
||||
if (item != null) {
|
||||
LibraryDetail(
|
||||
item = item,
|
||||
storageUrl = storageUrl,
|
||||
onBack = { selected = null },
|
||||
onDeleted = { selected = null; refresh++ },
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text("Library", style = MaterialTheme.typography.headlineSmall)
|
||||
androidx.compose.foundation.layout.Spacer(Modifier.weight(1f))
|
||||
OutlinedButton(onClick = { refresh++ }) { Text("Refresh") }
|
||||
}
|
||||
|
||||
error?.let {
|
||||
Text("Server unreachable: $it", style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
if (localItems.isNotEmpty()) {
|
||||
item {
|
||||
Text("On this phone", style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.padding(top = 8.dp))
|
||||
}
|
||||
items(localItems, key = { it.wav.name }) { li ->
|
||||
RecordingRow(
|
||||
title = stampToText(li.wav.nameWithoutExtension.removePrefix("meeting-")),
|
||||
subtitle = (if (li.hasTranscript) "" else "no transcript — ")
|
||||
+ "local file",
|
||||
onClick = { selected = li },
|
||||
)
|
||||
}
|
||||
item { HorizontalDivider(Modifier.padding(vertical = 8.dp)) }
|
||||
}
|
||||
item {
|
||||
Text("On the server", style = MaterialTheme.typography.titleSmall)
|
||||
}
|
||||
val servers = serverItems
|
||||
if (servers == null) {
|
||||
item {
|
||||
Row(Modifier.padding(8.dp)) { CircularProgressIndicator() }
|
||||
}
|
||||
} else if (servers.isEmpty()) {
|
||||
item {
|
||||
Text("No recordings yet — record and stop on the Record tab.",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
} else {
|
||||
items(servers, key = { it.meta.id }) { si ->
|
||||
RecordingRow(
|
||||
title = stampToText(si.meta.startedAt.ifBlank { si.meta.id }),
|
||||
subtitle = listOfNotNull(
|
||||
if (si.meta.durationMs > 0) fmtMs(si.meta.durationMs) else null,
|
||||
si.meta.language.ifBlank { null },
|
||||
si.meta.device.ifBlank { null },
|
||||
).joinToString(" · "),
|
||||
onClick = { selected = si },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RecordingRow(title: String, subtitle: String, onClick: () -> Unit) {
|
||||
Column(
|
||||
Modifier.fillMaxWidth().clickable(onClick = onClick).padding(vertical = 10.dp),
|
||||
) {
|
||||
Text(title, style = MaterialTheme.typography.bodyLarge)
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
/** Transcript detail for display: segments with start time, language, duration. */
|
||||
private data class Detail(
|
||||
val segments: List<Pair<Long, String>>,
|
||||
val language: String,
|
||||
val durationMs: Long,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun LibraryDetail(
|
||||
item: LibraryItem,
|
||||
storageUrl: String,
|
||||
onBack: () -> Unit,
|
||||
onDeleted: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var detail by remember { mutableStateOf<Detail?>(null) }
|
||||
var detailError by remember { mutableStateOf<String?>(null) }
|
||||
var confirmDelete by remember { mutableStateOf(false) }
|
||||
|
||||
// simple streaming player for the WAV
|
||||
var playing by remember { mutableStateOf(false) }
|
||||
val player = remember { MediaPlayer() }
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { player.stopSafely(); player.release() }
|
||||
}
|
||||
|
||||
fun startPlayback(source: () -> Unit) {
|
||||
try {
|
||||
player.stopSafely()
|
||||
player.reset()
|
||||
source()
|
||||
player.setOnPreparedListener {
|
||||
it.start()
|
||||
playing = true
|
||||
}
|
||||
player.setOnCompletionListener { playing = false }
|
||||
player.prepareAsync()
|
||||
} catch (e: Exception) {
|
||||
detailError = "playback failed: ${e.message}"
|
||||
playing = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(item) {
|
||||
detail = null
|
||||
detailError = null
|
||||
playing = false
|
||||
player.stopSafely()
|
||||
try {
|
||||
detail = withContext(Dispatchers.IO) {
|
||||
when (item) {
|
||||
is LibraryItem.Server -> {
|
||||
val url = storageUrl.trim().trimEnd('/')
|
||||
val bytes = if ("meeting.json" in item.meta.files) {
|
||||
StorageClient.fetchFile(url, item.meta.id, "meeting.json")
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val text = StorageClient.fetchFile(url, item.meta.id, "meeting.txt")
|
||||
.decodeToString()
|
||||
parseDetail(bytes, text, item.meta.language, item.meta.durationMs)
|
||||
}
|
||||
is LibraryItem.Local -> {
|
||||
val base = item.wav.parentFile!!
|
||||
val jsonBytes = File(base, item.wav.nameWithoutExtension + ".json")
|
||||
.takeIf { it.isFile }?.readBytes()
|
||||
val txt = File(base, item.wav.nameWithoutExtension + ".txt")
|
||||
.takeIf { it.isFile }?.readText() ?: ""
|
||||
parseDetail(jsonBytes, txt, "", 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
detailError = "failed to load transcript: ${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
fun delete() {
|
||||
scope.launch {
|
||||
try {
|
||||
when (item) {
|
||||
is LibraryItem.Server -> withContext(Dispatchers.IO) {
|
||||
StorageClient.delete(storageUrl, item.meta.id)
|
||||
}
|
||||
is LibraryItem.Local -> withContext(Dispatchers.IO) {
|
||||
val base = item.wav.parentFile!!
|
||||
listOf(".wav", ".txt", ".srt", ".json").forEach {
|
||||
File(base, item.wav.nameWithoutExtension + it).delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
detailError = "delete failed: ${e.message}"
|
||||
}
|
||||
onDeleted()
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedButton(onClick = onBack) { Text("← Back") }
|
||||
|
||||
when (item) {
|
||||
is LibraryItem.Server -> {
|
||||
Text(stampToText(item.meta.startedAt.ifBlank { item.meta.id }),
|
||||
style = MaterialTheme.typography.headlineSmall)
|
||||
val sub = listOfNotNull(
|
||||
if (item.meta.durationMs > 0) fmtMs(item.meta.durationMs) else null,
|
||||
item.meta.language.ifBlank { null },
|
||||
item.meta.device.ifBlank { null },
|
||||
).joinToString(" · ")
|
||||
if (sub.isNotBlank()) {
|
||||
Text(sub, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
is LibraryItem.Local -> {
|
||||
Text(stampToText(item.wav.nameWithoutExtension.removePrefix("meeting-")),
|
||||
style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
}
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button(onClick = {
|
||||
if (playing) {
|
||||
player.pause()
|
||||
playing = false
|
||||
} else if (player.isPlaying) {
|
||||
player.start()
|
||||
playing = true
|
||||
} else {
|
||||
when (item) {
|
||||
is LibraryItem.Server -> {
|
||||
val url = storageUrl.trim().trimEnd('/') +
|
||||
"/api/recordings/${item.meta.id}/files/meeting.wav"
|
||||
startPlayback { player.setDataSource(url) }
|
||||
}
|
||||
is LibraryItem.Local ->
|
||||
startPlayback { player.setDataSource(item.wav.absolutePath) }
|
||||
}
|
||||
}
|
||||
}) { Text(if (playing) "Pause" else "Play audio") }
|
||||
|
||||
OutlinedButton(onClick = {
|
||||
scope.launch {
|
||||
try {
|
||||
val text = detail?.segments?.joinToString("\n") {
|
||||
"[${fmtMs(it.first)}] ${it.second}"
|
||||
} ?: ""
|
||||
if (text.isNotBlank()) {
|
||||
context.startActivity(
|
||||
Intent.createChooser(
|
||||
Intent(Intent.ACTION_SEND)
|
||||
.setType("text/plain")
|
||||
.putExtra(Intent.EXTRA_TEXT, text),
|
||||
"Share transcript",
|
||||
),
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
detailError = "share failed: ${e.message}"
|
||||
}
|
||||
}
|
||||
}) { Text("Share") }
|
||||
|
||||
OutlinedButton(onClick = { confirmDelete = true }) { Text("Delete") }
|
||||
}
|
||||
|
||||
if (confirmDelete) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmDelete = false },
|
||||
title = { Text("Delete recording?") },
|
||||
text = { Text("This deletes the audio and transcripts. It cannot be undone.") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { confirmDelete = false; delete() }) {
|
||||
Text("Delete", color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = { confirmDelete = false }) { Text("Cancel") } },
|
||||
)
|
||||
}
|
||||
|
||||
detailError?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
|
||||
val d = detail
|
||||
when {
|
||||
d == null && detailError == null -> Row(Modifier.padding(8.dp)) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
d != null -> {
|
||||
if (d.segments.isEmpty()) {
|
||||
Text("No transcript available for this recording.",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
Text("Transcript (${d.segments.size} segments)",
|
||||
style = MaterialTheme.typography.titleSmall)
|
||||
LazyColumn(Modifier.fillMaxSize()) {
|
||||
items(d.segments) { (startMs, text) ->
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
|
||||
Text(fmtMs(startMs), style = MaterialTheme.typography.labelSmall)
|
||||
Text(text, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Builds segment list from meeting.json (preferred) or meeting.txt lines. */
|
||||
private fun parseDetail(
|
||||
jsonBytes: ByteArray?,
|
||||
txt: String,
|
||||
language: String,
|
||||
durationMs: Long,
|
||||
): Detail {
|
||||
val segments = mutableListOf<Pair<Long, String>>()
|
||||
var lang = language
|
||||
if (jsonBytes != null) {
|
||||
val root = JSONObject(String(jsonBytes))
|
||||
lang = root.optString("language", language)
|
||||
val arr = root.optJSONArray("segments")
|
||||
if (arr != null) {
|
||||
for (i in 0 until arr.length()) {
|
||||
val o = arr.getJSONObject(i)
|
||||
val text = o.optString("text", "").trim()
|
||||
if (text.isNotEmpty()) {
|
||||
segments.add((o.optDouble("start", 0.0) * 1000).toLong() to text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (segments.isEmpty() && txt.isNotBlank()) {
|
||||
for (line in txt.lineSequence()) {
|
||||
val m = Regex("^\\[(\\d+):(\\d{2})](.*)$").find(line)
|
||||
if (m != null) {
|
||||
val ms = m.groupValues[1].toLong() * 60_000 + m.groupValues[2].toLong() * 1000
|
||||
segments.add(ms to m.groupValues[3].trim())
|
||||
}
|
||||
}
|
||||
if (segments.isEmpty()) {
|
||||
segments.add(0L to txt.trim())
|
||||
}
|
||||
}
|
||||
return Detail(segments, lang, durationMs)
|
||||
}
|
||||
|
||||
private fun scanLocal(context: Context): List<LibraryItem.Local> {
|
||||
val dir = File(context.filesDir, "recordings")
|
||||
val wavs = dir.listFiles { f ->
|
||||
f.name.startsWith("meeting-") && f.name.endsWith(".wav")
|
||||
} ?: return emptyList()
|
||||
return wavs.sortedByDescending { it.name }.map { wav ->
|
||||
LibraryItem.Local(
|
||||
wav = wav,
|
||||
hasTranscript = File(dir, wav.nameWithoutExtension + ".txt").isFile,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** "20260907-150122" -> "2026-09-07 15:01" */
|
||||
private fun stampToText(stamp: String): String {
|
||||
val digits = stamp.take(15)
|
||||
if (digits.length >= 13 && digits.getOrNull(8) == '-') {
|
||||
return "${digits.substring(0, 4)}-${digits.substring(4, 6)}-${digits.substring(6, 8)} " +
|
||||
"${digits.substring(9, 11)}:${digits.substring(11, 13)}"
|
||||
}
|
||||
return stamp
|
||||
}
|
||||
|
||||
private fun MediaPlayer.stopSafely() {
|
||||
try {
|
||||
if (isPlaying) stop()
|
||||
} catch (_: IllegalStateException) {
|
||||
}
|
||||
}
|
||||
@@ -1,525 +1,56 @@
|
||||
package com.meetrec.android
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.recording.WavReader
|
||||
import com.meetrec.core.whisper.RemoteWhisperEngine
|
||||
import com.meetrec.core.whisper.Transcriber
|
||||
import com.meetrec.core.whisper.WhisperEngine
|
||||
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.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent { MeetRecScreen() }
|
||||
setContent { MeetRecApp() }
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun MeetRecScreen() {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
fun MeetRecApp() {
|
||||
var tab by remember { mutableStateOf(0) }
|
||||
|
||||
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",
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
scope.launch {
|
||||
try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
status = "Failed: ${e.message}"
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadManualWav(file: File) {
|
||||
val samples = withContext(Dispatchers.IO) {
|
||||
file.inputStream().use { WavReader.read(it) }
|
||||
}
|
||||
wavName = file.name
|
||||
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
|
||||
if (fin.segments.isNotEmpty()) {
|
||||
segments = fin.segments
|
||||
status = if (fin.error != null) {
|
||||
"Final pass failed: ${fin.error}"
|
||||
} else {
|
||||
"Saved ${fin.outputs.size} transcript files next to ${fin.file.name}"
|
||||
}
|
||||
} else if (fin.error != null) {
|
||||
status = "Final pass failed: ${fin.error} — the WAV is saved"
|
||||
runBusy { loadManualWav(fin.file) }
|
||||
} else {
|
||||
// no engine was loaded — offer the manual transcribe path
|
||||
runBusy { loadManualWav(fin.file) }
|
||||
}
|
||||
}
|
||||
|
||||
fun startRecording() = context.startForegroundService(
|
||||
Intent(context, RecorderService::class.java)
|
||||
.setAction(RecorderService.ACTION_START),
|
||||
)
|
||||
|
||||
fun stopRecording() = context.startService(
|
||||
Intent(context, RecorderService::class.java)
|
||||
.setAction(RecorderService.ACTION_STOP),
|
||||
)
|
||||
|
||||
val permLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { grants ->
|
||||
if (grants[Manifest.permission.RECORD_AUDIO] == true) {
|
||||
startRecording()
|
||||
} else {
|
||||
status = "Microphone permission is required to record"
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRecording() {
|
||||
if (recState is RecordingState.Recording) {
|
||||
stopRecording()
|
||||
return
|
||||
}
|
||||
val wanted = buildList {
|
||||
add(Manifest.permission.RECORD_AUDIO)
|
||||
if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
val missing = wanted.filter {
|
||||
context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
RecorderService.session = SessionConfig(
|
||||
finalEngine = if (transcribeOn == "phone") engine else null,
|
||||
serverUrl = if (transcribeOn == "server") {
|
||||
serverUrl.trim().ifBlank { null }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
language = language.takeIf { it != "auto" },
|
||||
liveModel = if (transcribeOn == "phone") {
|
||||
liveModel.takeIf { it != "off" }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
storageUrl = storageUrl.trim().ifBlank { null },
|
||||
)
|
||||
if (transcribeOn == "server") {
|
||||
status = "Recording — live and final pass on ${serverUrl.trim()}"
|
||||
} else if (engine == null) {
|
||||
status = "Recording without transcription — load an engine first " +
|
||||
"for the automatic final pass"
|
||||
}
|
||||
startRecording()
|
||||
} else {
|
||||
permLauncher.launch(missing.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
val pickWav = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
runBusy {
|
||||
val name = context.contentResolver
|
||||
.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else "audio" }
|
||||
?: "audio"
|
||||
val samples = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)!!.use { WavReader.read(it) }
|
||||
}
|
||||
wavName = name
|
||||
wavSamples = samples
|
||||
segments = emptyList()
|
||||
status = "$name: ${samples.size / 16000} s @ 16 kHz"
|
||||
}
|
||||
}
|
||||
|
||||
// what the transcript list shows: live lines while recording, else segments
|
||||
val transcript: List<Pair<Long, String>> =
|
||||
if (recState is RecordingState.Recording) {
|
||||
live.map { it.startMs to it.text }
|
||||
} else {
|
||||
segments.map { it.startMs to it.text }
|
||||
}
|
||||
|
||||
val recording = recState as? RecordingState.Recording
|
||||
val finalizing = recState is RecordingState.Finalizing
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
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 },
|
||||
) {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
// 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(),
|
||||
)
|
||||
|
||||
// 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(),
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBarItem(
|
||||
selected = tab == 0,
|
||||
onClick = { tab = 0 },
|
||||
icon = { Text("●") },
|
||||
label = { Text("Record") },
|
||||
)
|
||||
NavigationBarItem(
|
||||
selected = tab == 1,
|
||||
onClick = { tab = 1 },
|
||||
icon = { Text("☰") },
|
||||
label = { Text("Library") },
|
||||
)
|
||||
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"
|
||||
}
|
||||
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") }
|
||||
}
|
||||
|
||||
// recording
|
||||
Button(
|
||||
onClick = { toggleRecording() },
|
||||
enabled = !finalizing && (!busy || recording != null),
|
||||
colors = if (recording == null && !finalizing) {
|
||||
ButtonDefaults.buttonColors()
|
||||
} else {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
recording != null -> "\u25A0 Stop (${fmtMs(recording.elapsedMs)})"
|
||||
finalizing -> "Transcribing\u2026"
|
||||
else -> "\u25CF Record"
|
||||
},
|
||||
)
|
||||
}
|
||||
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)
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
Box(Modifier.fillMaxSize().padding(padding)) {
|
||||
when (tab) {
|
||||
0 -> RecordScreen()
|
||||
else -> LibraryScreen()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val LANGUAGES = listOf("auto", "en", "de")
|
||||
private val LIVE_MODELS = listOf("off", "tiny", "base")
|
||||
|
||||
private fun fmtMs(ms: Long): String {
|
||||
val total = ms / 1000
|
||||
val h = total / 3600
|
||||
val m = (total % 3600) / 60
|
||||
val s = total % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s)
|
||||
}
|
||||
@@ -0,0 +1,515 @@
|
||||
package com.meetrec.android
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.provider.OpenableColumns
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
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
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.recording.WavReader
|
||||
import com.meetrec.core.whisper.RemoteWhisperEngine
|
||||
import com.meetrec.core.whisper.Transcriber
|
||||
import com.meetrec.core.whisper.WhisperEngine
|
||||
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.
|
||||
*/
|
||||
@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",
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
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
|
||||
scope.launch {
|
||||
try {
|
||||
block()
|
||||
} catch (e: Exception) {
|
||||
status = "Failed: ${e.message}"
|
||||
} finally {
|
||||
busy = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun loadManualWav(file: File) {
|
||||
val samples = withContext(Dispatchers.IO) {
|
||||
file.inputStream().use { WavReader.read(it) }
|
||||
}
|
||||
wavName = file.name
|
||||
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
|
||||
if (fin.segments.isNotEmpty()) {
|
||||
segments = fin.segments
|
||||
status = if (fin.error != null) {
|
||||
"Final pass failed: ${fin.error}"
|
||||
} else {
|
||||
"Saved ${fin.outputs.size} transcript files next to ${fin.file.name}"
|
||||
}
|
||||
} else if (fin.error != null) {
|
||||
status = "Final pass failed: ${fin.error} — the WAV is saved"
|
||||
runBusy { loadManualWav(fin.file) }
|
||||
} else {
|
||||
// no engine was loaded — offer the manual transcribe path
|
||||
runBusy { loadManualWav(fin.file) }
|
||||
}
|
||||
}
|
||||
|
||||
fun startRecording() = context.startForegroundService(
|
||||
Intent(context, RecorderService::class.java)
|
||||
.setAction(RecorderService.ACTION_START),
|
||||
)
|
||||
|
||||
fun stopRecording() = context.startService(
|
||||
Intent(context, RecorderService::class.java)
|
||||
.setAction(RecorderService.ACTION_STOP),
|
||||
)
|
||||
|
||||
val permLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { grants ->
|
||||
if (grants[Manifest.permission.RECORD_AUDIO] == true) {
|
||||
startRecording()
|
||||
} else {
|
||||
status = "Microphone permission is required to record"
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleRecording() {
|
||||
if (recState is RecordingState.Recording) {
|
||||
stopRecording()
|
||||
return
|
||||
}
|
||||
val wanted = buildList {
|
||||
add(Manifest.permission.RECORD_AUDIO)
|
||||
if (Build.VERSION.SDK_INT >= 33) add(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
val missing = wanted.filter {
|
||||
context.checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
if (missing.isEmpty()) {
|
||||
RecorderService.session = SessionConfig(
|
||||
finalEngine = if (transcribeOn == "phone") engine else null,
|
||||
serverUrl = if (transcribeOn == "server") {
|
||||
serverUrl.trim().ifBlank { null }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
language = language.takeIf { it != "auto" },
|
||||
liveModel = if (transcribeOn == "phone") {
|
||||
liveModel.takeIf { it != "off" }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
storageUrl = storageUrl.trim().ifBlank { null },
|
||||
)
|
||||
if (transcribeOn == "server") {
|
||||
status = "Recording — live and final pass on ${serverUrl.trim()}"
|
||||
} else if (engine == null) {
|
||||
status = "Recording without transcription — load an engine first " +
|
||||
"for the automatic final pass"
|
||||
}
|
||||
startRecording()
|
||||
} else {
|
||||
permLauncher.launch(missing.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
||||
val pickWav = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.OpenDocument(),
|
||||
) { uri ->
|
||||
if (uri == null) return@rememberLauncherForActivityResult
|
||||
runBusy {
|
||||
val name = context.contentResolver
|
||||
.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
|
||||
?.use { c -> if (c.moveToFirst()) c.getString(0) else "audio" }
|
||||
?: "audio"
|
||||
val samples = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)!!.use { WavReader.read(it) }
|
||||
}
|
||||
wavName = name
|
||||
wavSamples = samples
|
||||
segments = emptyList()
|
||||
status = "$name: ${samples.size / 16000} s @ 16 kHz"
|
||||
}
|
||||
}
|
||||
|
||||
// what the transcript list shows: live lines while recording, else segments
|
||||
val transcript: List<Pair<Long, String>> =
|
||||
if (recState is RecordingState.Recording) {
|
||||
live.map { it.startMs to it.text }
|
||||
} else {
|
||||
segments.map { it.startMs to it.text }
|
||||
}
|
||||
|
||||
val recording = recState as? RecordingState.Recording
|
||||
val finalizing = recState is RecordingState.Finalizing
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
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 },
|
||||
) {
|
||||
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(),
|
||||
)
|
||||
}
|
||||
|
||||
// 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(),
|
||||
)
|
||||
|
||||
// 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"
|
||||
}
|
||||
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") }
|
||||
}
|
||||
|
||||
// recording
|
||||
Button(
|
||||
onClick = { toggleRecording() },
|
||||
enabled = !finalizing && (!busy || recording != null),
|
||||
colors = if (recording == null && !finalizing) {
|
||||
ButtonDefaults.buttonColors()
|
||||
} else {
|
||||
ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
recording != null -> "\u25A0 Stop (${fmtMs(recording.elapsedMs)})"
|
||||
finalizing -> "Transcribing\u2026"
|
||||
else -> "\u25CF Record"
|
||||
},
|
||||
)
|
||||
}
|
||||
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
|
||||
val m = (total % 3600) / 60
|
||||
val s = total % 60
|
||||
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%02d:%02d".format(m, s)
|
||||
}
|
||||
@@ -9,6 +9,78 @@ import org.json.JSONObject
|
||||
/** Client for the meetrec-server storage API (see server/meetrec-server/). */
|
||||
object StorageClient {
|
||||
|
||||
/** Index metadata for one stored recording. */
|
||||
data class RecordingMeta(
|
||||
val id: String,
|
||||
val startedAt: String,
|
||||
val durationMs: Long,
|
||||
val language: String,
|
||||
val device: String,
|
||||
val files: List<String>,
|
||||
)
|
||||
|
||||
/** Lists recordings, newest first. */
|
||||
fun list(baseUrl: String): List<RecordingMeta> {
|
||||
val body = get(url(baseUrl, "/api/recordings"))
|
||||
val arr = org.json.JSONArray(String(body))
|
||||
return (0 until arr.length()).map { i ->
|
||||
val o = arr.getJSONObject(i)
|
||||
RecordingMeta(
|
||||
id = o.getString("id"),
|
||||
startedAt = o.optString("started_at", ""),
|
||||
durationMs = o.optLong("duration_ms", 0),
|
||||
language = o.optString("language", ""),
|
||||
device = o.optString("device", ""),
|
||||
files = o.optJSONArray("files")?.let { f ->
|
||||
(0 until f.length()).map { j -> f.getString(j) }
|
||||
} ?: emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloads one of the recording's files (e.g. meeting.json). */
|
||||
fun fetchFile(baseUrl: String, id: String, name: String): ByteArray =
|
||||
get(url(baseUrl, "/api/recordings/$id/files/$name"))
|
||||
|
||||
/** Deletes a recording (204 expected). */
|
||||
fun delete(baseUrl: String, id: String) {
|
||||
val conn = (URL(url(baseUrl, "/api/recordings/$id"))
|
||||
.openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "DELETE"
|
||||
connectTimeout = 15_000
|
||||
readTimeout = 30_000
|
||||
}
|
||||
try {
|
||||
if (conn.responseCode !in 200..299) {
|
||||
throw IOException("server returned ${conn.responseCode}")
|
||||
}
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun get(url: String, timeoutMs: Int = 30_000): ByteArray {
|
||||
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 15_000
|
||||
readTimeout = timeoutMs
|
||||
}
|
||||
try {
|
||||
val code = conn.responseCode
|
||||
val stream = if (code in 200..299) conn.inputStream else conn.errorStream
|
||||
val resp = stream?.use { it.readBytes() } ?: ByteArray(0)
|
||||
if (code !in 200..299) {
|
||||
throw IOException("server returned $code: " +
|
||||
String(resp, 0, minOf(resp.size, 200)))
|
||||
}
|
||||
return resp
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun url(baseUrl: String, path: String): String =
|
||||
baseUrl.trim().trimEnd('/') + path
|
||||
|
||||
/** Uploads a recording bundle; returns the new recording id. */
|
||||
fun upload(
|
||||
baseUrl: String,
|
||||
|
||||
Reference in New Issue
Block a user