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:
parent
fb1d0e4880
commit
0a713879b9
4 changed files with 235 additions and 1 deletions
|
|
@ -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<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) {
|
||||
_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.
|
||||
|
|
|
|||
|
|
@ -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<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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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)) {
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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}"""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue