同步移动端工程、设备控制改进与发布资料

This commit is contained in:
2026-09-26 19:21:12 +08:00
parent 912ba432cf
commit 98eadc5c8b
137 changed files with 9274 additions and 85 deletions
@@ -0,0 +1,24 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kmp)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler)
}
kotlin {
android {
namespace = "org.qimiaoscreen.ui"
compileSdk = 37
minSdk = 26
}
sourceSets {
commonMain.dependencies {
implementation(project(":sharedCore"))
implementation(compose.runtime)
implementation(compose.foundation)
implementation(compose.material3)
implementation(compose.ui)
implementation(libs.coroutines.core)
implementation(libs.serialization.json)
}
}
}
@@ -0,0 +1,283 @@
package org.qimiaoscreen.ui
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import kotlinx.serialization.json.*
import org.qimiaoscreen.core.*
import kotlinx.coroutines.delay
import kotlin.time.Clock
private fun JsonObject.text(key: String, fallback: String = "—") = get(key)?.jsonPrimitive?.contentOrNull ?: fallback
data class AppInfo(val version: String, val build: String, val date: String)
@Composable
fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> ImageBitmap?,
appInfo: AppInfo = AppInfo("—", "—", "—")) {
val state by controller.state.collectAsState()
var menu by remember { mutableStateOf(false) }
var dialog by remember { mutableStateOf<String?>(null) }
MaterialTheme {
Scaffold(bottomBar = { NavigationBar {
listOf("连接", "设备状态", "显示内容", "设备设置").forEachIndexed { index, name ->
NavigationBarItem(state.page == index, { controller.page(index) },
icon = { Text(listOf("◉", "▣", "▦", "⚙")[index]) }, label = { Text(name) })
}
} }) { padding ->
Column(Modifier.fillMaxSize().padding(padding).padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
val identity = state.identity
Text(if (identity == null) "当前连接设备:未连接${if (state.phase == "正在连接") " · 连接中" else ""}"
else "当前连接设备:${identity.text("device_name")}(${identity.text("short_id")}) · ${wifiSummary(state)}",
Modifier.weight(1f), style = MaterialTheme.typography.bodySmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
Box {
TextButton({ menu = true }) { Text("⋮") }
DropdownMenu(menu, { menu = false }) {
DropdownMenuItem(text = { Text("关于") }, onClick = { menu = false; dialog = "about" })
DropdownMenuItem(text = { Text("软件设置") }, onClick = { menu = false; dialog = "settings" })
}
}
}
if (state.busy) LinearProgressIndicator(Modifier.fillMaxWidth())
state.error?.let { error -> Card { Row(Modifier.padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
Text(error, Modifier.weight(1f)); TextButton(controller::dismissError) { Text("知道了") }
} } }
when (state.page) {
0 -> ConnectionPage(state, controller, onScan)
1 -> if (state.identity == null) Text("请先在连接页连接设备") else Frame(state.frame, state.frameCheckedAtMs, decode, controller::refresh, !state.busy)
2 -> ContentPage(state, controller, decode)
3 -> SettingsPage(state, controller)
}
}
}
if (dialog == "about") AlertDialog(onDismissRequest = { dialog = null }, title = { Text("关于奇妙小屏幕") },
text = { Text("版本 ${appInfo.version}\n构建编号 ${appInfo.build}\n打包日期 ${appInfo.date}\n通过蓝牙控制核桃派小屏幕") },
confirmButton = { TextButton({ dialog = null }) { Text("关闭") } })
if (dialog == "settings") {
var name by remember { mutableStateOf(state.clientName) }
AlertDialog(onDismissRequest = { dialog = null }, title = { Text("软件设置") },
text = { OutlinedTextField(name, { name = it.take(40) }, label = { Text("当前手机昵称") }, singleLine = true) },
confirmButton = { TextButton({ controller.renamePhone(name); if (name.trim().isNotEmpty()) dialog = null }, enabled = !state.busy) { Text("保存") } },
dismissButton = { TextButton({ dialog = null }) { Text("取消") } })
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun ConnectionPage(state: ControllerState, c: Controller, scan: () -> Unit) {
var selected by remember { mutableStateOf<RememberedDevice?>(null) }
var renaming by remember { mutableStateOf(false) }
var name by remember { mutableStateOf("") }
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
item { Text("已保存的设备", style = MaterialTheme.typography.titleMedium) }
if (state.remembered.isEmpty()) item { Text("暂无已保存设备,扫描后选择即可连接") }
items(state.remembered, key = { it.id }) { device ->
Card(Modifier.fillMaxWidth().combinedClickable(onClick = { c.connectRemembered(device) }, onLongClick = { selected = device; renaming = false })) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) { Text(device.name); Text("代号 ${device.shortId}", style = MaterialTheme.typography.bodySmall) }
Text(if (state.identity?.text("device_id") == device.id) "已连接" else "点击连接")
}
}
}
item { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(scan, enabled = !state.scanning && state.phase != "正在连接") { Text(if (state.scanning) "扫描中…" else "扫描设备") }
if (state.identity != null) OutlinedButton(c::disconnect) { Text("断开连接") }
} }
item { Text("扫描到的设备", style = MaterialTheme.typography.titleMedium) }
items(state.devices, key = { it.handle }) { device -> Card(Modifier.fillMaxWidth()) {
Row(Modifier.padding(12.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(device.name)
Text("信号 ${device.signal} dBm${if (c.rememberedFor(device) != null) " · 已记忆" else ""}", style = MaterialTheme.typography.bodySmall)
}
Button({ c.connect(device, c.rememberedFor(device)?.id) }, enabled = state.phase != "正在连接") { Text("连接") }
}
} }
if (state.identity != null) item { Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("当前连接信息", style = MaterialTheme.typography.titleMedium)
Text("蓝牙信号:${state.signal?.let { "$it dBm" } ?: "暂不可用"}")
Text("设备WiFi:${wifiSummary(state)}")
} } }
}
selected?.let { device ->
if (!renaming) AlertDialog(onDismissRequest = { selected = null }, title = { Text(device.name) },
text = { Text("代号 ${device.shortId}") },
confirmButton = { TextButton({
if (state.identity?.text("device_id") != device.id) { c.reportError("请连接设备"); selected = null }
else { name = device.name; renaming = true }
}) { Text("重命名设备") } },
dismissButton = { Row { TextButton({ c.forget(device.id); selected = null }) { Text("取消记忆") }; TextButton({ selected = null }) { Text("关闭") } } })
else AlertDialog(onDismissRequest = { selected = null }, title = { Text("重命名设备") },
text = { OutlinedTextField(name, { name = it.take(40) }, label = { Text("设备昵称") }, singleLine = true) },
confirmButton = { TextButton({ c.renameDevice(name, device.id); selected = null }, enabled = name.isNotBlank() && !state.busy) { Text("保存") } },
dismissButton = { TextButton({ selected = null }) { Text("取消") } })
}
}
@Composable
private fun Frame(encoded: String?, checkedAt: Long?, decode: (String) -> ImageBitmap?, refresh: () -> Unit, enabled: Boolean) {
val bitmap = remember(encoded) { encoded?.let(decode) }
var now by remember { mutableLongStateOf(Clock.System.now().toEpochMilliseconds()) }
LaunchedEffect(checkedAt) { while (true) { now = Clock.System.now().toEpochMilliseconds(); delay(1_000) } }
Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text("设备画面", Modifier.weight(1f), style = MaterialTheme.typography.titleMedium)
TextButton(refresh, enabled = enabled) { Text("手动刷新") }
}
if (bitmap != null) Image(bitmap, "设备当前输出帧", Modifier.size(160.dp)) else Text("等待画面")
if (checkedAt != null && now - checkedAt > 6_000) Text("画面可能已过期,请刷新", style = MaterialTheme.typography.bodySmall)
} }
}
@Composable
private fun ContentPage(state: ControllerState, c: Controller, decode: (String) -> ImageBitmap?) {
if (state.identity == null) { Text("请先在连接页连接小屏幕"); return }
if (!c.supports("library")) { Text("此设备版本不支持内容控制"); return }
val playback = state.status?.get("state")?.jsonObject?.get("animation_playback")?.jsonObject
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
item { Frame(state.frame, state.frameCheckedAtMs, decode, c::refresh, !state.busy) }
item { Text("开机默认:${state.defaultContent?.text("name") ?: "正在读取…"}") }
if (playback?.get("active")?.jsonPrimitive?.booleanOrNull == true) item { Playback(playback, c, !state.busy) }
item { Text("可播放内容", style = MaterialTheme.typography.titleLarge) }
items(state.library, key = { it.text("type") + it.text("id") }) { item ->
val key = thumbnailKey(item)
LaunchedEffect(key) { c.thumbnail(item) }
val encoded = state.thumbnails[key]
val image = remember(encoded) { encoded?.let(decode) }
Card(Modifier.fillMaxWidth()) { Column(Modifier.padding(12.dp)) {
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (image != null) Image(image, item.text("name"), Modifier.size(64.dp)); Text(item.text("name")) }
Text((if (item.text("type") == "animation") "动图" else "静态模板") + if (item.text("is_demo") == "true") " · 演示内容" else "")
val enabled = !state.busy && item["playable"]?.jsonPrimitive?.booleanOrNull == true
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button({ c.play(item) }, enabled = enabled) { Text("播放") }
OutlinedButton({ c.play(item, true) }, enabled = enabled) { Text("设为默认并播放") }
}
} }
}
}
}
@Composable
private fun Playback(value: JsonObject, c: Controller, enabled: Boolean) {
val paused = value["paused"]?.jsonPrimitive?.booleanOrNull == true
val position = value["position_ms"]?.jsonPrimitive?.longOrNull ?: 0L
val total = (value["total_duration_ms"]?.jsonPrimitive?.longOrNull ?: 1L).coerceAtLeast(1)
var seek by remember(position) { mutableFloatStateOf(position.toFloat()) }
Text("动图进度 ${position / 1000.0} / ${total / 1000.0} 秒")
Button({ c.playback(buildJsonObject { put("paused", !paused) }) }, enabled = enabled) { Text(if (paused) "继续" else "暂停") }
Slider(seek.coerceIn(0f, total.toFloat()), { seek = it }, enabled = enabled, valueRange = 0f..total.toFloat(), onValueChangeFinished = { c.playback(buildJsonObject { put("position_ms", seek.toLong()) }) })
Choices("播放倍速", value.text("speed"), value["supported_speeds"]?.jsonArray?.map { it.jsonPrimitive.content } ?: emptyList(), enabled) {
c.playback(buildJsonObject { put("speed", it.toDouble()) })
}
}
@Composable
private fun SettingsPage(state: ControllerState, c: Controller) {
if (state.identity == null) { Text("请先在连接页连接设备"); return }
val values = state.settings?.get("values")?.jsonObject
LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) {
if (values != null) {
item {
var brightness by remember(values.text("brightness")) { mutableFloatStateOf(values.text("brightness", "40").toFloat()) }
Text("亮度 ${brightness.toInt()}%")
Slider(brightness, { brightness = it }, enabled = !state.busy, valueRange = 1f..100f,
onValueChangeFinished = { c.setting("brightness", JsonPrimitive(brightness.toInt())) })
}
item { Text("屏幕方向"); Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
listOf("0" to "正常", "90" to "向右90°", "180" to "倒置", "270" to "向左90°").forEach { (value, label) ->
FilterChip(values.text("orientation") == value, { c.setting("orientation", JsonPrimitive(value.toInt())) }, label = { Text(label) }, enabled = !state.busy)
}
} }
} else item { Text(if (c.supports("settings")) "正在读取设置…" else "设备不支持系统设置") }
if (c.supports("wifi")) item { WifiForm(state, c) }
if (values != null) item {
var seconds by remember(values.text("prompt_delay_seconds")) { mutableStateOf(values.text("prompt_delay_seconds")) }
OutlinedTextField(seconds, { seconds = it }, label = { Text("WiFi提示秒数") }, singleLine = true)
TextButton({ seconds.toIntOrNull()?.let { c.setting("prompt_delay_seconds", JsonPrimitive(it)) } }, enabled = !state.busy && seconds.toIntOrNull() != null) { Text("保存提示秒数") }
}
}
}
@Composable
private fun Choices(label: String, selected: String, options: List<String>, enabled: Boolean, select: (String) -> Unit) {
Text(label)
Row(Modifier.horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
options.forEach { value -> FilterChip(selected == value, { select(value) }, label = { Text(value) }, enabled = enabled) }
}
}
@Composable
private fun WifiForm(state: ControllerState, c: Controller) {
val saved = state.wifi?.get("saved")?.takeIf { it != JsonNull }?.jsonObject
val draft = state.wifiDraft ?: WifiDraft(saved?.text("ssid", "") ?: "", saved?.text("security", "wpa-psk") ?: "wpa-psk",
saved?.text("ipv4_mode") == "manual", saved?.text("address", "") ?: "", saved?.text("prefix", "24") ?: "24",
saved?.text("gateway", "") ?: "", saved?.get("dns_servers")?.jsonArray?.joinToString(",") { it.jsonPrimitive.content } ?: "")
var expanded by remember { mutableStateOf(false) }
var networks by remember { mutableStateOf(false) }
var password by remember { mutableStateOf("") }
var showPassword by remember { mutableStateOf(false) }
var confirm by remember { mutableStateOf(false) }
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
HorizontalDivider(); Text("WiFi网络", style = MaterialTheme.typography.titleMedium)
Text("当前WiFi:${wifiSummary(state)}")
TextButton({ expanded = !expanded; if (!expanded) { password = ""; showPassword = false } }) { Text("WiFi设置 ${if (expanded) "收起" else "展开"}") }
if (expanded) {
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(draft.ssid, { c.wifiDraft(draft.copy(ssid = it)) }, Modifier.weight(1f), label = { Text("WiFi名称") }, singleLine = true, enabled = !state.busy)
if (c.supports("wifi_scan")) TextButton({ networks = true; c.scanWifi() }, enabled = !state.busy) { Text("扫描WiFi") }
}
if (networks) {
if (state.networks.isEmpty() && !state.busy) Text("未发现WiFi,可手动输入名称")
state.networks.forEach { network -> TextButton({
c.wifiDraft(draft.copy(ssid = network.text("ssid"), security = network.text("security"))); password = ""; networks = false
}, enabled = !state.busy && network.text("security") != "unsupported") {
Text("${network.text("ssid")} · ${network.text("signal_percent")}%${if (network.text("security") == "unsupported") " · 暂不支持" else ""}")
} }
}
Choices("网络类型", if (draft.security == "open") "开放网络" else "密码网络", listOf("密码网络", "开放网络"), !state.busy) {
c.wifiDraft(draft.copy(security = if (it == "开放网络") "open" else "wpa-psk")); password = ""
}
if (draft.security != "open") {
val configured = saved?.get("password_configured")?.jsonPrimitive?.booleanOrNull == true && saved.text("ssid", "") == draft.ssid
OutlinedTextField(password, { password = it }, Modifier.fillMaxWidth(), label = { Text("WiFi密码") },
placeholder = { Text(if (configured) "已设置密码,留空保留" else "请输入密码") },
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), singleLine = true, enabled = !state.busy)
Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(showPassword, { showPassword = it }); Text("显示输入的密码") }
}
Row(verticalAlignment = Alignment.CenterVertically) { Text("自动获取IP(DHCP)", Modifier.weight(1f)); Switch(!draft.manual, { c.wifiDraft(draft.copy(manual = !it)) }, enabled = !state.busy) }
if (draft.manual) {
OutlinedTextField(draft.address, { c.wifiDraft(draft.copy(address = it)) }, label = { Text("静态IP地址") }, singleLine = true, enabled = !state.busy)
OutlinedTextField(draft.prefix, { c.wifiDraft(draft.copy(prefix = it)) }, label = { Text("前缀长度(例如24)") }, singleLine = true, enabled = !state.busy)
OutlinedTextField(draft.gateway, { c.wifiDraft(draft.copy(gateway = it)) }, label = { Text("网关") }, singleLine = true, enabled = !state.busy)
OutlinedTextField(draft.dns, { c.wifiDraft(draft.copy(dns = it)) }, label = { Text("DNS(多个用逗号分隔)") }, singleLine = true, enabled = !state.busy)
}
Button({ confirm = true }, enabled = !state.busy && draft.ssid.isNotBlank() && state.wifi != null) { Text("保存WiFi设置") }
}
}
if (confirm) AlertDialog(onDismissRequest = { confirm = false }, title = { Text("立即生效WiFi设置") },
text = { Text("WiFi设置将立即生效,是否确定?手机仍可通过蓝牙控制设备。") },
dismissButton = { TextButton({ confirm = false }) { Text("取消") } },
confirmButton = { TextButton({
confirm = false
c.saveWifi(buildJsonObject {
put("ssid", draft.ssid); put("security", draft.security)
put("password_action", if (draft.security == "open") "none" else if (password.isEmpty()) "keep" else "replace")
if (password.isNotEmpty() && draft.security != "open") put("password", password)
put("ipv4_mode", if (draft.manual) "manual" else "dhcp")
if (draft.manual) { put("address", draft.address); put("prefix", draft.prefix.toIntOrNull() ?: 0); put("gateway", draft.gateway) }
put("dns_servers", JsonArray(if (draft.manual) draft.dns.split(',').map { it.trim() }.filter { it.isNotEmpty() }.map { JsonPrimitive(it) } else emptyList()))
put("activation", "immediate")
}); password = ""; showPassword = false
}) { Text("确定") } })
}