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) private val _autoTranscribe = MutableStateFlow(true)
val autoTranscribe: StateFlow<Boolean> = _autoTranscribe.asStateFlow() 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). */ /** Files the pump is running or has queued this session (by name). */
private val inFlight = mutableSetOf<String>() private val inFlight = mutableSetOf<String>()
@ -152,9 +163,12 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
private var watchJob: Job? = null private var watchJob: Job? = null
init { init {
sweepTrash()
scope.launch { scope.launch {
_serverUrl.value = prefs.getString(KEY_URL) ?: "http://localhost:8000" _serverUrl.value = prefs.getString(KEY_URL) ?: "http://localhost:8000"
_autoTranscribe.value = prefs.getString(KEY_AUTO) != "0" _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" _summarizer.value = prefs.getString(KEY_SUMMARIZER) ?: "auto"
prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } } prefs.getString(KEY_FOLDER)?.let { File(it).takeIf { it.isDirectory } }
?.let { setFolder(it, silent = true) } ?.let { setFolder(it, silent = true) }
@ -462,7 +476,7 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
val dir = _folder.value ?: return val dir = _folder.value ?: return
val files = dir.listFiles() val files = dir.listFiles()
?.filter { it.isFile && !it.name.startsWith(".") && it.extension.lowercase() in AUDIO_EXTS } ?.filter { it.isFile && !it.name.startsWith(".") && it.extension.lowercase() in AUDIO_EXTS }
?.sortedBy { it.name.lowercase() } ?.let { sortLibraryFiles(it, _sortOrder.value) }
.orEmpty() .orEmpty()
_entries.value = files.map { f -> _entries.value = files.map { f ->
val done = reportFile(f).exists() val done = reportFile(f).exists()
@ -514,6 +528,14 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
if (on) pumpNewFiles() 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, /** Which engine summarizes: "auto" (LAN H200 when its key is set,
* else local Ollama), "lan" (prefer the H200), or "local" (always * else local Ollama), "lan" (prefer the H200), or "local" (always
* laptop Ollama — no contention with other GPU users, smaller * laptop Ollama — no contention with other GPU users, smaller
@ -798,37 +820,57 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
return null return null
} }
/** /** Non-null when a background server purge failed after a local delete. */
* Delete a library recording for good: the audio, its report, and its private val _deleteWarning = MutableStateFlow<String?>(null)
* mapping sidecar go now; the server record (when this file was ever val deleteWarning: StateFlow<String?> = _deleteWarning.asStateFlow()
* transcribed) is purged in the background — a failure there surfaces
* on [deleteWarning] rather than blocking the delete. Refuses only // ---- trash / undo-delete -------------------------------------------------
* while the file is ACTUALLY transcribing (the engine has it mid-job); // Deleting moves audio + report + mapping sidecar into
* a merely-queued file is cancelled out of the pump and deleted. // ~/.config/shonar-desktop/.trash/<id>/ instead of destroying them;
* Returns null on success or a human-readable refusal. // 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
fun deleteFile(f: File): String? { // 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 _deleteWarning.value = null
if (playingFile.value?.absolutePath == f.absolutePath) stopPlayback() 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) { val running = synchronized(runningGate) {
if (f.name in runningSet) true if (f.name in runningSet) true
else { queue.remove(f); inFlight -= f.name; false } else { queue.remove(f); inFlight -= f.name; false }
} }
if (running) return "Wait — this file is transcribing." if (running) return "Wait — this file is transcribing."
if (!f.exists()) { rescan(); return "That file is already gone." } 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 val remoteId = _detail.value?.takeIf { it.file == f }?.remoteId
?: loadMapping(f)?.recordingId ?: loadMapping(f)?.recordingId
val moved = mutableMapOf<File, File>()
val removals = LibraryQueue.deleteSet(f, reportFile(f), mappingFile(f)) for (src in LibraryQueue.deleteSet(f, reportFile(f), mappingFile(f))) {
val failed = removals.filter { !it.delete() } if (!src.exists()) continue
if (f.exists()) { val dst = File(dir, src.name)
return "Delete failed: ${failed.joinToString { it.name }} (disk said no)." 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}."
} }
// Bookkeeping keyed by name — drop every trace so a re-added file moved[src] = dst
// with the same name starts clean. }
trash[id] = Trashed(dir, moved, remoteId)
failedFiles -= f.name failedFiles -= f.name
_batch.value = LibraryQueue.batchRemove(_batch.value, f.name) _batch.value = LibraryQueue.batchRemove(_batch.value, f.name)
_liveProgress.value = _liveProgress.value.toMutableMap().apply { remove(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) go(Screen.LIBRARY)
} }
rescan() rescan()
if (announce) offerUndo("Deleted \"${f.nameWithoutExtension}\"", listOf(id))
return id
}
// Server purge in the background (suspend call; the engine is /** Move a trashed recording back exactly where it was. */
// local so this lands in milliseconds, but the EDT must not wait). fun undoTrash(id: String): String? {
// NotFound = stale mapping, nothing to warn about. val t = trash.remove(id) ?: return "That delete already finished."
if (remoteId != null && _connected.value) { 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 { scope.launch {
runCatching { runCatching {
provider.delete( provider.delete(
com.shonar.provider.RemoteRef( com.shonar.provider.RemoteRef(
providerId = com.shonar.provider.ProviderRegistry.CUSTOM_SHONAR_ID, providerId = com.shonar.provider.ProviderRegistry.CUSTOM_SHONAR_ID,
key = remoteId, etag = null, sizeBytes = f.length(), key = t.remoteId, etag = null,
sizeBytes = 0,
) )
) )
}.onFailure { }.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. */ /** Startup sweep: an undo window never survives a restart — destroy
private val _deleteWarning = MutableStateFlow<String?>(null) * leftovers from a killed session so .trash doesn't accumulate. */
val deleteWarning: StateFlow<String?> = _deleteWarning.asStateFlow() 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 * Delete many recordings at once — all into the trash as ONE undo
* selection and confirm are skipped (named in the return) rather than * group (the checkbox selection was the confirmation; no dialog).
* yanked mid-transcribe. Returns null when everything went, else a * Files the pump grabbed between selection and confirm are skipped
* summary of what was skipped/failed. * (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? { fun deleteFiles(files: List<File>): String? {
val (now, waiting) = LibraryQueue.planDelete(files, runningNames) val (now, waiting) = LibraryQueue.planDelete(files, runningNames)
val failed = now.mapNotNull { deleteFile(it) } val ids = mutableListOf<String>()
val notes = buildList { val notes = buildList {
waiting.forEach { add("\"${it.nameWithoutExtension}\" is transcribing — not deleted") } now.forEach { f ->
failed.forEach { add(it) } 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") }
}
if (ids.isNotEmpty()) offerUndo("Deleted ${ids.size}", ids)
_selected.value = emptySet() _selected.value = emptySet()
return notes.takeIf { it.isNotEmpty() }?.joinToString(" · ") 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 * Play [f] from [atSec] in mpv (external process — Java's own audio
* stack can't decode AAC/m4a). Starting playback replaces any current * stack can't decode AAC/m4a). Starting playback replaces any current
* one; leaving the detail screen or quitting stops it. * 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) { fun playFrom(f: File, atSec: Double = 0.0) {
stopPlayback() stopPlayback()
_playError.value = null _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 { val p = runCatching {
ProcessBuilder( ProcessBuilder(
"mpv", "--no-video", "--really-quiet", "mpv", "--no-video", "--really-quiet",
"--force-window=no", "--start=${atSec.toInt()}", "--force-window=no",
"--input-ipc-server=${sockPath}",
"--start=${atSec.toInt()}",
f.absolutePath, f.absolutePath,
).redirectErrorStream(true).start() ).redirectErrorStream(true).start()
}.getOrElse { }.getOrElse {
@ -923,22 +1042,112 @@ class DesktopState(private val appDir: File = defaultAppDir()) {
} }
playerProcess = p playerProcess = p
_playingFile.value = f _playingFile.value = f
// Natural end (or mpv missing at runtime): clear the UI state.
scope.launch { 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) { if (playerProcess === p) {
playerProcess = null playerProcess = null
_playingFile.value = 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() { fun stopPlayback() {
mpvSend("""{"command":["quit"]}""")
runCatching { mpvSocket?.close() }
mpvSocket = null
playerProcess?.let { runCatching { it.destroy() } } playerProcess?.let { runCatching { it.destroy() } }
playerProcess = null playerProcess = null
_playingFile.value = 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 var playerProcess: Process? = null
private val _playingFile = MutableStateFlow<File?>(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 // If the file is mid-pipeline (pump running, or reprocess started
// elsewhere), show live stage/progress instead of a stale report. // elsewhere), show live stage/progress instead of a stale report.
var sawActive = false
while (true) { while (true) {
val fetched = runCatching { parseJobs(provider.fetchJobs(remoteId)) } val fetched = runCatching { parseJobs(provider.fetchJobs(remoteId)) }
if (fetched.isFailure && d.jobs.isEmpty()) { 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 } jobLabel(jobs)?.let { d.busy = it } ?: run { d.busy = null }
_detail.value = d.copy() _detail.value = d.copy()
val active = jobs.any { it.status == "running" || it.status == "queued" } val active = jobs.any { it.status == "running" || it.status == "queued" }
sawActive = sawActive || active
if (!active) break if (!active) break
delay(2000) 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_PASSWORD = "local.password"
const val KEY_SECRET = "local.secret" const val KEY_SECRET = "local.secret"
const val KEY_AUTO = "library.auto_transcribe" 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). */ /** Summarizer choice: "auto" | "lan" | "local" (see setSummarizer). */
const val KEY_SUMMARIZER = "llm.summarizer" const val KEY_SUMMARIZER = "llm.summarizer"
/** Preferred local summarization model (Ollama). */ /** Preferred local summarization model (Ollama). */

View file

@ -1,8 +1,18 @@
package com.shonar.desktop package com.shonar.desktop
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding 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.material3.Surface
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@ -12,10 +22,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.isCtrlPressed 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.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
@ -91,8 +103,29 @@ fun main() = application {
ShonarTheme { ShonarTheme {
Surface( Surface(
Modifier.fillMaxSize().onPreviewKeyEvent { e -> Modifier.fillMaxSize().onPreviewKeyEvent { e ->
if (e.type != KeyEventType.KeyDown || !e.isCtrlPressed) return@onPreviewKeyEvent false 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) { 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 -> { Key.Equals, Key.Plus, Key.NumPadAdd, Key.DirectionUp -> {
zoom = (zoom * 1.1f).coerceAtMost(3f); true zoom = (zoom * 1.1f).coerceAtMost(3f); true
} }
@ -114,6 +147,26 @@ fun main() = application {
LocalDensity provides ScaledDensity(baseDensity, zoom), LocalDensity provides ScaledDensity(baseDensity, zoom),
) { ) {
Box(Modifier.fillMaxSize().padding(20.dp)) { Box(Modifier.fillMaxSize().padding(20.dp)) {
// 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) { when (screen) {
DesktopState.Screen.ENGINE -> EngineScreen(state) DesktopState.Screen.ENGINE -> EngineScreen(state)
DesktopState.Screen.LIBRARY -> LibraryScreen(state) DesktopState.Screen.LIBRARY -> LibraryScreen(state)
@ -126,4 +179,5 @@ fun main() = application {
} }
} }
} }
}
} }

View file

@ -33,6 +33,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -45,8 +46,11 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.unit.dp
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import com.shonar.recording.SUMMARY_LIST_KEYS
import java.io.File import java.io.File
// ---- engine ----------------------------------------------------------------- // ---- engine -----------------------------------------------------------------
@ -192,7 +196,21 @@ fun FolderPickerDialog(
@androidx.compose.runtime.Composable @androidx.compose.runtime.Composable
fun LibraryScreen(state: DesktopState) { fun LibraryScreen(state: DesktopState) {
val folder by state.folder.collectAsState() 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 query by state.query.collectAsState()
val hits by state.searchResults.collectAsState() val hits by state.searchResults.collectAsState()
val connected by state.connected.collectAsState() val connected by state.connected.collectAsState()
@ -293,6 +311,36 @@ fun LibraryScreen(state: DesktopState) {
OutlinedTextField(query, { state.setQuery(it) }, OutlinedTextField(query, { state.setQuery(it) },
label = { Text("Search saved transcripts") }, label = { Text("Search saved transcripts") },
singleLine = true, modifier = Modifier.fillMaxWidth()) 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()) { if (selected.isNotEmpty() && query.isBlank()) {
Row(verticalAlignment = Alignment.CenterVertically, Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) { horizontalArrangement = Arrangement.spacedBy(8.dp)) {
@ -376,7 +424,7 @@ fun LibraryScreen(state: DesktopState) {
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis)
Text( Text(
"%.1f MB • %s".format( ("%.1f MB • %s".format(
e.file.length() / 1e6, e.file.length() / 1e6,
when (e.status) { when (e.status) {
FileStatus.DONE -> "transcript saved" FileStatus.DONE -> "transcript saved"
@ -389,7 +437,12 @@ fun LibraryScreen(state: DesktopState) {
"failed" + (e.statusNote?.let { " ($it)" } ?: "") "failed" + (e.statusNote?.let { " ($it)" } ?: "")
FileStatus.NEW -> "not transcribed" 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, style = MaterialTheme.typography.bodySmall,
color = when (e.status) { color = when (e.status) {
FileStatus.FAILED -> MaterialTheme.colorScheme.error FileStatus.FAILED -> MaterialTheme.colorScheme.error
@ -481,35 +534,12 @@ fun LibraryScreen(state: DesktopState) {
style = MaterialTheme.typography.bodySmall) 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) { if (deletingMany) {
val victims = state.selectedFiles() 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) deleteNotice = state.deleteFiles(victims)
deletingMany = false deletingMany = false
}) { Text("Delete ${victims.size}",
color = MaterialTheme.colorScheme.error) }
},
dismissButton = {
TextButton({ deletingMany = false }) { Text("Cancel") }
},
)
} }
} }
} }
@ -561,12 +591,27 @@ fun DetailScreen(state: DesktopState) {
?: emptyList() ?: emptyList()
val recHits = searchTranscriptLines(transcriptLines, recQuery) 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)) { Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") } TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
Text(detail.file.nameWithoutExtension, Text(detail.file.nameWithoutExtension,
style = MaterialTheme.typography.headlineSmall, style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f)) 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( TextButton(
{ confirmDelete = true }, { confirmDelete = true },
enabled = detail.busy == null, 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, Row(verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) { horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (isPlaying) { if (isPlaying) {
if (transport) {
OutlinedButton({ state.setPaused(!ps.paused) }) {
Text(if (ps.paused) "Resume" else "Pause")
}
} else {
Button({ state.stopPlayback() }) { Button({ state.stopPlayback() }) {
Icon(Icons.Filled.Stop, contentDescription = null, Icon(Icons.Filled.Stop, contentDescription = null,
modifier = Modifier.size(18.dp)) modifier = Modifier.size(18.dp))
Spacer(Modifier.width(6.dp)) Spacer(Modifier.width(6.dp))
Text("Stop") Text("Stop")
} }
Text("playing…", style = MaterialTheme.typography.bodySmall, }
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) color = MaterialTheme.colorScheme.primary)
Spacer(Modifier.weight(1f))
}
TextButton({ state.stopPlayback() }) { Text("Stop") }
} else { } else {
OutlinedButton({ state.playFrom(detail.file) }) { OutlinedButton({ state.playFrom(detail.file) }) {
Icon(Icons.Filled.PlayArrow, contentDescription = null, Icon(Icons.Filled.PlayArrow, contentDescription = null,
@ -738,69 +810,13 @@ fun DetailScreen(state: DesktopState) {
detail.reportText == null && detail.error == null) { detail.reportText == null && detail.error == null) {
Button({ state.transcribe() }) { Text("Transcribe") } Button({ state.transcribe() }) { Text("Transcribe") }
} }
detail.transcript?.let { t -> // Summary ABOVE the transcript: a recording with a long transcript
val remoteKnownForEdit = state.remoteIdFor(detail.file) != null // pushed it below the fold (the transcript region is the only
Row(verticalAlignment = Alignment.CenterVertically) { // scroller), so opening a file showed wall-of-text with no summary
Text("Transcript", style = MaterialTheme.typography.titleMedium) // in sight. What the recording is about comes first. Only when a
if (t.editedByUser) { // live transcript is open — in the saved-report path the summary
Spacer(Modifier.width(8.dp)) // is already inside the report text shown below.
Text("· edited by you", if (detail.transcript != null) detail.summary?.let { s ->
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 ->
if (s.short.isNotBlank()) { if (s.short.isNotBlank()) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
Text("Summary", style = MaterialTheme.typography.titleMedium) Text("Summary", style = MaterialTheme.typography.titleMedium)
@ -812,26 +828,59 @@ fun DetailScreen(state: DesktopState) {
color = MaterialTheme.colorScheme.primary) color = MaterialTheme.colorScheme.primary)
} }
Text(s.short) Text(s.short)
// 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") }
} }
} }
// No live transcript (file opened from the library after the fact): copiedNote?.let {
// show the saved report so summary + transcript are actually visible. Text(it, style = MaterialTheme.typography.labelMedium,
val savedReport = detail.reportText color = MaterialTheme.colorScheme.primary)
if (detail.transcript == null && savedReport != null) { }
Text("Saved report", style = MaterialTheme.typography.titleMedium) // Full structured sections (key points, decisions, action
val reportLines = savedReport.lines() // items, questions, details) right on screen — the saved
LazyColumn(Modifier.fillMaxWidth().weight(1f), // .md report had them but the live view showed only the
verticalArrangement = Arrangement.spacedBy(2.dp)) { // one-liner, so "what did I have to do?" required opening
items(reportLines.size) { i -> // the file elsewhere.
Text(reportLines[i], 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) style = MaterialTheme.typography.bodyMedium)
} }
} }
Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md", }
style = MaterialTheme.typography.bodySmall, if (s.detailed.isNotBlank()) {
color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(Modifier.height(6.dp))
Text("Details", style = MaterialTheme.typography.titleSmall)
Text(s.detailed, style = MaterialTheme.typography.bodyMedium)
} }
Spacer(Modifier.height(4.dp)) 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) { if (detail.transcript != null || detail.reportText != null) {
// Honest labels: "Re-…" only when that output actually exists. // Honest labels: "Re-…" only when that output actually exists.
val report = detail.reportText val report = detail.reportText
@ -869,9 +918,8 @@ fun DetailScreen(state: DesktopState) {
Text(if (hasTranscript) "Re-transcribe" else "Transcribe") Text(if (hasTranscript) "Re-transcribe" else "Transcribe")
} }
} }
// Progress right where the buttons are: the busy spinner near // Summarize progress right where the buttons are: a running
// the top is off-screen once a report fills the page, which // summarize must be visible next to the chips that started it.
// made a running summarize look like "nothing happens".
val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" } val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" }
val sRunning = sJob?.status in setOf("queued", "running") val sRunning = sJob?.status in setOf("queued", "running")
if (sRunning) { if (sRunning) {
@ -910,35 +958,102 @@ fun DetailScreen(state: DesktopState) {
color = MaterialTheme.colorScheme.onSurfaceVariant) color = MaterialTheme.colorScheme.onSurfaceVariant)
} }
} }
Spacer(Modifier.height(4.dp))
if (confirmDelete) { detail.transcript?.let { t ->
AlertDialog( val remoteKnownForEdit = state.remoteIdFor(detail.file) != null
onDismissRequest = { confirmDelete = false }, Row(verticalAlignment = Alignment.CenterVertically) {
title = { Text("Delete recording?") }, Text("Transcript", style = MaterialTheme.typography.titleMedium)
text = { if (t.editedByUser) {
Text("\"${detail.file.nameWithoutExtension}\" and its saved " + Spacer(Modifier.width(8.dp))
"transcript will be removed from this folder. If it " + Text("· edited by you",
"was transcribed, the engine's copy is deleted too. " + style = MaterialTheme.typography.labelMedium,
"This can't be undone from the app.") color = MaterialTheme.colorScheme.primary)
}, }
confirmButton = { Spacer(Modifier.weight(1f))
if (editDraft == null && remoteKnownForEdit && detail.busy == null) {
TextButton({ TextButton({
// deleteFile returns to the library on success; editDraft = if (t.segments.isNotEmpty())
// a non-null return means it refused — show why. t.segments.joinToString("\n") { it.text } else t.text
deleteError = state.deleteFile(detail.file) }) { Text("Edit") }
confirmDelete = false }
}) { Text("Delete", color = MaterialTheme.colorScheme.error) } }
}, val draft = editDraft
dismissButton = { if (draft == null) {
TextButton({ confirmDelete = false }) { Text("Cancel") } // 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 { deleteError?.let {
Text(it, color = MaterialTheme.colorScheme.error, Text(it, color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall) style = MaterialTheme.typography.bodySmall)
} }
} }
}
} }
@androidx.compose.runtime.Composable @androidx.compose.runtime.Composable

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