diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index c93ad8e..d8ea574 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -520,6 +520,27 @@ class DesktopState(private val appDir: File = defaultAppDir()) { startPump() } + // ---- transcribe-all batches --------------------------------------------- + + private val _batch = MutableStateFlow(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 + } } } diff --git a/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt b/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt index 0064e9b..b769e52 100644 --- a/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt +++ b/app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt @@ -28,6 +28,9 @@ enum class FileStatus { ; } +/** "Transcribe all" batch: [names] being processed, [done] settled so far. */ +data class TranscribeBatch(val names: Set, 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): List = + 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( diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 6dbba1b..cb7a2b3 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -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") }, diff --git a/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt b/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt index a34b24f..d2109c3 100644 --- a/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt +++ b/app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt @@ -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(), + 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")) + } +}