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:
parent
134742fc67
commit
104aaa4d66
4 changed files with 663 additions and 193 deletions
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
app/src/test/kotlin/com/shonar/desktop/LibrarySortTest.kt
Normal file
50
app/src/test/kotlin/com/shonar/desktop/LibrarySortTest.kt
Normal 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 })
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue