Detail/library UX pass from live-use feedback:

- Detail: pinned header (leave from anywhere), Prev/Next + Alt+arrows + Esc
- Summary above transcript with full sections (key points/decisions/action
  items/details) + Copy summary / Copy action items buttons
- Voice chips moved under the summary; one-page scroll below the header
- Stuck-at-99% fixed: detail re-fetches transcript+summary when a watched
  job finishes while the screen is open
- Playback: Pause/Resume + seek slider + position readout over mpv IPC
  (socket verified; controls hide if IPC unavailable)
- Library: sort Name/Newest/Oldest (persisted, date shown per row) and
  status filter All/Untranscribed/Done/Failed
- Delete is undoable: trash + 10s Undo toast replaces confirm dialogs
  (single + bulk); startup sweeps stale trash; engine purge on undo-window
  close
This commit is contained in:
avi 2026-09-17 10:00:03 -05:00
commit 104aaa4d66
4 changed files with 663 additions and 193 deletions

View file

@ -130,6 +130,17 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
private val _autoTranscribe = MutableStateFlow(true)
val autoTranscribe: StateFlow<Boolean> = _autoTranscribe.asStateFlow()
/** Library sort: "name" (A–Z), "new" (newest file first), "old". */
private val _sortOrder = MutableStateFlow("name")
val sortOrder: StateFlow<String> = _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<String> = _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<String>()
@ -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<String?>(null)
val deleteWarning: StateFlow<String?> = _deleteWarning.asStateFlow()
// ---- trash / undo-delete -------------------------------------------------
// Deleting moves audio + report + mapping sidecar into
// ~/.config/shonar-desktop/.trash/<id>/ 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<File, File>,
val remoteId: String?,
)
private val trash = mutableMapOf<String, Trashed>()
/** 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<File, File>()
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<String?>(null)
val deleteWarning: StateFlow<String?> = _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<String>)
private val _undoTrash = MutableStateFlow<TrashToast?>(null)
val undoTrash: StateFlow<TrashToast?> = _undoTrash.asStateFlow()
private var undoJob: Job? = null
private fun offerUndo(label: String, ids: List<String>) {
_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<File>): String? {
val (now, waiting) = LibraryQueue.planDelete(files, runningNames)
val failed = now.mapNotNull { deleteFile(it) }
val ids = mutableListOf<String>()
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> = _playState.asStateFlow()
/** True when the mpv IPC socket connected: pause/seek controls appear. */
private val _playTransportReady = MutableStateFlow(false)
val playTransportReady: StateFlow<Boolean> = _playTransportReady.asStateFlow()
private var mpvSocket: java.nio.channels.SocketChannel? = null
private var playerProcess: Process? = null
private val _playingFile = MutableStateFlow<File?>(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<File>, order: String): List<File> =
when (order) {
"new" -> files.sortedWith(
compareByDescending<File> { it.lastModified() }
.thenBy { it.name.lowercase() })
"old" -> files.sortedWith(
compareBy<File> { 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?)

View file

@ -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)
}
}
}
}

View file

@ -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<String?>(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)
}
}
}

View file

@ -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 })
}
}