Standalone Shonar Desktop: vendor portable sources + local engine; decouple from ~/Projects/Shonar
- shared/ = portable Android-origin sources vendored from deferred/desktop-server (app/build.gradle.kts srcDir repointed; PlaybackController.kt excluded as Android-only) - backend/ = bundled-lite engine (SQLite + inline queue); .venv symlinked from the old checkout, PYTHONPATH pins THIS backend's code over any editable install - repoRoot() resolves this project dir (env SHONAR_REPO still wins); desktop-dev.sh watches shared/ + backend/ - Verified: :app:compileKotlin + :app:test green (23 tests); engine boots on :8010, self-migrates, /healthz ok
This commit is contained in:
commit
76c867fca4
136 changed files with 21099 additions and 0 deletions
99
app/build.gradle.kts
Normal file
99
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
plugins {
|
||||
kotlin("jvm") version "2.0.21"
|
||||
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21"
|
||||
id("org.jetbrains.compose") version "1.7.3"
|
||||
}
|
||||
|
||||
group = "com.shonar"
|
||||
version = "0.1.0"
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main {
|
||||
kotlin {
|
||||
// Portable shared sources vendored into this project (formerly
|
||||
// compiled in place from the sibling Android repo). Android-only
|
||||
// files excluded below.
|
||||
srcDir("../shared")
|
||||
exclude(
|
||||
"com/shonar/ShonarApplication.kt",
|
||||
"com/shonar/MainActivity.kt",
|
||||
// settings store impls are Android (DataStore/Keystore);
|
||||
// desktop provides its own (desktopSettingsStore.kt).
|
||||
"com/shonar/settings/SettingsStore.kt",
|
||||
// recording: only AiContent.kt is portable.
|
||||
"com/shonar/recording/MigrationRunner.kt",
|
||||
"com/shonar/recording/RecordingDao.kt",
|
||||
"com/shonar/recording/RecordingEntity.kt",
|
||||
"com/shonar/recording/RecordingNotificationHelper.kt",
|
||||
"com/shonar/recording/PlaybackController.kt",
|
||||
"com/shonar/recording/RecordingPermissionHelper.kt",
|
||||
"com/shonar/recording/RecordingRepository.kt",
|
||||
"com/shonar/recording/RecordingService.kt",
|
||||
"com/shonar/recording/RecordingStateStore.kt",
|
||||
"com/shonar/recording/ShonarDatabase.kt",
|
||||
"com/shonar/recording/SyncDrain.kt",
|
||||
"com/shonar/recording/SyncSlots.kt",
|
||||
"com/shonar/recording/SyncStateConverter.kt",
|
||||
"com/shonar/recording/SyncWorker.kt",
|
||||
// widget + rename-after-save UI: Android-only (AppWidget,
|
||||
// ShonarApplication references).
|
||||
"com/shonar/widget/**",
|
||||
"com/shonar/ui/rename/**",
|
||||
// ui: only ui/folder/FolderList.kt is portable.
|
||||
"com/shonar/ui/detail/**",
|
||||
"com/shonar/ui/home/**",
|
||||
"com/shonar/ui/provider/**",
|
||||
"com/shonar/ui/settings/**",
|
||||
"com/shonar/ui/theme/**",
|
||||
"com/shonar/ui/folder/FolderBrowserScreen.kt",
|
||||
)
|
||||
srcDir("src/main/kotlin")
|
||||
}
|
||||
}
|
||||
test {
|
||||
kotlin {
|
||||
srcDir("src/test/kotlin")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(compose.material3)
|
||||
implementation(compose.materialIconsExtended)
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-swing:1.9.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
|
||||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
// Real org.json for JVM (Android stubs throw "not mocked").
|
||||
implementation("org.json:json:20240303")
|
||||
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||
testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0")
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "com.shonar.desktop.MainKt"
|
||||
nativeDistributions {
|
||||
targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg)
|
||||
targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi)
|
||||
targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb)
|
||||
packageName = "shonar-desktop"
|
||||
packageVersion = "0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.withType<Test> {
|
||||
useJUnit()
|
||||
}
|
||||
1057
app/src/main/kotlin/com/shonar/desktop/DesktopState.kt
Normal file
1057
app/src/main/kotlin/com/shonar/desktop/DesktopState.kt
Normal file
File diff suppressed because it is too large
Load diff
96
app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt
Normal file
96
app/src/main/kotlin/com/shonar/desktop/LibraryQueue.kt
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Pure library-queue logic (unit-tested; no coroutines, no Android).
|
||||
*
|
||||
* [plan] decides which files on disk are transcription candidates, and
|
||||
* the queue pump consumes them in a stable order. All policy lives here
|
||||
* so the state class stays plumbing.
|
||||
*/
|
||||
|
||||
enum class FileStatus {
|
||||
/** No report yet — a candidate. */
|
||||
NEW,
|
||||
|
||||
/** Queued for auto-transcription. */
|
||||
QUEUED,
|
||||
|
||||
/** Upload/transcribe/summarize running now. */
|
||||
RUNNING,
|
||||
|
||||
/** Report written next to the audio. */
|
||||
DONE,
|
||||
|
||||
/** Last attempt failed (error message kept beside it). */
|
||||
FAILED,
|
||||
;
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff between two scans of the library folder. [added] preserves sorted
|
||||
* file-name order so the queue is deterministic across rescans.
|
||||
*/
|
||||
class LibraryDiff(
|
||||
val added: List<File>,
|
||||
/** Names that disappeared since the previous scan. */
|
||||
val removed: Set<String>,
|
||||
)
|
||||
|
||||
object LibraryQueue {
|
||||
|
||||
/**
|
||||
* Validate renaming [oldFile] to base name [newBase] (no extension;
|
||||
* a typed extension is stripped). Returns the target File or a
|
||||
* human-readable failure. The extension always stays the original's.
|
||||
*/
|
||||
fun renameTarget(oldFile: File, newBase: String): Result<File> {
|
||||
val base = newBase.trim()
|
||||
.let { s ->
|
||||
val ext = oldFile.extension
|
||||
if (ext.isNotEmpty() && s.length > ext.length + 1 &&
|
||||
s.endsWith(".$ext", ignoreCase = true)
|
||||
) s.dropLast(ext.length + 1) else s
|
||||
}
|
||||
.trim()
|
||||
fun fail(msg: String): Result<File> =
|
||||
Result.failure(IllegalArgumentException(msg))
|
||||
if (base.isEmpty()) return fail("Name is empty.")
|
||||
if (base.any { it == '/' || it == '\\' }) return fail("Name can't contain a slash.")
|
||||
if (base == "." || base == "..") return fail("Not a valid name.")
|
||||
if (base.startsWith(".")) return fail("Name can't start with a dot.")
|
||||
if (base.length > 120) return fail("Name is too long.")
|
||||
val target = File(oldFile.parentFile, "$base.${oldFile.extension}")
|
||||
if (target == oldFile) return fail("That's already the name.")
|
||||
if (target.exists()) return fail("A file with that name already exists.")
|
||||
return Result.success(target)
|
||||
}
|
||||
|
||||
/** Audio candidates: regular files, known extension, not hidden. */
|
||||
fun isAudioCandidate(f: File, audioExts: Set<String>): Boolean =
|
||||
f.isFile && !f.name.startsWith(".") &&
|
||||
f.extension.lowercase() in audioExts
|
||||
|
||||
fun diff(before: Set<String>, after: List<File>): LibraryDiff {
|
||||
val afterNames = after.map { it.name }.toSet()
|
||||
val added = after.filter { it.name !in before }
|
||||
val removed = before - afterNames
|
||||
return LibraryDiff(added, removed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Files to (re)queue for auto-transcription: candidates without a
|
||||
* report, minus files already in-flight or previously failed since
|
||||
* the last scan (a failure must not hot-loop; a rescan after the
|
||||
* user edits/renames clears it naturally when the report appears).
|
||||
*/
|
||||
fun autoQueueCandidates(
|
||||
entries: List<File>,
|
||||
reportExists: (File) -> Boolean,
|
||||
inFlightOrDone: Set<String>,
|
||||
failed: Set<String>,
|
||||
): List<File> = entries.filter {
|
||||
!reportExists(it) && it.name !in inFlightOrDone && it.name !in failed
|
||||
}
|
||||
}
|
||||
87
app/src/main/kotlin/com/shonar/desktop/Main.kt
Normal file
87
app/src/main/kotlin/com/shonar/desktop/Main.kt
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
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.key
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Window
|
||||
import androidx.compose.ui.window.application
|
||||
import androidx.compose.ui.window.rememberWindowState
|
||||
|
||||
/** Scales every dp/sp in the app uniformly (fonts, paddings, controls). */
|
||||
private data class ScaledDensity(val base: Density, val zoom: Float) : Density {
|
||||
override val density: Float get() = base.density * zoom
|
||||
override val fontScale: Float get() = base.fontScale * zoom
|
||||
}
|
||||
|
||||
fun main() = application {
|
||||
val state = remember { DesktopState() }
|
||||
val screen by state.screen.collectAsState()
|
||||
// Ctrl+= / Ctrl+- zoom the whole app; Ctrl+0 resets (after a zoom).
|
||||
var zoom by remember { mutableStateOf(1f) }
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "SHONAR Desktop" + run {
|
||||
// Build stamp so "which window is this?" is answerable at a glance.
|
||||
val jar = java.io.File(
|
||||
System.getProperty("java.class.path", "").split(":")
|
||||
.firstOrNull { it.endsWith(".jar") } ?: "")
|
||||
if (jar.exists()) {
|
||||
val fmt = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
|
||||
.withZone(java.time.ZoneId.systemDefault())
|
||||
" · build " + fmt.format(java.time.Instant.ofEpochMilli(jar.lastModified()))
|
||||
} else ""
|
||||
},
|
||||
state = rememberWindowState(width = 1100.dp, height = 800.dp),
|
||||
) {
|
||||
val baseDensity = LocalDensity.current
|
||||
ShonarTheme {
|
||||
Surface(
|
||||
Modifier.fillMaxSize().onPreviewKeyEvent { e ->
|
||||
if (e.type != KeyEventType.KeyDown || !e.isCtrlPressed) return@onPreviewKeyEvent false
|
||||
when (e.key) {
|
||||
Key.Equals, Key.Plus, Key.NumPadAdd -> {
|
||||
zoom = (zoom * 1.1f).coerceAtMost(3f); true
|
||||
}
|
||||
Key.Minus, Key.NumPadSubtract -> {
|
||||
zoom = (zoom / 1.1f).coerceAtLeast(0.4f); true
|
||||
}
|
||||
Key.Zero, Key.NumPad0 -> {
|
||||
if (zoom != 1f) { zoom = 1f; true } else false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
},
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/src/main/kotlin/com/shonar/desktop/RemoteMapping.kt
Normal file
51
app/src/main/kotlin/com/shonar/desktop/RemoteMapping.kt
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Sidecar mapping a library file to its server recording id. Written next
|
||||
* to the audio as `<name>.shonar.json` (excluded from the library listing
|
||||
* by the audio-extension filter; travels with the file through renames
|
||||
* and Syncthing).
|
||||
*
|
||||
* Why persisted: without it, "Re-transcribe" after a restart uploads a
|
||||
* brand-new recording (duplicate on the server). With it, the desktop
|
||||
* re-runs the pipeline in place via POST /reprocess.
|
||||
*
|
||||
* Pure logic — unit-tested.
|
||||
*/
|
||||
data class RemoteMapping(
|
||||
val recordingId: String,
|
||||
/** Model override the last transcribe used, if any (informational). */
|
||||
val model: String? = null,
|
||||
val title: String? = null,
|
||||
)
|
||||
|
||||
fun mappingFile(audio: File): File =
|
||||
File(audio.parentFile, "${audio.nameWithoutExtension}.shonar.json")
|
||||
|
||||
fun loadMapping(audio: File): RemoteMapping? = runCatching {
|
||||
val o = JSONObject(mappingFile(audio).readText())
|
||||
val id = o.optString("recording_id")
|
||||
if (id.isBlank()) null
|
||||
else RemoteMapping(
|
||||
recordingId = id,
|
||||
model = o.optString("model").takeIf { it.isNotBlank() },
|
||||
title = o.optString("title").takeIf { it.isNotBlank() },
|
||||
)
|
||||
}.getOrNull()
|
||||
|
||||
fun saveMapping(audio: File, mapping: RemoteMapping) {
|
||||
val o = JSONObject()
|
||||
o.put("recording_id", mapping.recordingId)
|
||||
mapping.model?.let { o.put("model", it) }
|
||||
mapping.title?.let { o.put("title", it) }
|
||||
mappingFile(audio).writeText(o.toString(2) + "\n")
|
||||
}
|
||||
|
||||
/** Carry the mapping across a file rename (returns true if one moved). */
|
||||
fun moveMapping(old: File, new: File): Boolean {
|
||||
val src = mappingFile(old)
|
||||
return src.exists() && src.renameTo(mappingFile(new))
|
||||
}
|
||||
48
app/src/main/kotlin/com/shonar/desktop/Reports.kt
Normal file
48
app/src/main/kotlin/com/shonar/desktop/Reports.kt
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import com.shonar.recording.SUMMARY_LIST_KEYS
|
||||
import com.shonar.recording.SummaryData
|
||||
import com.shonar.recording.TranscriptData
|
||||
|
||||
/**
|
||||
* Plaud-style Markdown report written next to the audio file.
|
||||
* Pure logic — unit-tested.
|
||||
*/
|
||||
fun renderReport(title: String, transcript: TranscriptData?, summary: SummaryData?): String {
|
||||
val out = mutableListOf("# $title", "")
|
||||
if (summary != null) {
|
||||
if (summary.short.isNotBlank()) {
|
||||
out += listOf("## Summary", "", summary.short, "")
|
||||
}
|
||||
for (key in SUMMARY_LIST_KEYS) {
|
||||
val items = summary.list(key)
|
||||
if (items.isNotEmpty()) {
|
||||
out += listOf("## " + key.replace('_', ' ').replaceFirstChar { it.uppercase() }, "")
|
||||
out += items.map { "- $it" } + listOf("")
|
||||
}
|
||||
}
|
||||
if (summary.detailed.isNotBlank()) {
|
||||
out += listOf("## Details", "", summary.detailed, "")
|
||||
}
|
||||
}
|
||||
if (transcript != null) {
|
||||
out += listOf("## Transcript", "")
|
||||
val segs = transcript.segments
|
||||
if (segs.isNotEmpty()) {
|
||||
segs.forEach { s -> out += "[${fmtTs(s.startSec)}] ${s.text}" }
|
||||
out += ""
|
||||
} else {
|
||||
out += listOf(transcript.text, "")
|
||||
}
|
||||
val prov = transcript.provider.ifBlank { "?" }
|
||||
out += "*Transcribed with $prov, v${transcript.version}.*"
|
||||
} else {
|
||||
out += listOf("## Transcript", "", "_No transcript available._", "")
|
||||
}
|
||||
return out.joinToString("\n")
|
||||
}
|
||||
|
||||
fun fmtTs(sec: Double): String {
|
||||
val total = sec.toLong().coerceAtLeast(0)
|
||||
return "%02d:%02d:%02d".format(total / 3600, (total % 3600) / 60, total % 60)
|
||||
}
|
||||
525
app/src/main/kotlin/com/shonar/desktop/Screens.kt
Normal file
525
app/src/main/kotlin/com/shonar/desktop/Screens.kt
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
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.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.collectAsState
|
||||
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.unit.dp
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import java.io.File
|
||||
import javax.swing.JFileChooser
|
||||
|
||||
// ---- engine -----------------------------------------------------------------
|
||||
|
||||
@androidx.compose.runtime.Composable
|
||||
fun EngineScreen(state: DesktopState) {
|
||||
val engine by state.engine.collectAsState()
|
||||
val error by state.engineError.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Text("Starting the transcription engine…",
|
||||
style = MaterialTheme.typography.headlineSmall)
|
||||
Text(
|
||||
"SHONAR transcribes on this computer with local models — nothing " +
|
||||
"is uploaded anywhere. The engine runs in the background while " +
|
||||
"this app is open.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
when (engine) {
|
||||
DesktopState.EngineState.STARTING, DesktopState.EngineState.UNKNOWN -> {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Starting…")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Button({ state.startEngine() }) { Text("Start engine") }
|
||||
OutlinedButton({ state.ensureReady() }) { Text("Retry") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- library ---------------------------------------------------------------
|
||||
|
||||
private fun chooseFolder(): File? {
|
||||
val chooser = JFileChooser().apply {
|
||||
dialogTitle = "Choose your recordings folder"
|
||||
fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
|
||||
isAcceptAllFileFilterUsed = false
|
||||
// Without an explicit size the dialog comes up tiny on HiDPI
|
||||
// compositors (and there is no resize handle), so pin a usable one.
|
||||
preferredSize = java.awt.Dimension(1040, 700)
|
||||
}
|
||||
return if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) chooser.selectedFile else null
|
||||
}
|
||||
|
||||
@androidx.compose.runtime.Composable
|
||||
fun LibraryScreen(state: DesktopState) {
|
||||
val folder by state.folder.collectAsState()
|
||||
val entries by state.entries.collectAsState()
|
||||
val query by state.query.collectAsState()
|
||||
val hits by state.searchResults.collectAsState()
|
||||
val connected by state.connected.collectAsState()
|
||||
var renaming by remember { mutableStateOf<File?>(null) }
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Recordings", style = MaterialTheme.typography.headlineSmall,
|
||||
modifier = Modifier.weight(1f))
|
||||
TextButton({ state.go(DesktopState.Screen.SETTINGS) }) { Text("Settings") }
|
||||
}
|
||||
if (!connected) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Transcription engine isn't running.",
|
||||
modifier = Modifier.weight(1f))
|
||||
TextButton({ state.go(DesktopState.Screen.ENGINE) }) {
|
||||
Text("Start it")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(folder?.absolutePath ?: "No folder chosen",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
modifier = Modifier.weight(1f))
|
||||
Spacer(Modifier.width(8.dp))
|
||||
OutlinedButton({ chooseFolder()?.let { state.pickFolder(it) } }) {
|
||||
Text("Choose folder…")
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
val auto = state.autoTranscribe.collectAsState().value
|
||||
Checkbox(auto, { state.setAutoTranscribe(it) })
|
||||
Text("Auto-transcribe new files",
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
OutlinedTextField(query, { state.setQuery(it) },
|
||||
label = { Text("Search saved transcripts") },
|
||||
singleLine = true, modifier = Modifier.fillMaxWidth())
|
||||
if (query.isNotBlank()) {
|
||||
if (hits.isEmpty()) {
|
||||
Text("No matches.", style = MaterialTheme.typography.bodySmall)
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxWidth().weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
items(hits, key = { "${it.audioPath ?: "text"}:${it.report}:${it.line}" }) { h ->
|
||||
val openable = h.audioPath?.let { File(it).exists() } == true
|
||||
Card(
|
||||
Modifier.fillMaxWidth().then(
|
||||
if (openable) Modifier.clickable {
|
||||
state.openDetail(File(h.audioPath))
|
||||
} else Modifier
|
||||
),
|
||||
) {
|
||||
Column(Modifier.padding(10.dp)) {
|
||||
Text(h.report, style = MaterialTheme.typography.titleSmall)
|
||||
Text(
|
||||
if (h.line == 0) h.snippet else "line ${h.line}: …${h.snippet}…",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (folder == null) {
|
||||
Text("Pick the folder with your .m4a recordings to begin.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else if (entries.isEmpty()) {
|
||||
Text("No audio files in this folder.",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
} else {
|
||||
LazyColumn(Modifier.fillMaxWidth().weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
items(entries, key = { it.file.absolutePath }) { e ->
|
||||
Card(Modifier.fillMaxWidth().clickable { state.openDetail(e.file) }) {
|
||||
Row(Modifier.fillMaxWidth().padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(e.file.nameWithoutExtension,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
"%.1f MB • %s".format(
|
||||
e.file.length() / 1e6,
|
||||
when (e.status) {
|
||||
FileStatus.DONE -> "transcript saved"
|
||||
FileStatus.QUEUED -> "queued for transcription"
|
||||
// statusNote carries the live
|
||||
// "transcribing… 42%" label.
|
||||
FileStatus.RUNNING ->
|
||||
e.statusNote ?: "transcribing…"
|
||||
FileStatus.FAILED ->
|
||||
"failed" + (e.statusNote?.let { " ($it)" } ?: "")
|
||||
FileStatus.NEW -> "not transcribed"
|
||||
},
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = when (e.status) {
|
||||
FileStatus.FAILED -> MaterialTheme.colorScheme.error
|
||||
FileStatus.DONE -> MaterialTheme.colorScheme.primary
|
||||
else -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
if (e.status == FileStatus.RUNNING) {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
val frac = e.progress
|
||||
if (frac != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { frac },
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (e.status == FileStatus.NEW || e.status == FileStatus.FAILED) {
|
||||
TextButton({ state.pumpFile(e.file) }) { Text("Transcribe") }
|
||||
}
|
||||
if (e.status != FileStatus.RUNNING &&
|
||||
e.status != FileStatus.QUEUED) {
|
||||
TextButton({ renaming = e.file }) { Text("Rename") }
|
||||
}
|
||||
TextButton({ state.openDetail(e.file) }) { Text("Open") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renaming?.let { target ->
|
||||
var text by remember(target) { mutableStateOf(target.nameWithoutExtension) }
|
||||
var error by remember(target) { mutableStateOf<String?>(null) }
|
||||
AlertDialog(
|
||||
onDismissRequest = { renaming = null },
|
||||
title = { Text("Rename recording") },
|
||||
text = {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it; error = null },
|
||||
label = { Text("New name") },
|
||||
singleLine = true,
|
||||
)
|
||||
error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton({
|
||||
error = state.renameFile(target, text)
|
||||
if (error == null) renaming = null
|
||||
}) { Text("Rename") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton({ renaming = null }) { Text("Cancel") }
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- detail -----------------------------------------------------------------
|
||||
|
||||
@androidx.compose.runtime.Composable
|
||||
fun DetailScreen(state: DesktopState) {
|
||||
val detail = state.detail.collectAsState().value ?: run {
|
||||
state.go(DesktopState.Screen.LIBRARY)
|
||||
return
|
||||
}
|
||||
val models = state.models.collectAsState().value
|
||||
var showModels by remember { mutableStateOf(false) }
|
||||
|
||||
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))
|
||||
}
|
||||
// Model override: "Use default" or a specific size.
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("Model: ", style = MaterialTheme.typography.bodyMedium)
|
||||
val current = detail.overrideModel
|
||||
?: models?.defaultModel
|
||||
?: "base"
|
||||
TextButton({ showModels = !showModels }) {
|
||||
Text(if (detail.overrideModel == null) "Use default ($current)" else current)
|
||||
}
|
||||
}
|
||||
if (showModels && models != null) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
ModelRow(state, detail, null, models.defaultModel,
|
||||
"Use default (${models.defaultModel})", "")
|
||||
models.models.forEach { m ->
|
||||
ModelRow(state, detail, m.name, models.defaultModel,
|
||||
m.displayName, m.description +
|
||||
(if (!m.available) " — not downloaded" else ""))
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
}
|
||||
detail.uploadProgress?.let {
|
||||
LinearProgressIndicator(progress = { it }, modifier = Modifier.fillMaxWidth())
|
||||
Text("Uploading… ${(it * 100).toInt()}%")
|
||||
}
|
||||
detail.busy?.takeIf { detail.uploadProgress == null }?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(it)
|
||||
}
|
||||
}
|
||||
val tJob = detail.jobs.firstOrNull { it.jobType == "transcribe" }
|
||||
tJob?.let {
|
||||
val running = it.status == "running"
|
||||
val label = when {
|
||||
it.status == "succeeded" -> "Transcribed"
|
||||
it.status == "failed" -> "Failed"
|
||||
it.stage == "loading-model" -> "Loading model…"
|
||||
running && it.progress != null -> "Transcribing… ${it.progress}%"
|
||||
it.stage == "transcribing" -> "Transcribing…"
|
||||
running -> "Working… (attempt ${it.attempt}/${it.maxAttempts})"
|
||||
else -> "Queued"
|
||||
}
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (it.status == "failed") MaterialTheme.colorScheme.error
|
||||
else MaterialTheme.colorScheme.primary)
|
||||
if (running) {
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (it.progress != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { it.progress / 100f },
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val sJob = detail.jobs.firstOrNull { it.jobType == "summarize" }
|
||||
sJob?.takeIf { it.status == "running" || it.status == "queued" }?.let {
|
||||
val label = if (it.status == "running") "Summarizing…" else "Summary queued"
|
||||
Text(label, style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
LinearProgressIndicator(
|
||||
modifier = Modifier.fillMaxWidth().height(4.dp),
|
||||
)
|
||||
}
|
||||
detail.error?.let {
|
||||
Text(it, color = MaterialTheme.colorScheme.error)
|
||||
if (tJob?.status == "failed") {
|
||||
Button({ state.retry() }) { Text("Retry transcription") }
|
||||
}
|
||||
}
|
||||
if (detail.busy == null && detail.transcript == null &&
|
||||
detail.reportText == null && detail.error == null) {
|
||||
Button({ state.transcribe() }) { Text("Transcribe") }
|
||||
}
|
||||
detail.transcript?.let { t ->
|
||||
Text("Transcript", style = MaterialTheme.typography.titleMedium)
|
||||
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) }
|
||||
}
|
||||
}
|
||||
Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
detail.summary?.let { s ->
|
||||
if (s.short.isNotBlank()) {
|
||||
Text("Summary", style = MaterialTheme.typography.titleMedium)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Text("Saved to ${detail.file.nameWithoutExtension}.transcript.md",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
if (detail.transcript != null || detail.reportText != null) {
|
||||
// Honest labels: "Re-…" only when that output actually exists.
|
||||
val report = detail.reportText
|
||||
val hasTranscript = detail.transcript != null ||
|
||||
(report != null && !report.contains("_No transcript available._"))
|
||||
val hasSummary = detail.summary != null ||
|
||||
(report?.contains("## Summary") == true)
|
||||
val remoteKnown = state.remoteIdFor(detail.file) != null
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
TextButton({ state.transcribe() }) {
|
||||
Text(if (hasTranscript) "Re-transcribe" else "Transcribe")
|
||||
}
|
||||
TextButton({ state.summarize() },
|
||||
enabled = remoteKnown && hasTranscript) {
|
||||
Text(if (hasSummary) "Re-summarize" else "Summarize")
|
||||
}
|
||||
}
|
||||
if (!remoteKnown || !hasTranscript) {
|
||||
Text(if (!hasTranscript)
|
||||
"Summarize unlocks once this recording has a transcript."
|
||||
else
|
||||
"Summarize unlocks once this file is uploaded (press Transcribe).",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@androidx.compose.runtime.Composable
|
||||
private fun ModelRow(
|
||||
state: DesktopState,
|
||||
detail: DesktopState.DetailUi,
|
||||
value: String?,
|
||||
default: String,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
) {
|
||||
val selected = detail.overrideModel == value ||
|
||||
(value == null && detail.overrideModel == null)
|
||||
Card(
|
||||
Modifier.fillMaxWidth().clickable { state.setOverride(value) },
|
||||
) {
|
||||
Row(Modifier.fillMaxWidth().padding(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(if (selected) "◉ " else "○ ")
|
||||
Column {
|
||||
Text(title, style = MaterialTheme.typography.bodyMedium)
|
||||
if (subtitle.isNotBlank()) {
|
||||
Text(subtitle, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- settings -----------------------------------------------------------------
|
||||
|
||||
@androidx.compose.runtime.Composable
|
||||
fun SettingsScreen(state: DesktopState) {
|
||||
val models = state.models.collectAsState().value
|
||||
val modelsError = state.modelsError.collectAsState().value
|
||||
val url by state.serverUrl.collectAsState()
|
||||
|
||||
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
TextButton({ state.go(DesktopState.Screen.LIBRARY) }) { Text("← Library") }
|
||||
Text("Settings", style = MaterialTheme.typography.headlineSmall)
|
||||
}
|
||||
Text("Local engine: $url", style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text("Default transcription model", style = MaterialTheme.typography.titleMedium)
|
||||
Text("Applies to future transcriptions. Each recording keeps the model it used.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
if (models == null) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
CircularProgressIndicator(Modifier.width(20.dp).height(20.dp))
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text("Loading models…")
|
||||
}
|
||||
} else {
|
||||
if (!models.fasterWhisperInstalled) {
|
||||
Text("faster-whisper is not installed on the server " +
|
||||
"(pip install shonar-backend[faster-whisper]).",
|
||||
color = MaterialTheme.colorScheme.error)
|
||||
}
|
||||
models.models.forEach { m ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
(if (m.isDefault) "◉ " else "○ ") + m.displayName,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!m.isDefault) {
|
||||
TextButton({ state.setDefaultModel(m.name) }) {
|
||||
Text("Set default")
|
||||
}
|
||||
}
|
||||
if (!m.downloaded) {
|
||||
TextButton({ state.downloadModel(m.name) }) {
|
||||
Text("Download")
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(m.description, style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("${m.params} • ${m.approxMemory} • ${m.relativeSpeed}" +
|
||||
(if (m.available) " • ready" else " • not downloaded"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
modelsError?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
||||
}
|
||||
}
|
||||
69
app/src/main/kotlin/com/shonar/desktop/Theme.kt
Normal file
69
app/src/main/kotlin/com/shonar/desktop/Theme.kt
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* SHONAR Desktop theme — "warm graphite": near-black charcoal surfaces
|
||||
* with an olive-gold accent, inspired by the Skills Hub hero design.
|
||||
* Desktop-only for now (Android keeps its own ui/theme/Theme.kt).
|
||||
*
|
||||
* Status semantics in the library list map to scheme roles:
|
||||
* done -> primary (gold), failed -> error (red), queued/running ->
|
||||
* onSurfaceVariant with a primary progress bar.
|
||||
*/
|
||||
private val ShonarGold = Color(0xFFE0B23A) // text-safe gold (brighter for AA on cards)
|
||||
private val ShonarGoldBright = Color(0xFFF0C419)
|
||||
private val GoldInk = Color(0xFF1F1800) // text on gold fills (AA)
|
||||
private val GraphiteBg = Color(0xFF1A1A1A)
|
||||
private val GraphiteSurface = Color(0xFF222222)
|
||||
private val GraphiteHigh = Color(0xFF2B2B2B)
|
||||
private val InkPrimary = Color(0xFFF4F4F4)
|
||||
private val InkSecondary = Color(0xFFB3B3B3)
|
||||
private val InkMuted = Color(0xFF8A8A8A)
|
||||
private val OutlineWarm = Color(0xFF3A3A3A)
|
||||
private val DangerWarm = Color(0xFFE5544F)
|
||||
|
||||
private val ShonarDarkScheme = darkColorScheme(
|
||||
primary = ShonarGold,
|
||||
onPrimary = GoldInk,
|
||||
primaryContainer = Color(0xFF3A3216),
|
||||
onPrimaryContainer = ShonarGoldBright,
|
||||
|
||||
secondary = Color(0xFFB99B5E),
|
||||
onSecondary = GoldInk,
|
||||
secondaryContainer = Color(0xFF332C1A),
|
||||
onSecondaryContainer = Color(0xFFE8D9AE),
|
||||
|
||||
tertiary = Color(0xFF8FA6B2), // cool counterpoint (info-ish)
|
||||
onTertiary = Color(0xFF0E1A20),
|
||||
tertiaryContainer = Color(0xFF2A3B45),
|
||||
onTertiaryContainer = Color(0xFFCDE2EC),
|
||||
|
||||
background = GraphiteBg,
|
||||
onBackground = InkPrimary,
|
||||
|
||||
surface = GraphiteSurface,
|
||||
onSurface = InkPrimary,
|
||||
surfaceVariant = Color(0xFF2E2A21),
|
||||
onSurfaceVariant = InkSecondary,
|
||||
surfaceContainer = GraphiteHigh, // cards lift off the bg
|
||||
surfaceContainerHigh = Color(0xFF333333),
|
||||
surfaceContainerHighest = Color(0xFF3A3A3A),
|
||||
|
||||
outline = OutlineWarm,
|
||||
outlineVariant = Color(0xFF2E2E2E),
|
||||
|
||||
error = DangerWarm,
|
||||
onError = Color(0xFF2B0A08),
|
||||
errorContainer = Color(0xFF47201E),
|
||||
onErrorContainer = Color(0xFFFFB4AF),
|
||||
)
|
||||
|
||||
/** Wrap the app content in the SHONAR dark-gold theme. */
|
||||
@Composable
|
||||
fun ShonarTheme(content: @Composable () -> Unit) {
|
||||
MaterialTheme(colorScheme = ShonarDarkScheme, content = content)
|
||||
}
|
||||
101
app/src/main/kotlin/com/shonar/settings/DesktopSettingsStore.kt
Normal file
101
app/src/main/kotlin/com/shonar/settings/DesktopSettingsStore.kt
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package com.shonar.settings
|
||||
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Desktop copy of the settings-store contract. The Android original
|
||||
* (DataStore/Keystore implementations) cannot compile on JVM desktop, so
|
||||
* this file provides the same fully-qualified names backed by plain files:
|
||||
* [SettingsStore], [InMemorySettingsStore], and [FileSettingsStore].
|
||||
*
|
||||
* Secrets note: desktop v1 keeps server tokens in a 0600 JSON file next to
|
||||
* ordinary settings. Good enough for a local single-user app; a keyring
|
||||
* backend can replace [FileSettingsStore] later without touching callers.
|
||||
*/
|
||||
interface SettingsStore {
|
||||
suspend fun getString(key: String): String?
|
||||
suspend fun putString(key: String, value: String)
|
||||
suspend fun remove(key: String)
|
||||
suspend fun keys(): Set<String>
|
||||
|
||||
/** Emits whenever any value changes (for reactive UI). */
|
||||
val changes: StateFlow<Long>
|
||||
}
|
||||
|
||||
/** In-memory store: used by unit tests. */
|
||||
class InMemorySettingsStore : SettingsStore {
|
||||
private val map = mutableMapOf<String, String>()
|
||||
private val changeFlow = MutableStateFlow(0L)
|
||||
override val changes: StateFlow<Long> = changeFlow.asStateFlow()
|
||||
|
||||
override suspend fun getString(key: String): String? = map[key]
|
||||
override suspend fun putString(key: String, value: String) {
|
||||
map[key] = value
|
||||
changeFlow.value += 1
|
||||
}
|
||||
|
||||
override suspend fun remove(key: String) {
|
||||
map.remove(key)
|
||||
changeFlow.value += 1
|
||||
}
|
||||
|
||||
override suspend fun keys(): Set<String> = map.keys.toSet()
|
||||
}
|
||||
|
||||
/** JSON file store (0600). Keys map 1:1 to the JSON object members. */
|
||||
class FileSettingsStore(private val file: File) : SettingsStore {
|
||||
private val mutex = Mutex()
|
||||
private val changeFlow = MutableStateFlow(0L)
|
||||
override val changes: StateFlow<Long> = changeFlow.asStateFlow()
|
||||
|
||||
private fun readAll(): MutableMap<String, String> {
|
||||
if (!file.isFile) return mutableMapOf()
|
||||
return runCatching {
|
||||
val obj = JSONObject(file.readText())
|
||||
buildMap {
|
||||
for (key in obj.keys()) put(key, obj.optString(key, ""))
|
||||
}.toMutableMap()
|
||||
}.getOrDefault(mutableMapOf())
|
||||
}
|
||||
|
||||
private fun writeAll(map: Map<String, String>) {
|
||||
file.parentFile?.mkdirs()
|
||||
file.writeText(JSONObject(map).toString())
|
||||
runCatching {
|
||||
file.setReadable(false, false)
|
||||
file.setReadable(true, true)
|
||||
file.setWritable(false, false)
|
||||
file.setWritable(true, true)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getString(key: String): String? = mutex.withLock {
|
||||
readAll()[key]
|
||||
}
|
||||
|
||||
override suspend fun putString(key: String, value: String) = mutex.withLock {
|
||||
val map = readAll()
|
||||
map[key] = value
|
||||
writeAll(map)
|
||||
changeFlow.value += 1
|
||||
}
|
||||
|
||||
override suspend fun remove(key: String) {
|
||||
mutex.withLock {
|
||||
val map = readAll()
|
||||
map.remove(key)
|
||||
writeAll(map)
|
||||
changeFlow.value += 1
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun keys(): Set<String> = mutex.withLock {
|
||||
readAll().keys.toSet()
|
||||
}
|
||||
}
|
||||
48
app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt
Normal file
48
app/src/test/kotlin/com/shonar/desktop/JobProgressTest.kt
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import com.shonar.recording.JobInfo
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class JobProgressTest {
|
||||
|
||||
private fun job(type: String, status: String, stage: String? = null, progress: Int? = null) =
|
||||
JobInfo(jobType = type, status = status, stage = stage, progress = progress)
|
||||
|
||||
@Test fun `idle when nothing running`() {
|
||||
assertNull(DesktopState.jobProgress(emptyList()))
|
||||
assertNull(DesktopState.jobProgress(listOf(job("transcribe", "succeeded"))))
|
||||
}
|
||||
|
||||
@Test fun `loading model has no percentage`() {
|
||||
val p = DesktopState.jobProgress(
|
||||
listOf(job("transcribe", "running", stage = "loading-model")),
|
||||
)
|
||||
assertEquals("loading model…", p?.label)
|
||||
assertNull(p?.fraction)
|
||||
}
|
||||
|
||||
@Test fun `transcribing shows backend percentage`() {
|
||||
val p = DesktopState.jobProgress(
|
||||
listOf(job("transcribe", "running", stage = "transcribing", progress = 42)),
|
||||
)
|
||||
assertEquals("transcribing… 42%", p?.label)
|
||||
assertEquals(0.42f, p!!.fraction!!, 0.0001f)
|
||||
}
|
||||
|
||||
@Test fun `transcribe failure falls back to indeterminate`() {
|
||||
val p = DesktopState.jobProgress(
|
||||
listOf(job("transcribe", "running", stage = "transcribing")),
|
||||
)
|
||||
assertEquals("transcribing…", p?.label)
|
||||
assertNull(p?.fraction)
|
||||
}
|
||||
|
||||
@Test fun `summarizing stage`() {
|
||||
val p = DesktopState.jobProgress(
|
||||
listOf(job("transcribe", "succeeded"), job("summarize", "running")),
|
||||
)
|
||||
assertEquals("summarizing…", p?.label)
|
||||
}
|
||||
}
|
||||
51
app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt
Normal file
51
app/src/test/kotlin/com/shonar/desktop/LibraryQueueTest.kt
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class LibraryQueueTest {
|
||||
|
||||
private val exts = setOf("m4a", "wav", "mp3")
|
||||
|
||||
@Test fun candidateOnlyForKnownAudioExt() {
|
||||
val dir = createTempDirectory("lq").toFile()
|
||||
val audio = File(dir, "a.m4a").apply { writeText("x") }
|
||||
val note = File(dir, "notes.txt").apply { writeText("x") }
|
||||
val hidden = File(dir, ".hidden.m4a").apply { writeText("x") }
|
||||
assertTrue(LibraryQueue.isAudioCandidate(audio, exts))
|
||||
assertTrue(!LibraryQueue.isAudioCandidate(note, exts))
|
||||
assertTrue(!LibraryQueue.isAudioCandidate(hidden, exts))
|
||||
assertTrue(!LibraryQueue.isAudioCandidate(File(dir, "sub"), exts))
|
||||
}
|
||||
|
||||
@Test fun diffDetectsAddedAndRemoved() {
|
||||
val dir = createTempDirectory("lq").toFile()
|
||||
val a = File(dir, "a.m4a").apply { writeText("x") }
|
||||
val b = File(dir, "b.m4a").apply { writeText("x") }
|
||||
val d = LibraryQueue.diff(setOf("gone.m4a"), listOf(a, b))
|
||||
assertEquals(listOf("a.m4a", "b.m4a"), d.added.map { it.name })
|
||||
assertEquals(setOf("gone.m4a"), d.removed)
|
||||
}
|
||||
|
||||
@Test fun autoQueueSkipsReportsInFlightAndFailed() {
|
||||
val dir = createTempDirectory("lq").toFile()
|
||||
val fresh = File(dir, "fresh.m4a").apply { writeText("x") }
|
||||
val done = File(dir, "done.m4a").apply { writeText("x") }
|
||||
File(dir, "done.transcript.md").writeText("# done")
|
||||
val running = File(dir, "running.m4a").apply { writeText("x") }
|
||||
val broken = File(dir, "broken.m4a").apply { writeText("x") }
|
||||
|
||||
val picked = LibraryQueue.autoQueueCandidates(
|
||||
entries = listOf(fresh, done, running, broken),
|
||||
reportExists = { f ->
|
||||
File(f.parentFile, "${f.nameWithoutExtension}.transcript.md").exists()
|
||||
},
|
||||
inFlightOrDone = setOf("running.m4a"),
|
||||
failed = setOf("broken.m4a"),
|
||||
)
|
||||
assertEquals(listOf("fresh.m4a"), picked.map { it.name })
|
||||
}
|
||||
}
|
||||
59
app/src/test/kotlin/com/shonar/desktop/RemoteMappingTest.kt
Normal file
59
app/src/test/kotlin/com/shonar/desktop/RemoteMappingTest.kt
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RemoteMappingTest {
|
||||
private fun tmpAudio(name: String = "rec.m4a"): File {
|
||||
val dir = createTempDirectory().toFile()
|
||||
val f = File(dir, name)
|
||||
f.writeText("audio")
|
||||
return f
|
||||
}
|
||||
|
||||
@Test fun `round trip save and load`() {
|
||||
val f = tmpAudio()
|
||||
saveMapping(f, RemoteMapping("uuid-1", model = "small", title = "rec"))
|
||||
val m = loadMapping(f)
|
||||
assertEquals("uuid-1", m?.recordingId)
|
||||
assertEquals("small", m?.model)
|
||||
assertEquals("rec", m?.title)
|
||||
}
|
||||
|
||||
@Test fun `load returns null when absent`() {
|
||||
assertNull(loadMapping(tmpAudio()))
|
||||
}
|
||||
|
||||
@Test fun `load returns null on corrupt json`() {
|
||||
val f = tmpAudio()
|
||||
mappingFile(f).writeText("{not json")
|
||||
assertNull(loadMapping(f))
|
||||
}
|
||||
|
||||
@Test fun `load returns null on blank id`() {
|
||||
val f = tmpAudio()
|
||||
mappingFile(f).writeText("""{"recording_id": ""}""")
|
||||
assertNull(loadMapping(f))
|
||||
}
|
||||
|
||||
@Test fun `move follows the audio rename`() {
|
||||
val f = tmpAudio("old.m4a")
|
||||
saveMapping(f, RemoteMapping("uuid-2"))
|
||||
val renamed = File(f.parentFile, "new.m4a")
|
||||
f.renameTo(renamed)
|
||||
assertTrue(moveMapping(f, renamed))
|
||||
assertEquals("uuid-2", loadMapping(renamed)?.recordingId)
|
||||
assertNull(loadMapping(f))
|
||||
}
|
||||
|
||||
@Test fun `move is a no-op without an existing mapping`() {
|
||||
val f = tmpAudio("a.m4a")
|
||||
val g = File(f.parentFile, "b.m4a")
|
||||
assertTrue(!moveMapping(f, g))
|
||||
assertNull(loadMapping(g))
|
||||
}
|
||||
}
|
||||
43
app/src/test/kotlin/com/shonar/desktop/RenameTest.kt
Normal file
43
app/src/test/kotlin/com/shonar/desktop/RenameTest.kt
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import java.io.File
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class RenameTest {
|
||||
|
||||
private fun tmp(): File = createTempDirectory("rn").toFile()
|
||||
|
||||
@Test fun `plain rename keeps extension`() {
|
||||
val dir = tmp()
|
||||
val f = File(dir, "Recording 20260803.m4a").apply { writeText("x") }
|
||||
val target = LibraryQueue.renameTarget(f, "standup intro").getOrThrow()
|
||||
assertEquals(File(dir, "standup intro.m4a"), target)
|
||||
}
|
||||
|
||||
@Test fun `typed extension is stripped`() {
|
||||
val dir = tmp()
|
||||
val f = File(dir, "a.m4a").apply { writeText("x") }
|
||||
assertEquals(File(dir, "b.m4a"), LibraryQueue.renameTarget(f, "b.M4A").getOrThrow())
|
||||
assertEquals(File(dir, "b.m4a"), LibraryQueue.renameTarget(f, "b.m4a").getOrThrow())
|
||||
}
|
||||
|
||||
@Test fun `rejects empty path and dot names`() {
|
||||
val dir = tmp()
|
||||
val f = File(dir, "a.m4a").apply { writeText("x") }
|
||||
for (bad in listOf("", " ", "x/y", "x\\y", ".", "..", ".hidden")) {
|
||||
val r = LibraryQueue.renameTarget(f, bad)
|
||||
assertTrue("should reject '$bad'", r.isFailure)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun `rejects collisions and no-op renames`() {
|
||||
val dir = tmp()
|
||||
val f = File(dir, "a.m4a").apply { writeText("x") }
|
||||
File(dir, "taken.m4a").writeText("y")
|
||||
assertTrue(LibraryQueue.renameTarget(f, "taken").isFailure)
|
||||
assertTrue(LibraryQueue.renameTarget(f, "a").isFailure)
|
||||
}
|
||||
}
|
||||
60
app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt
Normal file
60
app/src/test/kotlin/com/shonar/desktop/ReportsTest.kt
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package com.shonar.desktop
|
||||
|
||||
import com.shonar.recording.SummaryData
|
||||
import com.shonar.recording.TranscriptData
|
||||
import com.shonar.recording.TranscriptSegment
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ReportsTest {
|
||||
|
||||
@Test fun renderTranscriptWithTimestamps() {
|
||||
val t = TranscriptData(
|
||||
version = 1, provider = "faster_whisper", model = "base",
|
||||
text = "hello world",
|
||||
segments = listOf(
|
||||
TranscriptSegment(0.0, 1.5, "hello"),
|
||||
TranscriptSegment(65.0, 67.0, "world"),
|
||||
),
|
||||
)
|
||||
val md = renderReport("Evening notes", t, null)
|
||||
assertTrue(md.startsWith("# Evening notes"))
|
||||
assertTrue("[00:00:00] hello" in md)
|
||||
assertTrue("[00:01:05] world" in md)
|
||||
assertTrue("faster_whisper, v1" in md)
|
||||
}
|
||||
|
||||
@Test fun renderEmptyTranscript() {
|
||||
val md = renderReport("x", null, null)
|
||||
assertTrue("_No transcript available._" in md)
|
||||
}
|
||||
|
||||
@Test fun fmtTsShape() {
|
||||
assertEquals("00:00:00", fmtTs(0.0))
|
||||
assertEquals("00:01:05", fmtTs(65.0))
|
||||
assertEquals("01:02:03", fmtTs(3723.0))
|
||||
}
|
||||
|
||||
@Test fun reportFileName() {
|
||||
val dir = Files.createTempDirectory("rep").toFile()
|
||||
try {
|
||||
val audio = File(dir, "chat.m4a")
|
||||
assertEquals("chat.transcript.md", DesktopState.reportFile(audio).name)
|
||||
} finally {
|
||||
dir.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun sharedParsersWorkOnDesktop() {
|
||||
val raw = """{"version":2,"provider":"user","text":"hi","segments":[],
|
||||
"edited_by_user":true}"""
|
||||
val t = com.shonar.recording.parseTranscript(raw)!!
|
||||
assertEquals(2, t.version)
|
||||
assertTrue(t.editedByUser)
|
||||
val summary = SummaryData(version = 1, content = mapOf("short" to "s"))
|
||||
assertEquals("s", summary.short)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue