Add Android animation library browsing

This commit is contained in:
2026-10-10 14:51:16 +08:00
parent 5db283c05a
commit c9dd03c23b
39 changed files with 1015 additions and 46 deletions
@@ -1,5 +1,16 @@
package org.qimiaoscreen.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTransformGestures
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@@ -66,12 +77,13 @@ fun QimiaoApp(controller: Controller, onScan: () -> Unit, decode: (String) -> Im
} } }
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)
1 -> if (state.identity == null) Text("请先在连接页连接设备") else Frame(state.frame, state.frameCheckedAtMs, decode, controller::refresh, !state.busy, { controller.viewImage() })
2 -> ContentPage(state, controller, decode)
3 -> SettingsPage(state, controller)
}
}
}
state.viewer?.let { ImageViewer(state, controller, decode) }
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("关闭") } })
@@ -159,7 +171,7 @@ private fun ConnectionPage(state: ControllerState, c: Controller, scan: () -> Un
}
@Composable
private fun Frame(encoded: String?, checkedAt: Long?, decode: (String) -> ImageBitmap?, refresh: () -> Unit, enabled: Boolean) {
private fun Frame(encoded: String?, checkedAt: Long?, decode: (String) -> ImageBitmap?, refresh: () -> Unit, enabled: Boolean, open: () -> Unit = {}) {
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) } }
@@ -172,7 +184,7 @@ private fun Frame(encoded: String?, checkedAt: Long?, decode: (String) -> ImageB
TextButton(refresh, Modifier.align(Alignment.CenterEnd).testTag("frame_refresh")
.onSizeChanged { refreshWidth = with(density) { it.width.toDp() } }, enabled = enabled) { Text("手动刷新") }
}
if (bitmap != null) Image(bitmap, "设备当前输出帧", Modifier.size(160.dp).testTag("frame_image")) else Text("等待画面", textAlign = TextAlign.Center)
if (bitmap != null) Image(bitmap, "设备当前输出帧", Modifier.size(160.dp).testTag("frame_image").clickable(onClick = open)) else Text("等待画面", textAlign = TextAlign.Center)
if (checkedAt != null && now - checkedAt > 6_000) Text("画面可能已过期,请刷新", style = MaterialTheme.typography.bodySmall, textAlign = TextAlign.Center)
} }
}
@@ -182,8 +194,9 @@ private fun ContentPage(state: ControllerState, c: Controller, decode: (String)
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) }
val listState = rememberLazyListState()
LazyColumn(state = listState, verticalArrangement = Arrangement.spacedBy(12.dp)) {
item { Frame(state.frame, state.frameCheckedAtMs, decode, c::refresh, !state.busy, { c.viewImage() }) }
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) }
@@ -191,9 +204,11 @@ private fun ContentPage(state: ControllerState, c: Controller, decode: (String)
val key = thumbnailKey(item)
val encoded = state.thumbnails[key] ?: state.previews[key]
LaunchedEffect(key, encoded) { c.thumbnail(item) }
val image = remember(encoded) { encoded?.let(decode) }
val visible = listState.layoutInfo.visibleItemsInfo.any { it.key == item.text("type") + item.text("id") }
val image = ContentBitmap(item, encoded, state.animationClips[key], c, decode, state.imagesForeground && visible && state.viewer == null)
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")) }
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { if (image != null) Image(image, item.text("name"), Modifier.size(64.dp).testTag("library_image_$key").clickable { c.viewImage(item) }); Text(item.text("name")) }
if (image == null) TextButton({ c.viewImage(item) }) { Text("查看图片") }
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)) {
@@ -205,6 +220,67 @@ private fun ContentPage(state: ControllerState, c: Controller, decode: (String)
}
}
@Composable
private fun ContentBitmap(item: JsonObject, fallback: String?, clip: AnimationClip?, c: Controller,
decode: (String) -> ImageBitmap?, playing: Boolean): ImageBitmap? {
var bitmap by remember(thumbnailKey(item)) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(fallback, clip, playing, thumbnailKey(item)) {
if (clip == null || !playing) {
bitmap = withContext(Dispatchers.Default) { fallback?.let(decode) }
} else {
var index = 0
while (true) {
val started = kotlin.time.TimeSource.Monotonic.markNow()
val encoded = c.animationFrame(item, clip, index)
val frame = withContext(Dispatchers.Default) { encoded?.let(decode) }
if (frame != null) bitmap = frame
delay((clip.durations[index] - started.elapsedNow().inWholeMilliseconds).coerceAtLeast(1))
index = (index + 1) % clip.durations.size
}
}
}
return bitmap
}
@Composable
private fun ImageViewer(state: ControllerState, c: Controller, decode: (String) -> ImageBitmap?) {
val item = state.viewer ?: return
val isFrame = item.text("type") == "frame"
val key = if (isFrame) "frame" else thumbnailKey(item)
val image = if (isFrame) {
var bitmap by remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(state.frame) { bitmap = withContext(Dispatchers.Default) { state.frame?.let(decode) } }
bitmap
} else ContentBitmap(item, state.thumbnails[key] ?: state.previews[key], state.animationClips[key], c, decode, state.imagesForeground)
var scale by remember(key) { mutableFloatStateOf(1f) }
var x by remember(key) { mutableFloatStateOf(0f) }
var y by remember(key) { mutableFloatStateOf(0f) }
Dialog(onDismissRequest = c::closeImage, properties = DialogProperties(usePlatformDefaultWidth = false)) {
Surface(Modifier.fillMaxSize().testTag("image_viewer")) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(item.text("name"), Modifier.weight(1f))
TextButton(c::closeImage, Modifier.testTag("viewer_close")) { Text("关闭") }
}
Box(Modifier.weight(1f).fillMaxWidth().clipToBounds().pointerInput(key) {
detectTransformGestures { _, pan, zoom, _ ->
scale = (scale * zoom).coerceIn(1f, 8f)
x = if (scale == 1f) 0f else (x + pan.x).coerceIn(-size.width * (scale - 1) / 2, size.width * (scale - 1) / 2)
y = if (scale == 1f) 0f else (y + pan.y).coerceIn(-size.height * (scale - 1) / 2, size.height * (scale - 1) / 2)
}
}, contentAlignment = Alignment.Center) {
if (image != null) Image(image, item.text("name"), Modifier.fillMaxSize().testTag("viewer_image")
.graphicsLayer(scaleX = scale, scaleY = scale, translationX = x, translationY = y), contentScale = ContentScale.Fit)
else Text("等待图像")
}
if (!isFrame && item.text("type") == "animation" && !c.supports("library_animation")) Text("设备暂不支持动画预览")
state.viewerProgress?.let { Text(it, Modifier.testTag("viewer_progress")) }
state.imageErrors[key]?.let { Text(it); TextButton({ c.viewImage(item) }) { Text("重试") } }
}
}
}
}
@Composable
private fun Playback(value: JsonObject, c: Controller, enabled: Boolean) {
val paused = value["paused"]?.jsonPrimitive?.booleanOrNull == true