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)
|
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). */
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -127,3 +180,4 @@ fun main() = application {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,36 +958,103 @@ 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
|
||||||
private fun ModelRow(
|
private fun ModelRow(
|
||||||
|
|
|
||||||
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