同步移动端工程、设备控制改进与发布资料
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+283
@@ -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("确定") } })
|
||||
}
|
||||
Reference in New Issue
Block a user