diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index 8ba64bb..c93ad8e 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -149,7 +149,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { ?.let { setFolder(it, silent = true) } 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) { + if (_screen.value == Screen.DETAIL && screen != Screen.DETAIL) stopPlayback() _screen.value = screen if (screen == Screen.LIBRARY) rescan() } @@ -662,6 +663,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { */ fun deleteFile(f: File): String? { _deleteWarning.value = null + if (playingFile.value?.absolutePath == f.absolutePath) stopPlayback() if (f.name in inFlight) return "Wait — this file is transcribing." if (!f.exists()) { rescan(); return "That file is already gone." } @@ -748,6 +750,52 @@ class DesktopState(private val appDir: File = defaultAppDir()) { fun selectedFiles(): List = _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(null) + val playingFile: StateFlow = _playingFile.asStateFlow() + + private val _playError = MutableStateFlow(null) + val playError: StateFlow = _playError.asStateFlow() + fun setQuery(q: String) { _query.value = q val needle = q.trim().lowercase() @@ -838,6 +886,25 @@ class DesktopState(private val appDir: File = defaultAppDir()) { _detail.value = d go(Screen.DETAIL) 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 // the server recording by exact title match so Re-summarize // appears and finished AI outputs become reachable. diff --git a/app/src/main/kotlin/com/shonar/desktop/Reports.kt b/app/src/main/kotlin/com/shonar/desktop/Reports.kt index a2f3fd4..263bd8c 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Reports.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Reports.kt @@ -46,3 +46,42 @@ fun fmtTs(sec: Double): String { val total = sec.toLong().coerceAtLeast(0) 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 { + var inTranscript = false + val out = mutableListOf() + 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, + needle: String, + limit: Int = 100, +): List { + val q = needle.trim().lowercase() + if (q.isEmpty()) return emptyList() + return lines.filter { it.text.lowercase().contains(q) }.take(limit) +} diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 86d8db3..92f3a37 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn @@ -379,6 +380,18 @@ fun DetailScreen(state: DesktopState) { var showModels by remember { mutableStateOf(false) } var confirmDelete by remember { mutableStateOf(false) } var deleteError by remember { mutableStateOf(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)) { 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. Row(verticalAlignment = Alignment.CenterVertically) { Text("Model: ", style = MaterialTheme.typography.bodyMedium) diff --git a/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt b/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt index a3e837b..12c4fd7 100644 --- a/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt @@ -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() { val raw = """{"version":2,"provider":"user","text":"hi","segments":[], "edited_by_user":true}"""