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()
|
||||
}
|
||||
|
||||
// ---- 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() {
|
||||
if (pumpJob?.isActive == true) return
|
||||
pumpJob = scope.launch {
|
||||
|
|
@ -529,8 +550,13 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
|
|||
if (!ok) failedFiles += f.name
|
||||
else failedFiles -= f.name
|
||||
inFlight -= f.name
|
||||
_batch.value = LibraryQueue.batchAdvance(_batch.value, f.name)
|
||||
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
|
||||
* file-name order so the queue is deterministic across rescans.
|
||||
|
|
@ -87,6 +90,27 @@ object LibraryQueue {
|
|||
if (candidates.isNotEmpty() && current.containsAll(candidates)) emptySet()
|
||||
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
|
||||
* because the pump has them in flight. */
|
||||
fun planDelete(
|
||||
|
|
|
|||
|
|
@ -237,6 +237,45 @@ fun LibraryScreen(state: DesktopState) {
|
|||
Text("Auto-transcribe new files",
|
||||
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) },
|
||||
label = { Text("Search saved transcripts") },
|
||||
|
|
|
|||
|
|
@ -125,3 +125,39 @@ class FolderPickerTest {
|
|||
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