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:
avi 2026-09-14 17:14:54 -05:00
commit 76c867fca4
136 changed files with 21099 additions and 0 deletions

99
app/build.gradle.kts Normal file
View 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()
}

File diff suppressed because it is too large Load diff

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

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

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

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

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

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

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

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

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

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

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

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