Desktop: Transcribe-all button with batch progress bar
Toolbar gets 'Transcribe all (N)' when N files still lack a transcript (includes previously-failed ones — asking again means retry). Queues them through the existing pump; hidden while a batch runs or when the library is fully transcribed. While a batch runs, a card shows 'Transcribing batch: k of N — <current file>' with a bar that blends the settled-file count with the current file's own backend-reported fraction. Disconnected mid-batch clears the bar instead of stranding it. Pure logic in LibraryQueue (transcribeAllPlan, batchAdvance) + 3 new tests; app suite 37/37. Verified button renders on live build 07:34.
This commit is contained in:
parent
087e8386f5
commit
957b4d7c77
4 changed files with 125 additions and 0 deletions
|
|
@ -520,6 +520,27 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
startPump()
|
startPump()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- transcribe-all batches ---------------------------------------------
|
||||||
|
|
||||||
|
private val _batch = MutableStateFlow<TranscribeBatch?>(null)
|
||||||
|
val batch = _batch.asStateFlow()
|
||||||
|
|
||||||
|
/** Queue every file still without a transcript. Progress rides the
|
||||||
|
* [batch] flow; the shared pump runs them in queue order. */
|
||||||
|
fun transcribeAll() {
|
||||||
|
val todo = LibraryQueue.transcribeAllPlan(_entries.value)
|
||||||
|
if (todo.isEmpty()) return
|
||||||
|
_batch.value = TranscribeBatch(todo.map { it.name }.toSet(), 0, todo.size)
|
||||||
|
todo.forEach { f ->
|
||||||
|
if (f.name !in inFlight) {
|
||||||
|
queue.add(f)
|
||||||
|
inFlight += f.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rescanStatuses()
|
||||||
|
startPump()
|
||||||
|
}
|
||||||
|
|
||||||
private fun startPump() {
|
private fun startPump() {
|
||||||
if (pumpJob?.isActive == true) return
|
if (pumpJob?.isActive == true) return
|
||||||
pumpJob = scope.launch {
|
pumpJob = scope.launch {
|
||||||
|
|
@ -529,8 +550,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
||||||
if (!ok) failedFiles += f.name
|
if (!ok) failedFiles += f.name
|
||||||
else failedFiles -= f.name
|
else failedFiles -= f.name
|
||||||
inFlight -= f.name
|
inFlight -= f.name
|
||||||
|
_batch.value = LibraryQueue.batchAdvance(_batch.value, f.name)
|
||||||
rescanStatuses()
|
rescanStatuses()
|
||||||
}
|
}
|
||||||
|
// Disconnected mid-batch: don't strand the progress bar.
|
||||||
|
if (_batch.value != null && queue.isNotEmpty() && !_connected.value) {
|
||||||
|
_batch.value = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ enum class FileStatus {
|
||||||
;
|
;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "Transcribe all" batch: [names] being processed, [done] settled so far. */
|
||||||
|
data class TranscribeBatch(val names: Set<String>, val done: Int, val total: Int)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Diff between two scans of the library folder. [added] preserves sorted
|
* Diff between two scans of the library folder. [added] preserves sorted
|
||||||
* file-name order so the queue is deterministic across rescans.
|
* file-name order so the queue is deterministic across rescans.
|
||||||
|
|
@ -87,6 +90,27 @@ object LibraryQueue {
|
||||||
if (candidates.isNotEmpty() && current.containsAll(candidates)) emptySet()
|
if (candidates.isNotEmpty() && current.containsAll(candidates)) emptySet()
|
||||||
else candidates.toSet()
|
else candidates.toSet()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What "Transcribe all" queues: every entry still without a report —
|
||||||
|
* including previously FAILED ones (the user asked again, retry them) —
|
||||||
|
* but not files already queued or running.
|
||||||
|
*/
|
||||||
|
fun transcribeAllPlan(entries: List<DesktopState.LibraryEntry>): List<File> =
|
||||||
|
entries.filter {
|
||||||
|
!it.hasReport && it.status != FileStatus.QUEUED &&
|
||||||
|
it.status != FileStatus.RUNNING
|
||||||
|
}.map { it.file }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One completed file in an active batch: returns the updated batch, or
|
||||||
|
* null when the batch just finished (done == total). Null in → null out.
|
||||||
|
*/
|
||||||
|
fun batchAdvance(batch: TranscribeBatch?, fileName: String): TranscribeBatch? {
|
||||||
|
if (batch == null || fileName !in batch.names) return batch
|
||||||
|
val done = batch.done + 1
|
||||||
|
return if (done >= batch.total) null else batch.copy(done = done)
|
||||||
|
}
|
||||||
|
|
||||||
/** Split a selection into what can go now vs. files that must wait
|
/** Split a selection into what can go now vs. files that must wait
|
||||||
* because the pump has them in flight. */
|
* because the pump has them in flight. */
|
||||||
fun planDelete(
|
fun planDelete(
|
||||||
|
|
|
||||||
|
|
@ -237,6 +237,45 @@ fun LibraryScreen(state: DesktopState) {
|
||||||
Text("Auto-transcribe new files",
|
Text("Auto-transcribe new files",
|
||||||
style = MaterialTheme.typography.bodySmall)
|
style = MaterialTheme.typography.bodySmall)
|
||||||
}
|
}
|
||||||
|
val batch = state.batch.collectAsState().value
|
||||||
|
val untranscribed = entries.count {
|
||||||
|
!it.hasReport && it.status != FileStatus.QUEUED &&
|
||||||
|
it.status != FileStatus.RUNNING
|
||||||
|
}
|
||||||
|
if (batch == null && untranscribed > 0) {
|
||||||
|
OutlinedButton({ state.transcribeAll() }) {
|
||||||
|
Text("Transcribe all ($untranscribed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Batch progress: settled count + current file's own fraction.
|
||||||
|
val batch = state.batch.collectAsState().value
|
||||||
|
if (batch != null) {
|
||||||
|
val running = entries.firstOrNull {
|
||||||
|
it.status == FileStatus.RUNNING && it.file.name in batch.names
|
||||||
|
}
|
||||||
|
Card(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(10.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text("Transcribing batch: ${batch.done + 1} of ${batch.total}" +
|
||||||
|
(running?.let { " — ${it.file.name}" } ?: ""),
|
||||||
|
style = MaterialTheme.typography.bodySmall)
|
||||||
|
val fileFrac = running?.progress
|
||||||
|
if (fileFrac != null) {
|
||||||
|
// Overall = settled files + current file's fraction.
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = {
|
||||||
|
((batch.done + fileFrac) / batch.total)
|
||||||
|
.coerceIn(0f, 1f)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth())
|
||||||
|
} else {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
progress = { (batch.done.toFloat() / batch.total) },
|
||||||
|
modifier = Modifier.fillMaxWidth())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
OutlinedTextField(query, { state.setQuery(it) },
|
OutlinedTextField(query, { state.setQuery(it) },
|
||||||
label = { Text("Search saved transcripts") },
|
label = { Text("Search saved transcripts") },
|
||||||
|
|
|
||||||
|
|
@ -125,3 +125,39 @@ class FolderPickerTest {
|
||||||
assertTrue(chain.size >= 3)
|
assertTrue(chain.size >= 3)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class TranscribeBatchTest {
|
||||||
|
private fun entry(name: String, status: FileStatus, report: Boolean) =
|
||||||
|
DesktopState.LibraryEntry(File("/r/$name"), report, status)
|
||||||
|
|
||||||
|
@Test fun planPicksNewAndFailedNotQueuedOrDone() {
|
||||||
|
val entries = listOf(
|
||||||
|
entry("a.m4a", FileStatus.NEW, false),
|
||||||
|
entry("b.m4a", FileStatus.DONE, true),
|
||||||
|
entry("c.m4a", FileStatus.QUEUED, false),
|
||||||
|
entry("d.m4a", FileStatus.RUNNING, false),
|
||||||
|
entry("e.m4a", FileStatus.FAILED, false),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(File("/r/a.m4a"), File("/r/e.m4a")),
|
||||||
|
LibraryQueue.transcribeAllPlan(entries),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun planEmptyWhenAllDone() {
|
||||||
|
assertEquals(
|
||||||
|
emptyList<File>(),
|
||||||
|
LibraryQueue.transcribeAllPlan(listOf(entry("b.m4a", FileStatus.DONE, true))),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun advanceCountsThenCompletes() {
|
||||||
|
val b = TranscribeBatch(setOf("a.m4a", "b.m4a"), 0, 2)
|
||||||
|
assertEquals(1, LibraryQueue.batchAdvance(b, "a.m4a")?.done)
|
||||||
|
// Completing the last file clears the batch.
|
||||||
|
assertEquals(null, LibraryQueue.batchAdvance(b.copy(done = 1), "b.m4a"))
|
||||||
|
// Files outside the batch don't advance it.
|
||||||
|
assertEquals(0, LibraryQueue.batchAdvance(b, "z.m4a")?.done)
|
||||||
|
assertEquals(null, LibraryQueue.batchAdvance(null, "a.m4a"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue