diff --git a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt index 9c1a34a..b9a9db6 100644 --- a/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt +++ b/app/src/main/kotlin/com/shonar/desktop/DesktopState.kt @@ -130,6 +130,17 @@ class DesktopState(private val appDir: File = defaultAppDir()) { private val _autoTranscribe = MutableStateFlow(true) val autoTranscribe: StateFlow = _autoTranscribe.asStateFlow() + /** Library sort: "name" (A–Z), "new" (newest file first), "old". */ + private val _sortOrder = MutableStateFlow("name") + val sortOrder: StateFlow = _sortOrder.asStateFlow() + + /** Library status filter: "all" | "new" (untranscribed) | "done" | + * "failed". UI-only lens over entries — never persisted (a stale + * filter hiding files at startup reads like data loss). */ + private val _libraryFilter = MutableStateFlow("all") + val libraryFilter: StateFlow = _libraryFilter.asStateFlow() + fun setLibraryFilter(f: String) { _libraryFilter.value = f } + /** Files the pump is running or has queued this session (by name). */ private val inFlight = mutableSetOf() @@ -152,9 +163,12 @@ class DesktopState(private val appDir: File = defaultAppDir()) { private var watchJob: Job? = null init { + sweepTrash() scope.launch { _serverUrl.value = prefs.getString(KEY_URL) ?: "http://localhost:8000" _autoTranscribe.value = prefs.getString(KEY_AUTO) != "0" + prefs.getString(KEY_SORT)?.takeIf { it in SORT_ORDERS } + ?.let { _sortOrder.value = it } _summarizer.value = prefs.getString(KEY_SUMMARIZER) ?: "auto" prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } } ?.let { setFolder(it, silent = true) } @@ -462,7 +476,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { val dir = _folder.value ?: return val files = dir.listFiles() ?.filter { it.isFile && !it.name.startsWith(".") && it.extension.lowercase() in AUDIO_EXTS } - ?.sortedBy { it.name.lowercase() } + ?.let { sortLibraryFiles(it, _sortOrder.value) } .orEmpty() _entries.value = files.map { f -> val done = reportFile(f).exists() @@ -514,6 +528,14 @@ class DesktopState(private val appDir: File = defaultAppDir()) { if (on) pumpNewFiles() } + /** Library sort order: "name" | "new" | "old" (persisted; rescan applies). */ + fun setSortOrder(order: String) { + if (order !in SORT_ORDERS) return + _sortOrder.value = order + scope.launch { prefs.putString(KEY_SORT, order) } + rescan() + } + /** Which engine summarizes: "auto" (LAN H200 when its key is set, * else local Ollama), "lan" (prefer the H200), or "local" (always * laptop Ollama — no contention with other GPU users, smaller @@ -798,37 +820,57 @@ class DesktopState(private val appDir: File = defaultAppDir()) { return null } - /** - * Delete a library recording for good: the audio, its report, and its - * mapping sidecar go now; the server record (when this file was ever - * transcribed) is purged in the background — a failure there surfaces - * on [deleteWarning] rather than blocking the delete. Refuses only - * while the file is ACTUALLY transcribing (the engine has it mid-job); - * a merely-queued file is cancelled out of the pump and deleted. - * Returns null on success or a human-readable refusal. - */ - fun deleteFile(f: File): String? { + /** Non-null when a background server purge failed after a local delete. */ + private val _deleteWarning = MutableStateFlow(null) + val deleteWarning: StateFlow = _deleteWarning.asStateFlow() + + // ---- trash / undo-delete ------------------------------------------------- + // Deleting moves audio + report + mapping sidecar into + // ~/.config/shonar-desktop/.trash// instead of destroying them; + // the UI shows an Undo toast and calls purgeTrash(id) when the window + // closes (that's when the server copy is purged too). Undo moves + // everything back untouched. Startup sweeps leftovers from a killed + // session so .trash never grows forever. + + private data class Trashed( + val dir: File, + /** original path -> trashed path, moved back on undo. */ + val moved: Map, + val remoteId: String?, + ) + private val trash = mutableMapOf() + + /** Move one recording (audio + report + mapping) to the trash. + * Refuses while the engine is mid-transcribe it. Returns the trash id + * for undo/purge, or a human-readable refusal. [announce] shows the + * undo toast for this one file; bulk callers pass false and offer a + * single group themselves. */ + fun trashFile(f: File, announce: Boolean = true): String? { _deleteWarning.value = null if (playingFile.value?.absolutePath == f.absolutePath) stopPlayback() - // Atomic against the pump's poll+register: if it isn't running we - // pull it from the queue now, so it can never start after this point. val running = synchronized(runningGate) { if (f.name in runningSet) true else { queue.remove(f); inFlight -= f.name; false } } if (running) return "Wait — this file is transcribing." if (!f.exists()) { rescan(); return "That file is already gone." } - + val id = java.util.UUID.randomUUID().toString().take(8) + val dir = File(appDir, ".trash/$id") val remoteId = _detail.value?.takeIf { it.file == f }?.remoteId ?: loadMapping(f)?.recordingId - - val removals = LibraryQueue.deleteSet(f, reportFile(f), mappingFile(f)) - val failed = removals.filter { !it.delete() } - if (f.exists()) { - return "Delete failed: ${failed.joinToString { it.name }} (disk said no)." + val moved = mutableMapOf() + for (src in LibraryQueue.deleteSet(f, reportFile(f), mappingFile(f))) { + if (!src.exists()) continue + val dst = File(dir, src.name) + dir.mkdirs() + if (!src.renameTo(dst)) { + // Roll back whatever already moved; caller sees refusal. + moved.forEach { (orig, t) -> t.renameTo(orig) } + return "Delete failed: couldn't move ${src.name}." + } + moved[src] = dst } - // Bookkeeping keyed by name — drop every trace so a re-added file - // with the same name starts clean. + trash[id] = Trashed(dir, moved, remoteId) failedFiles -= f.name _batch.value = LibraryQueue.batchRemove(_batch.value, f.name) _liveProgress.value = _liveProgress.value.toMutableMap().apply { remove(f.name) } @@ -838,17 +880,35 @@ class DesktopState(private val appDir: File = defaultAppDir()) { go(Screen.LIBRARY) } rescan() + if (announce) offerUndo("Deleted \"${f.nameWithoutExtension}\"", listOf(id)) + return id + } - // Server purge in the background (suspend call; the engine is - // local so this lands in milliseconds, but the EDT must not wait). - // NotFound = stale mapping, nothing to warn about. - if (remoteId != null && _connected.value) { + /** Move a trashed recording back exactly where it was. */ + fun undoTrash(id: String): String? { + val t = trash.remove(id) ?: return "That delete already finished." + val missing = t.moved.filter { (_, trashed) -> !trashed.exists() }.keys + for ((orig, trashed) in t.moved) trashed.renameTo(orig) + runCatching { t.dir.delete() } + rescan() + return if (missing.isEmpty()) null + else "Some parts were missing; restored what was there." + } + + /** Undo window closed: destroy the trashed files for good and purge + * the engine's copy (the point of no return). */ + fun purgeTrash(id: String) { + val t = trash.remove(id) ?: return + t.moved.values.forEach { runCatching { it.delete() } } + runCatching { t.dir.deleteRecursively() } + if (t.remoteId != null && _connected.value) { scope.launch { runCatching { provider.delete( com.shonar.provider.RemoteRef( providerId = com.shonar.provider.ProviderRegistry.CUSTOM_SHONAR_ID, - key = remoteId, etag = null, sizeBytes = f.length(), + key = t.remoteId, etag = null, + sizeBytes = 0, ) ) }.onFailure { @@ -859,26 +919,74 @@ class DesktopState(private val appDir: File = defaultAppDir()) { } } } - return null } - /** Non-null when a background server purge failed after a local delete. */ - private val _deleteWarning = MutableStateFlow(null) - val deleteWarning: StateFlow = _deleteWarning.asStateFlow() + /** Startup sweep: an undo window never survives a restart — destroy + * leftovers from a killed session so .trash doesn't accumulate. */ + private fun sweepTrash() { + File(appDir, ".trash").listFiles()?.forEach { it.deleteRecursively() } + } + + // One pending undo group at a time: the newest delete replaces the + // previous one (purging it), and after UNDO_WINDOW_MS the files are + // destroyed for good. UI renders `undoTrash` as a toast with Undo. + data class TrashToast(val label: String, val ids: List) + private val _undoTrash = MutableStateFlow(null) + val undoTrash: StateFlow = _undoTrash.asStateFlow() + private var undoJob: Job? = null + + private fun offerUndo(label: String, ids: List) { + _undoTrash.value?.ids?.forEach(::purgeTrash) + _undoTrash.value = TrashToast(label, ids) + undoJob?.cancel() + undoJob = scope.launch { + delay(UNDO_WINDOW_MS) + if (_undoTrash.value?.ids == ids) { + ids.forEach(::purgeTrash) + _undoTrash.value = null + } + } + } + + /** Undo button: move every file of the pending group back. */ + fun undoLastTrash() { + undoJob?.cancel() + _undoTrash.value?.ids?.reversed()?.forEach { undoTrash(it) } + _undoTrash.value = null + } + + /** Prev/Next navigation on the detail screen: neighbor of [current] + * in the current library order (whatever sort is active). Returns + * the neighbor file or null at the ends. */ + fun neighborOf(current: File, forward: Boolean): File? { + val list = _entries.value.map { it.file } + val i = list.indexOfFirst { it.absolutePath == current.absolutePath } + if (i < 0) return null + val j = if (forward) i + 1 else i - 1 + return list.getOrNull(j) + } /** - * Delete many recordings at once. Files the pump grabbed between - * selection and confirm are skipped (named in the return) rather than - * yanked mid-transcribe. Returns null when everything went, else a - * summary of what was skipped/failed. + * Delete many recordings at once — all into the trash as ONE undo + * group (the checkbox selection was the confirmation; no dialog). + * Files the pump grabbed between selection and confirm are skipped + * (named in the return) rather than yanked mid-transcribe. Returns + * null when everything went, else a summary of what was skipped. */ fun deleteFiles(files: List): String? { val (now, waiting) = LibraryQueue.planDelete(files, runningNames) - val failed = now.mapNotNull { deleteFile(it) } + val ids = mutableListOf() val notes = buildList { + now.forEach { f -> + val r = trashFile(f, announce = false) + when { + r != null && trash.containsKey(r) -> ids.add(r) + r != null -> add(r) // refusal note ("… is transcribing") + } + } waiting.forEach { add("\"${it.nameWithoutExtension}\" is transcribing — not deleted") } - failed.forEach { add(it) } } + if (ids.isNotEmpty()) offerUndo("Deleted ${ids.size}", ids) _selected.value = emptySet() return notes.takeIf { it.isNotEmpty() }?.joinToString(" · ") } @@ -907,14 +1015,25 @@ class DesktopState(private val appDir: File = defaultAppDir()) { * 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. + * + * mpv runs with an IPC unix socket so the UI can pause/resume, seek, + * and follow the playhead (poll ~2x/sec). If the socket can't be + * opened (old mpv, weird tmpdir) playback still works — the transport + * controls just hide themselves rather than lying. */ fun playFrom(f: File, atSec: Double = 0.0) { stopPlayback() _playError.value = null + _playState.value = PlayState(positionSec = atSec) + val sockPath = java.nio.file.Path.of(System.getProperty("java.io.tmpdir"), + "shonar-mpv-${ProcessHandle.current().pid()}.sock") + runCatching { java.nio.file.Files.deleteIfExists(sockPath) } val p = runCatching { ProcessBuilder( "mpv", "--no-video", "--really-quiet", - "--force-window=no", "--start=${atSec.toInt()}", + "--force-window=no", + "--input-ipc-server=${sockPath}", + "--start=${atSec.toInt()}", f.absolutePath, ).redirectErrorStream(true).start() }.getOrElse { @@ -923,22 +1042,112 @@ class DesktopState(private val appDir: File = defaultAppDir()) { } playerProcess = p _playingFile.value = f - // Natural end (or mpv missing at runtime): clear the UI state. scope.launch { - runCatching { p.waitFor() } + // mpv creates the socket shortly after boot; retry-connect briefly. + var ch: java.nio.channels.SocketChannel? = null + val deadline = System.currentTimeMillis() + 3000 + while (ch == null && System.currentTimeMillis() < deadline && p.isAlive) { + ch = runCatching { + java.nio.channels.SocketChannel + .open(java.net.StandardProtocolFamily.UNIX) + .apply { + connect(java.net.UnixDomainSocketAddress.of(sockPath)) + configureBlocking(false) + } + }.getOrNull() + if (ch == null) delay(100) + } + mpvSocket = ch + if (_detail.value?.file == f) _playTransportReady.value = ch != null + val buf = java.nio.ByteBuffer.allocate(8192) + var lineBuf = "" + var pos = atSec + var dur = 0.0 + while (p.isAlive) { + var gotSt = false + var gotDu = false + mpvSend("""{"request_id":"st","command":["get_property","time-pos"]}""") + mpvSend("""{"request_id":"du","command":["get_property","duration"]}""") + var waited = 0L + while ((!gotSt || !gotDu) && waited < 1500 && p.isAlive) { + buf.clear() + val n = runCatching { ch?.read(buf) ?: -1 }.getOrDefault(-1) + if (n < 0) break + if (n == 0) { delay(40); waited += 40; continue } + buf.flip() + val arr = ByteArray(buf.remaining()); buf.get(arr) + lineBuf += String(arr) + var nl: Int + while (lineBuf.indexOf('\n').also { nl = it } >= 0) { + val line = lineBuf.substring(0, nl) + lineBuf = lineBuf.substring(nl + 1) + runCatching { + val o = org.json.JSONObject(line) + val data = if (o.isNull("data")) null + else o.get("data").toString().toDoubleOrNull() + when (o.optString("request_id")) { + "st" -> { gotSt = true; data?.let { pos = it } } + "du" -> { gotDu = true; data?.let { dur = it } } + } + } + } + } + _playState.value = _playState.value.copy( + positionSec = pos, durationSec = dur) + delay(500) + } if (playerProcess === p) { playerProcess = null _playingFile.value = null + _playTransportReady.value = false + mpvSocket = null + _playState.value = PlayState() + runCatching { java.nio.file.Files.deleteIfExists(sockPath) } } } } + private fun mpvSend(cmd: String) { + val ch = mpvSocket ?: return + runCatching { + ch.write(java.nio.ByteBuffer.wrap((cmd + "\n").toByteArray())) + } + } + + fun setPaused(paused: Boolean) { + mpvSend("""{"command":["set_pause",$paused]}""") + _playState.value = _playState.value.copy(paused = paused) + } + + fun seekTo(sec: Double) { + mpvSend("""{"command":["seek",${sec.coerceAtLeast(0.0)},"absolute"]}""") + _playState.value = _playState.value.copy(positionSec = sec) + } + fun stopPlayback() { + mpvSend("""{"command":["quit"]}""") + runCatching { mpvSocket?.close() } + mpvSocket = null playerProcess?.let { runCatching { it.destroy() } } playerProcess = null _playingFile.value = null + _playTransportReady.value = false + _playState.value = PlayState() } + /** Live transport state while playing (position/duration/pause). */ + data class PlayState( + val paused: Boolean = false, + val positionSec: Double = 0.0, + val durationSec: Double = 0.0, + ) + private val _playState = MutableStateFlow(PlayState()) + val playState: StateFlow = _playState.asStateFlow() + /** True when the mpv IPC socket connected: pause/seek controls appear. */ + private val _playTransportReady = MutableStateFlow(false) + val playTransportReady: StateFlow = _playTransportReady.asStateFlow() + private var mpvSocket: java.nio.channels.SocketChannel? = null + private var playerProcess: Process? = null private val _playingFile = MutableStateFlow(null) @@ -1084,6 +1293,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) { } // If the file is mid-pipeline (pump running, or reprocess started // elsewhere), show live stage/progress instead of a stale report. + var sawActive = false while (true) { val fetched = runCatching { parseJobs(provider.fetchJobs(remoteId)) } if (fetched.isFailure && d.jobs.isEmpty()) { @@ -1106,9 +1316,31 @@ class DesktopState(private val appDir: File = defaultAppDir()) { jobLabel(jobs)?.let { d.busy = it } ?: run { d.busy = null } _detail.value = d.copy() val active = jobs.any { it.status == "running" || it.status == "queued" } + sawActive = sawActive || active if (!active) break delay(2000) } + // A job that FINISHED while this screen sat open: the + // transcript/summary fetched at open time were empty (the job + // was mid-run), so without this the screen keeps its "99%" + // label, a blank body, and no voice tag after completion. + if (sawActive && _detail.value?.file == file) { + runCatching { + d.transcript = runCatching { + parseTranscript(provider.fetchTranscript(remoteId)) + }.getOrNull() ?: d.transcript + d.summary = runCatching { + parseSummary(provider.fetchSummary(remoteId)) + }.getOrNull() ?: d.summary + if (d.transcript != null || d.summary != null) saveReport(d) + } + d.jobs = emptyList() + d.busy = null + d.reportText = runCatching { reportFile(file).readText() }.getOrNull() + ?: d.reportText + _detail.value = d.copy() + rescanStatuses() + } } } @@ -1445,6 +1677,25 @@ class DesktopState(private val appDir: File = defaultAppDir()) { const val KEY_PASSWORD = "local.password" const val KEY_SECRET = "local.secret" const val KEY_AUTO = "library.auto_transcribe" + /** Library sort order: "name" | "new" | "old" (see setSortOrder). */ + const val KEY_SORT = "library.sort" + val SORT_ORDERS = setOf("name", "new", "old") + /** How long the delete Undo toast stays actionable. */ + const val UNDO_WINDOW_MS = 10_000L + + /** Order library files by the chosen scheme. "new"/"old" sort by + * last-modified (recording time for voice memos); name breaks ties + * so equal timestamps stay stable. Pure — unit-tested. */ + fun sortLibraryFiles(files: List, order: String): List = + when (order) { + "new" -> files.sortedWith( + compareByDescending { it.lastModified() } + .thenBy { it.name.lowercase() }) + "old" -> files.sortedWith( + compareBy { it.lastModified() } + .thenBy { it.name.lowercase() }) + else -> files.sortedBy { it.name.lowercase() } + } /** Summarizer choice: "auto" | "lan" | "local" (see setSummarizer). */ const val KEY_SUMMARIZER = "llm.summarizer" /** Preferred local summarization model (Ollama). */ @@ -1481,4 +1732,4 @@ data class SearchHit( ) /** What the pipeline is doing for a library file right now. */ -data class LiveProgress(val label: String, val fraction: Float?) +data class LiveProgress(val label: String, val fraction: Float?) \ No newline at end of file diff --git a/app/src/main/kotlin/com/shonar/desktop/Main.kt b/app/src/main/kotlin/com/shonar/desktop/Main.kt index 8ed0154..e403836 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Main.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Main.kt @@ -1,8 +1,18 @@ package com.shonar.desktop import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.Surface import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect @@ -12,10 +22,12 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isAltPressed import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.type @@ -91,8 +103,29 @@ fun main() = application { ShonarTheme { Surface( Modifier.fillMaxSize().onPreviewKeyEvent { e -> - if (e.type != KeyEventType.KeyDown || !e.isCtrlPressed) return@onPreviewKeyEvent false - when (e.key) { + if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + // Esc leaves Detail → Library; Alt+←/→ page recordings. + // (Plain arrows stay with text fields.) + if (screen == DesktopState.Screen.DETAIL) { + if (e.key == Key.Escape) { + state.go(DesktopState.Screen.LIBRARY); true + } else if (e.isAltPressed && !e.isCtrlPressed) { + when (e.key) { + Key.DirectionLeft -> { + state.detail.value?.file?.let { + state.neighborOf(it, forward = false)?.let(state::openDetail) + }; true + } + Key.DirectionRight -> { + state.detail.value?.file?.let { + state.neighborOf(it, forward = true)?.let(state::openDetail) + }; true + } + else -> false + } + } else false + } else if (!e.isCtrlPressed) false + else when (e.key) { Key.Equals, Key.Plus, Key.NumPadAdd, Key.DirectionUp -> { zoom = (zoom * 1.1f).coerceAtMost(3f); true } @@ -114,11 +147,32 @@ fun main() = application { LocalDensity provides ScaledDensity(baseDensity, zoom), ) { Box(Modifier.fillMaxSize().padding(20.dp)) { - when (screen) { - DesktopState.Screen.ENGINE -> EngineScreen(state) - DesktopState.Screen.LIBRARY -> LibraryScreen(state) - DesktopState.Screen.DETAIL -> DetailScreen(state) - DesktopState.Screen.SETTINGS -> SettingsScreen(state) + // Undo-delete toast sits above Library AND Detail + // (a delete from Detail lands back on Library, + // but the toast must appear either way). + val undoToast by state.undoTrash.collectAsState() + Column(Modifier.fillMaxSize()) { + undoToast?.let { t -> + Card(Modifier.fillMaxWidth()) { + Row(Modifier.fillMaxWidth() + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically) { + Text(t.label, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.weight(1f)) + TextButton({ state.undoLastTrash() }) { + Text("Undo") + } + } + } + Spacer(Modifier.height(8.dp)) + } + when (screen) { + DesktopState.Screen.ENGINE -> EngineScreen(state) + DesktopState.Screen.LIBRARY -> LibraryScreen(state) + DesktopState.Screen.DETAIL -> DetailScreen(state) + DesktopState.Screen.SETTINGS -> SettingsScreen(state) + } } } } diff --git a/app/src/main/kotlin/com/shonar/desktop/Screens.kt b/app/src/main/kotlin/com/shonar/desktop/Screens.kt index 302ab07..1fc344b 100644 --- a/app/src/main/kotlin/com/shonar/desktop/Screens.kt +++ b/app/src/main/kotlin/com/shonar/desktop/Screens.kt @@ -33,6 +33,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text @@ -45,8 +46,11 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import androidx.compose.ui.text.style.TextOverflow +import com.shonar.recording.SUMMARY_LIST_KEYS import java.io.File // ---- engine ----------------------------------------------------------------- @@ -192,7 +196,21 @@ fun FolderPickerDialog( @androidx.compose.runtime.Composable fun LibraryScreen(state: DesktopState) { val folder by state.folder.collectAsState() - val entries by state.entries.collectAsState() + val allEntries by state.entries.collectAsState() + val statusFilter by state.libraryFilter.collectAsState() + // Status lens over the sorted list (counts above also follow it so + // "Transcribe all (N)" reflects what's visible when filtered). + val entries = when (statusFilter) { + "new" -> allEntries.filter { + !it.hasReport && it.status != FileStatus.QUEUED && + it.status != FileStatus.RUNNING && it.status != FileStatus.DONE + } + "done" -> allEntries.filter { + it.hasReport || it.status == FileStatus.DONE + } + "failed" -> allEntries.filter { it.status == FileStatus.FAILED } + else -> allEntries + } val query by state.query.collectAsState() val hits by state.searchResults.collectAsState() val connected by state.connected.collectAsState() @@ -293,6 +311,36 @@ fun LibraryScreen(state: DesktopState) { OutlinedTextField(query, { state.setQuery(it) }, label = { Text("Search saved transcripts") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + // Sort order (persisted): name A–Z, newest first, oldest first. + // Date modes also surface the file date on each row below. + if (folder != null && query.isBlank()) { + Row(verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Sort", style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + val order = state.sortOrder.collectAsState().value + listOf("name" to "Name", "new" to "Newest", + "old" to "Oldest").forEach { (value, label) -> + FilterChip( + selected = order == value, + onClick = { state.setSortOrder(value) }, + label = { Text(label) }, + ) + } + Spacer(Modifier.width(12.dp)) + Text("Show", style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant) + listOf("all" to "All", "new" to "Untranscribed", + "done" to "Done", "failed" to "Failed") + .forEach { (value, label) -> + FilterChip( + selected = statusFilter == value, + onClick = { state.setLibraryFilter(value) }, + label = { Text(label) }, + ) + } + } + } if (selected.isNotEmpty() && query.isBlank()) { Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { @@ -376,7 +424,7 @@ fun LibraryScreen(state: DesktopState) { maxLines = 1, overflow = TextOverflow.Ellipsis) Text( - "%.1f MB • %s".format( + ("%.1f MB • %s".format( e.file.length() / 1e6, when (e.status) { FileStatus.DONE -> "transcript saved" @@ -389,7 +437,12 @@ fun LibraryScreen(state: DesktopState) { "failed" + (e.statusNote?.let { " ($it)" } ?: "") FileStatus.NEW -> "not transcribed" }, - ), + ) + if (state.sortOrder.collectAsState().value != "name") + // Date sorts: show what they sorted by. + " • " + java.time.LocalDate.ofInstant( + java.time.Instant.ofEpochMilli(e.file.lastModified()), + java.time.ZoneId.systemDefault()) + else ""), style = MaterialTheme.typography.bodySmall, color = when (e.status) { FileStatus.FAILED -> MaterialTheme.colorScheme.error @@ -481,35 +534,12 @@ fun LibraryScreen(state: DesktopState) { style = MaterialTheme.typography.bodySmall) } + // Bulk delete: checkbox selection IS the confirmation — files go + // to the trash and the Undo toast covers it; no scary dialog. if (deletingMany) { val victims = state.selectedFiles() - val names = victims.take(8).joinToString("\n") { - "• " + it.nameWithoutExtension - } + (if (victims.size > 8) "\n• …and ${victims.size - 8} more" else "") - AlertDialog( - onDismissRequest = { deletingMany = false }, - title = { Text("Delete ${victims.size} recordings?") }, - text = { - Column { - Text(names, style = MaterialTheme.typography.bodySmall) - Spacer(Modifier.height(8.dp)) - Text("Saved transcripts go with them; transcribed " + - "files are also removed from the engine. " + - "Anything mid-transcribe is skipped, not killed.", - style = MaterialTheme.typography.bodySmall) - } - }, - confirmButton = { - TextButton({ - deleteNotice = state.deleteFiles(victims) - deletingMany = false - }) { Text("Delete ${victims.size}", - color = MaterialTheme.colorScheme.error) } - }, - dismissButton = { - TextButton({ deletingMany = false }) { Text("Cancel") } - }, - ) + deleteNotice = state.deleteFiles(victims) + deletingMany = false } } } @@ -561,12 +591,27 @@ fun DetailScreen(state: DesktopState) { ?: emptyList() val recHits = searchTranscriptLines(transcriptLines, recQuery) + // Header stays pinned (outside the scroller): deep in a long + // transcript, scrolling all the way up just to leave the page was + // the complaint. Everything below scrolls as one flow. Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) { Row(verticalAlignment = Alignment.CenterVertically) { TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } Text(detail.file.nameWithoutExtension, style = MaterialTheme.typography.headlineSmall, modifier = Modifier.weight(1f)) + // Page through recordings without returning to the list + // (Alt+←/→ do the same). Disabled honestly at the ends. + TextButton( + onClick = { state.neighborOf(detail.file, forward = false) + ?.let(state::openDetail) }, + enabled = state.neighborOf(detail.file, forward = false) != null, + ) { Text("← Prev") } + TextButton( + onClick = { state.neighborOf(detail.file, forward = true) + ?.let(state::openDetail) }, + enabled = state.neighborOf(detail.file, forward = true) != null, + ) { Text("Next →") } TextButton( { confirmDelete = true }, enabled = detail.busy == null, @@ -575,18 +620,45 @@ fun DetailScreen(state: DesktopState) { }, ) } - // Playback row: play from the top / stop. + Column(Modifier.fillMaxWidth().weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp)) { + // Playback row: play / pause / seek / stop. Pause+seek ride the + // mpv IPC socket; if it failed to connect, only Play/Stop show + // (controls that work, never controls that lie). + val ps by state.playState.collectAsState() + val transport by state.playTransportReady.collectAsState() Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (isPlaying) { - Button({ state.stopPlayback() }) { - Icon(Icons.Filled.Stop, contentDescription = null, - modifier = Modifier.size(18.dp)) - Spacer(Modifier.width(6.dp)) - Text("Stop") + if (transport) { + OutlinedButton({ state.setPaused(!ps.paused) }) { + Text(if (ps.paused) "Resume" else "Pause") + } + } else { + Button({ state.stopPlayback() }) { + Icon(Icons.Filled.Stop, contentDescription = null, + modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text("Stop") + } } - Text("playing…", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.primary) + if (transport && ps.durationSec > 0) { + val dur = ps.durationSec + Slider( + value = ps.positionSec.coerceIn(0.0, dur).toFloat(), + onValueChange = { state.seekTo(it.toDouble()) }, + valueRange = 0f..dur.toFloat(), + modifier = Modifier.weight(1f)) + Text("${fmtTs(ps.positionSec)} / ${fmtTs(dur)}", + style = MaterialTheme.typography.labelMedium) + } else { + Text(if (ps.paused) "paused…" else "playing…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary) + Spacer(Modifier.weight(1f)) + } + TextButton({ state.stopPlayback() }) { Text("Stop") } } else { OutlinedButton({ state.playFrom(detail.file) }) { Icon(Icons.Filled.PlayArrow, contentDescription = null, @@ -738,69 +810,13 @@ fun DetailScreen(state: DesktopState) { detail.reportText == null && detail.error == null) { Button({ state.transcribe() }) { Text("Transcribe") } } - detail.transcript?.let { t -> - val remoteKnownForEdit = state.remoteIdFor(detail.file) != null - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Transcript", style = MaterialTheme.typography.titleMedium) - if (t.editedByUser) { - Spacer(Modifier.width(8.dp)) - Text("· edited by you", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary) - } - Spacer(Modifier.weight(1f)) - if (editDraft == null && remoteKnownForEdit && detail.busy == null) { - TextButton({ - editDraft = if (t.segments.isNotEmpty()) - t.segments.joinToString("\n") { it.text } else t.text - }) { Text("Edit") } - } - } - val draft = editDraft - if (draft == null) { - LazyColumn(Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp)) { - if (t.segments.isNotEmpty()) { - items(t.segments.size) { i -> - val s = t.segments[i] - Text("[${fmtTs(s.startSec)}] ${s.text}", - style = MaterialTheme.typography.bodyMedium) - } - } else { - item { Text(t.text) } - } - } - } else { - OutlinedTextField( - value = draft, - onValueChange = { editDraft = it }, - modifier = Modifier.fillMaxWidth().weight(1f), - ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button( - enabled = draft.isNotBlank() && !savingTranscript, - onClick = { - savingTranscript = true - state.saveTranscriptEdit(draft) - }, - ) { Text(if (savingTranscript) "Saving…" else "Save") } - OutlinedButton( - enabled = !savingTranscript, - onClick = { editDraft = null }, - ) { Text("Cancel") } - } - Text("Your corrected words are saved as the official version — " + - "re-transcribing later will not overwrite them.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - if (editDraft == null) { - Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) - } - } - detail.summary?.let { s -> + // Summary ABOVE the transcript: a recording with a long transcript + // pushed it below the fold (the transcript region is the only + // scroller), so opening a file showed wall-of-text with no summary + // in sight. What the recording is about comes first. Only when a + // live transcript is open — in the saved-report path the summary + // is already inside the report text shown below. + if (detail.transcript != null) detail.summary?.let { s -> if (s.short.isNotBlank()) { Row(verticalAlignment = Alignment.CenterVertically) { Text("Summary", style = MaterialTheme.typography.titleMedium) @@ -812,26 +828,59 @@ fun DetailScreen(state: DesktopState) { color = MaterialTheme.colorScheme.primary) } Text(s.short) - } - } - // No live transcript (file opened from the library after the fact): - // show the saved report so summary + transcript are actually visible. - val savedReport = detail.reportText - if (detail.transcript == null && savedReport != null) { - Text("Saved report", style = MaterialTheme.typography.titleMedium) - val reportLines = savedReport.lines() - LazyColumn(Modifier.fillMaxWidth().weight(1f), - verticalArrangement = Arrangement.spacedBy(2.dp)) { - items(reportLines.size) { i -> - Text(reportLines[i], - style = MaterialTheme.typography.bodyMedium) + // One-click copy: the summary text and the action items + // are what get pasted into notes/messages — grabbing them + // by mouse-selecting a wall of text was the old way. + val clipboard = LocalClipboardManager.current + var copiedNote by remember(detail.file.name) { + mutableStateOf(null) } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton({ + clipboard.setText(AnnotatedString(s.short)) + copiedNote = "Summary copied" + }) { Text("Copy summary") } + val actionItems = s.list("action_items") + if (actionItems.isNotEmpty()) { + TextButton({ + clipboard.setText(AnnotatedString( + actionItems.joinToString("\n") { "- $it" })) + copiedNote = "Action items copied" + }) { Text("Copy action items") } + } + } + copiedNote?.let { + Text(it, style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary) + } + // Full structured sections (key points, decisions, action + // items, questions, details) right on screen — the saved + // .md report had them but the live view showed only the + // one-liner, so "what did I have to do?" required opening + // the file elsewhere. + SUMMARY_LIST_KEYS.forEach { key -> + val items = s.list(key) + if (items.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + Text(key.replace('_', ' ').replaceFirstChar { it.uppercase() }, + style = MaterialTheme.typography.titleSmall) + items.forEach { item -> + Text("• $item", + style = MaterialTheme.typography.bodyMedium) + } + } + } + if (s.detailed.isNotBlank()) { + Spacer(Modifier.height(6.dp)) + Text("Details", style = MaterialTheme.typography.titleSmall) + Text(s.detailed, style = MaterialTheme.typography.bodyMedium) + } + Spacer(Modifier.height(4.dp)) } - Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant) } - Spacer(Modifier.height(4.dp)) + // Voice controls sit directly under the summary — they act on it, + // and at the page bottom (below a long transcript) they read as + // gone. Hoisted above the transcript section; same conditions. if (detail.transcript != null || detail.reportText != null) { // Honest labels: "Re-…" only when that output actually exists. val report = detail.reportText @@ -869,9 +918,8 @@ fun DetailScreen(state: DesktopState) { Text(if (hasTranscript) "Re-transcribe" else "Transcribe") } } - // Progress right where the buttons are: the busy spinner near - // the top is off-screen once a report fills the page, which - // made a running summarize look like "nothing happens". + // Summarize progress right where the buttons are: a running + // summarize must be visible next to the chips that started it. val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" } val sRunning = sJob?.status in setOf("queued", "running") if (sRunning) { @@ -910,33 +958,100 @@ fun DetailScreen(state: DesktopState) { color = MaterialTheme.colorScheme.onSurfaceVariant) } } - - if (confirmDelete) { - AlertDialog( - onDismissRequest = { confirmDelete = false }, - title = { Text("Delete recording?") }, - text = { - Text("\"${detail.file.nameWithoutExtension}\" and its saved " + - "transcript will be removed from this folder. If it " + - "was transcribed, the engine's copy is deleted too. " + - "This can't be undone from the app.") - }, - confirmButton = { + Spacer(Modifier.height(4.dp)) + detail.transcript?.let { t -> + val remoteKnownForEdit = state.remoteIdFor(detail.file) != null + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Transcript", style = MaterialTheme.typography.titleMedium) + if (t.editedByUser) { + Spacer(Modifier.width(8.dp)) + Text("· edited by you", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary) + } + Spacer(Modifier.weight(1f)) + if (editDraft == null && remoteKnownForEdit && detail.busy == null) { TextButton({ - // deleteFile returns to the library on success; - // a non-null return means it refused — show why. - deleteError = state.deleteFile(detail.file) - confirmDelete = false - }) { Text("Delete", color = MaterialTheme.colorScheme.error) } - }, - dismissButton = { - TextButton({ confirmDelete = false }) { Text("Cancel") } - }, - ) - deleteError?.let { - Text(it, color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall) + editDraft = if (t.segments.isNotEmpty()) + t.segments.joinToString("\n") { it.text } else t.text + }) { Text("Edit") } + } } + val draft = editDraft + if (draft == null) { + // Plain Column (not a weighted LazyColumn): the whole + // screen scrolls now, and a weighted child inside an + // infinite-height scroll parent throws. + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + if (t.segments.isNotEmpty()) { + t.segments.forEach { s -> + Text("[${fmtTs(s.startSec)}] ${s.text}", + style = MaterialTheme.typography.bodyMedium) + } + } else { + Text(t.text) + } + } + } else { + OutlinedTextField( + value = draft, + onValueChange = { editDraft = it }, + modifier = Modifier.fillMaxWidth().heightIn(min = 240.dp), + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button( + enabled = draft.isNotBlank() && !savingTranscript, + onClick = { + savingTranscript = true + state.saveTranscriptEdit(draft) + }, + ) { Text(if (savingTranscript) "Saving…" else "Save") } + OutlinedButton( + enabled = !savingTranscript, + onClick = { editDraft = null }, + ) { Text("Cancel") } + } + Text("Your corrected words are saved as the official version — " + + "re-transcribing later will not overwrite them.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + if (editDraft == null) { + Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + // No live transcript (file opened from the library after the fact): + // show the saved report so summary + transcript are actually visible. + val savedReport = detail.reportText + if (detail.transcript == null && savedReport != null) { + Text("Saved report", style = MaterialTheme.typography.titleMedium) + // Plain Column: weighted LazyColumn is illegal in a scroll parent. + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + savedReport.lines().forEach { line -> + Text(line, style = MaterialTheme.typography.bodyMedium) + } + } + Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant) + } + // (Voice chips + Re-transcribe + summarize progress live above the + // transcript, right under the summary.) + } // close the scrollable column (header stays pinned above it) + + // Delete goes straight to the trash (Undo toast in the library + // covers it) — the scary dialog was friction without safety. + if (confirmDelete) { + // trashFile returns to the library on success; a non-null + // return means it refused — stay put and show why. + deleteError = state.trashFile(detail.file) + confirmDelete = false + } + deleteError?.let { + Text(it, color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall) } } } diff --git a/app/src/test/kotlin/com/shonar/desktop/LibrarySortTest.kt b/app/src/test/kotlin/com/shonar/desktop/LibrarySortTest.kt new file mode 100644 index 0000000..9706163 --- /dev/null +++ b/app/src/test/kotlin/com/shonar/desktop/LibrarySortTest.kt @@ -0,0 +1,50 @@ +package com.shonar.desktop + +import java.io.File +import kotlin.io.path.createTempDirectory +import org.junit.Assert.assertEquals +import org.junit.Test + +class LibrarySortTest { + + private fun file(dir: File, name: String, mtime: Long): File = + File(dir, name).apply { + writeText("x") + setLastModified(mtime) + } + + @Test fun nameSortIsCaseInsensitiveAlpha() { + val dir = createTempDirectory("sort").toFile() + val b = file(dir, "Bravo.m4a", 100) + val a = file(dir, "alpha.m4a", 200) + val c = file(dir, "Charlie.m4a", 50) + assertEquals(listOf("alpha.m4a", "Bravo.m4a", "Charlie.m4a"), + DesktopState.sortLibraryFiles(listOf(b, a, c), "name").map { it.name }) + } + + @Test fun newestFirstByMtime() { + val dir = createTempDirectory("sort").toFile() + val old = file(dir, "old.m4a", 1_000) + val mid = file(dir, "mid.m4a", 2_000) + val fresh = file(dir, "fresh.m4a", 3_000) + assertEquals(listOf("fresh.m4a", "mid.m4a", "old.m4a"), + DesktopState.sortLibraryFiles(listOf(mid, old, fresh), "new").map { it.name }) + } + + @Test fun oldestFirstWithStableNameTieBreak() { + val dir = createTempDirectory("sort").toFile() + val z = file(dir, "zulu.m4a", 500) + val a = file(dir, "alpha.m4a", 500) // equal mtime -> name decides + val early = file(dir, "early.m4a", 100) + assertEquals(listOf("early.m4a", "alpha.m4a", "zulu.m4a"), + DesktopState.sortLibraryFiles(listOf(z, a, early), "old").map { it.name }) + } + + @Test fun unknownOrderFallsBackToName() { + val dir = createTempDirectory("sort").toFile() + val b = file(dir, "b.m4a", 1) + val a = file(dir, "a.m4a", 999) + assertEquals(listOf("a.m4a", "b.m4a"), + DesktopState.sortLibraryFiles(listOf(b, a), "nonsense").map { it.name }) + } +}