Desktop: progress-while-open, search-within-recording, click-hit-to-playback

- openDetail attaches to a pump-owned file with no mapping yet: mirrors the
  pump's live stage/progress until it leaves in-flight, then picks up the
  mapping/report — opening a mid-transcribe file now shows movement instead
  of an idle Transcribe button.
- Detail screen gets a Play/Stop row and a 'Search this recording' box.
  Searchable lines come from live segments or by parsing the [HH:MM:SS]
  prefixes out of the saved report (round-trip tested); each hit card shows
  its timestamp and plays the audio from that moment on click.
- Playback via mpv (external process; Java can't decode m4a) — exact flag
  set verified on this box against a real recording. Stop on leave-screen,
  on file delete, and at app shutdown; natural end clears the playing
  state. 31/31 tests green.
This commit is contained in:
avi 2026-09-15 05:45:30 -05:00
commit 0a713879b9
4 changed files with 235 additions and 1 deletions

View file

@ -149,7 +149,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
?.let { setFolder(it, silent = true) } ?.let { setFolder(it, silent = true) }
ensureReady() ensureReady()
} }
Runtime.getRuntime().addShutdownHook(Thread { stopEngine() }) Runtime.getRuntime().addShutdownHook(Thread { stopPlayback(); stopEngine() })
} }
/** /**
@ -258,6 +258,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
} }
fun go(screen: Screen) { fun go(screen: Screen) {
if (_screen.value == Screen.DETAIL && screen != Screen.DETAIL) stopPlayback()
_screen.value = screen _screen.value = screen
if (screen == Screen.LIBRARY) rescan() if (screen == Screen.LIBRARY) rescan()
} }
@ -662,6 +663,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
*/ */
fun deleteFile(f: File): String? { fun deleteFile(f: File): String? {
_deleteWarning.value = null _deleteWarning.value = null
if (playingFile.value?.absolutePath == f.absolutePath) stopPlayback()
if (f.name in inFlight) return "Wait — this file is transcribing." if (f.name in inFlight) return "Wait — this file is transcribing."
if (!f.exists()) { rescan(); return "That file is already gone." } if (!f.exists()) { rescan(); return "That file is already gone." }
@ -748,6 +750,52 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
fun selectedFiles(): List<File> = fun selectedFiles(): List<File> =
_entries.value.filter { it.file.absolutePath in _selected.value }.map { it.file } _entries.value.filter { it.file.absolutePath in _selected.value }.map { it.file }
// ---- playback -----------------------------------------------------------
/**
* Play [f] from [atSec] in mpv (external process — Java's own audio
* stack can't decode AAC/m4a). Starting playback replaces any current
* one; leaving the detail screen or quitting stops it.
*/
fun playFrom(f: File, atSec: Double = 0.0) {
stopPlayback()
_playError.value = null
val p = runCatching {
ProcessBuilder(
"mpv", "--no-video", "--really-quiet",
"--force-window=no", "--start=${atSec.toInt()}",
f.absolutePath,
).redirectErrorStream(true).start()
}.getOrElse {
_playError.value = "Couldn't start playback (${it.message})."
return
}
playerProcess = p
_playingFile.value = f
// Natural end (or mpv missing at runtime): clear the UI state.
scope.launch {
runCatching { p.waitFor() }
if (playerProcess === p) {
playerProcess = null
_playingFile.value = null
}
}
}
fun stopPlayback() {
playerProcess?.let { runCatching { it.destroy() } }
playerProcess = null
_playingFile.value = null
}
private var playerProcess: Process? = null
private val _playingFile = MutableStateFlow<File?>(null)
val playingFile: StateFlow<File?> = _playingFile.asStateFlow()
private val _playError = MutableStateFlow<String?>(null)
val playError: StateFlow<String?> = _playError.asStateFlow()
fun setQuery(q: String) { fun setQuery(q: String) {
_query.value = q _query.value = q
val needle = q.trim().lowercase() val needle = q.trim().lowercase()
@ -838,6 +886,25 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
_detail.value = d _detail.value = d
go(Screen.DETAIL) go(Screen.DETAIL)
pollJob = scope.launch { pollJob = scope.launch {
// Pump-owned file without a mapping yet (queued/uploading):
// mirror the pump's live progress until it leaves in-flight,
// then continue with whatever the pump left behind (mapping,
// report) so opening mid-transcribe shows movement, never an
// idle "Transcribe" button for a file already working.
if (d.remoteId == null && file.name in inFlight) {
d.busy = _liveProgress.value[file.name]?.label ?: "queued for transcription…"
if (_detail.value?.file == file) _detail.value = d.copy()
while (file.name in inFlight && _detail.value?.file == file) {
delay(500)
d.busy = _liveProgress.value[file.name]?.label ?: "uploading…"
if (_detail.value?.file == file) _detail.value = d.copy()
}
if (_detail.value?.file != file) return@launch
d.busy = null
d.remoteId = loadMapping(file)?.recordingId
d.reportText = runCatching { reportFile(file).readText() }.getOrNull()
if (_detail.value?.file == file) _detail.value = d.copy()
}
// Files that predate remote-id mapping have no sidecar: adopt // Files that predate remote-id mapping have no sidecar: adopt
// the server recording by exact title match so Re-summarize // the server recording by exact title match so Re-summarize
// appears and finished AI outputs become reachable. // appears and finished AI outputs become reachable.

View file

@ -46,3 +46,42 @@ fun fmtTs(sec: Double): String {
val total = sec.toLong().coerceAtLeast(0) val total = sec.toLong().coerceAtLeast(0)
return "%02d:%02d:%02d".format(total / 3600, (total % 3600) / 60, total % 60) return "%02d:%02d:%02d".format(total / 3600, (total % 3600) / 60, total % 60)
} }
/** One searchable transcript line: when it starts + its text. */
data class TranscriptLine(val startSec: Double, val text: String)
private val reportLineRe = Regex("""^\[(\d{1,2}):(\d{2}):(\d{2})]\s+(.*)$""")
/**
* Timestamped transcript lines from a saved report — everything under the
* "## Transcript" heading that carries an [HH:MM:SS] prefix. Lets search-
* within-recording + click-to-play work on files opened without a live
* transcript in memory.
*/
fun parseReportTranscriptLines(report: String): List<TranscriptLine> {
var inTranscript = false
val out = mutableListOf<TranscriptLine>()
for (line in report.lineSequence()) {
when {
line.startsWith("## Transcript") -> inTranscript = true
line.startsWith("## ") -> inTranscript = false
inTranscript -> reportLineRe.matchEntire(line)?.let { m ->
val (h, mi, s) = m.destructured
val sec = h.toDouble() * 3600 + mi.toDouble() * 60 + s.toDouble()
out += TranscriptLine(sec, m.groupValues[4])
}
}
}
return out
}
/** Case-insensitive substring search over transcript lines. */
fun searchTranscriptLines(
lines: List<TranscriptLine>,
needle: String,
limit: Int = 100,
): List<TranscriptLine> {
val q = needle.trim().lowercase()
if (q.isEmpty()) return emptyList()
return lines.filter { it.text.lowercase().contains(q) }.take(limit)
}

View file

@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@ -379,6 +380,18 @@ fun DetailScreen(state: DesktopState) {
var showModels by remember { mutableStateOf(false) } var showModels by remember { mutableStateOf(false) }
var confirmDelete by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) }
var deleteError by remember { mutableStateOf<String?>(null) } var deleteError by remember { mutableStateOf<String?>(null) }
var recQuery by remember { mutableStateOf("") }
val playingFile by state.playingFile.collectAsState()
val playError by state.playError.collectAsState()
val isPlaying = playingFile?.absolutePath == detail.file.absolutePath
// Searchable lines: live segments when present, else the saved report
// (its [HH:MM:SS] prefixes carry the timestamps back).
val transcriptLines = detail.transcript?.segments?.takeIf { it.isNotEmpty() }
?.map { TranscriptLine(it.startSec, it.text) }
?: detail.reportText?.let { parseReportTranscriptLines(it) }
?: emptyList()
val recHits = searchTranscriptLines(transcriptLines, recQuery)
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -394,6 +407,61 @@ fun DetailScreen(state: DesktopState) {
}, },
) )
} }
// Playback row: play from the top / stop.
Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (isPlaying) {
Button({ state.stopPlayback() }) { Text("Stop") }
Text("playing…", style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary)
} else {
OutlinedButton({ state.playFrom(detail.file) }) { Text("Play") }
}
playError?.let {
Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall)
}
}
// Search within this recording: hits jump the audio to that moment.
if (transcriptLines.isNotEmpty()) {
OutlinedTextField(recQuery, { recQuery = it },
label = { Text("Search this recording") },
singleLine = true, modifier = Modifier.fillMaxWidth())
if (recQuery.isNotBlank()) {
if (recHits.isEmpty()) {
Text("No matches in this recording.",
style = MaterialTheme.typography.bodySmall)
} else {
Text("${recHits.size} match${if (recHits.size == 1) "" else "es"} " +
"— click one to play from there",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
LazyColumn(
Modifier.fillMaxWidth().heightIn(max = 260.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
items(recHits) { hit ->
Card(Modifier.fillMaxWidth().clickable {
state.playFrom(detail.file, hit.startSec)
}) {
Row(Modifier.padding(8.dp),
verticalAlignment = Alignment.CenterVertically) {
Text(fmtTs(hit.startSec),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.width(10.dp))
Text(hit.text,
style = MaterialTheme.typography.bodySmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f))
}
}
}
}
}
}
}
// Model override: "Use default" or a specific size. // Model override: "Use default" or a specific size.
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("Model: ", style = MaterialTheme.typography.bodyMedium) Text("Model: ", style = MaterialTheme.typography.bodyMedium)

View file

@ -48,6 +48,66 @@ class ReportsTest {
} }
} }
@Test fun reportLinesRoundTripThroughRender() {
// What renderReport writes, parseReportTranscriptLines must read
// back with identical timestamps — the search-jump-to-playback
// feature depends on this round trip for files without a live
// transcript in memory.
val t = TranscriptData(
version = 1, provider = "faster_whisper", model = "base",
text = "",
segments = listOf(
TranscriptSegment(0.0, 2.0, "first thing"),
TranscriptSegment(3661.0, 3663.0, "an hour later"),
),
)
val report = renderReport("t", t, SummaryData(version = 1,
content = mapOf("short" to "a summary mentioning first thing")))
val lines = parseReportTranscriptLines(report)
assertEquals(2, lines.size)
assertEquals(0.0, lines[0].startSec, 0.001)
assertEquals("first thing", lines[0].text)
// Report prefixes are whole-seconds; 3661.0 survives exactly.
assertEquals(3661.0, lines[1].startSec, 0.001)
assertEquals("an hour later", lines[1].text)
}
@Test fun reportLineParserIgnoresNonTranscriptSections() {
val report = """
# title
## Summary
[00:05:00] fake line inside summary
## Transcript
[00:01:00] real line
*Transcribed with faster_whisper, v1.*
""".trimIndent()
val lines = parseReportTranscriptLines(report)
assertEquals(1, lines.size)
assertEquals("real line", lines[0].text)
assertEquals(60.0, lines[0].startSec, 0.001)
}
@Test fun searchTranscriptLinesCaseInsensitiveSubstring() {
val lines = listOf(
TranscriptLine(0.0, "Talked about the BRIDGE project"),
TranscriptLine(10.0, "crossing the bridge at dawn"),
TranscriptLine(20.0, "unrelated"),
)
// Needle is trimmed + lowercased: "bridge" hits both lines (1 and 2).
assertEquals(listOf(0.0, 10.0), searchTranscriptLines(lines, " Bridge ").map { it.startSec })
// Phrase narrows to the second line only.
assertEquals(listOf(10.0), searchTranscriptLines(lines, "bridge at dawn").map { it.startSec })
assertTrue(searchTranscriptLines(lines, " ").isEmpty())
assertTrue(searchTranscriptLines(lines, "nope").isEmpty())
// Limit caps the hit list.
assertEquals(1, searchTranscriptLines(lines, "bridge", limit = 1).size)
}
@Test fun sharedParsersWorkOnDesktop() { @Test fun sharedParsersWorkOnDesktop() {
val raw = """{"version":2,"provider":"user","text":"hi","segments":[], val raw = """{"version":2,"provider":"user","text":"hi","segments":[],
"edited_by_user":true}""" "edited_by_user":true}"""