Add Android animation library browsing
This commit is contained in:
+83
-7
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user