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) }
|
?.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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}"""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue