Code review:

- Use the app's cached stringRes helper instead of raw stringResource,
  matching the dominant convention in ui/note/types
- Drive the icon animation from the Compose frame clock (withFrameMillis)
  instead of a delay loop, so the ticker suspends whenever the composition
  stops drawing rather than waking the main dispatcher 4x/sec from the
  back stack
- Drop the unconsumed memoryCardId/blockState/blockHash accessors; the
  tag schema stays documented in the class KDoc
- Document the frames arrays as frozen: mutating them in place would
    silently break the @Immutable skip contract; build a new instance
    to change pixels
- Replace the API-29-deprecated Bitmap.createBitmap(IntArray, ...)
    overload with createBitmap(w, h, config) + setPixels
This commit is contained in:
davotoula
2026-07-06 00:35:58 +02:00
parent c0cc2b0245
commit 836caa5cd6
4 changed files with 18 additions and 22 deletions
@@ -37,22 +37,22 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameMillis
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.FilterQuality
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Size24dp
import com.vitorpamplona.amethyst.ui.theme.placeholderText
import com.vitorpamplona.amethyst.ui.theme.replyModifier
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveEvent
import com.vitorpamplona.quartz.experimental.ps1saves.Ps1SaveIcon
import kotlinx.coroutines.delay
/** How many hex characters of the block to preview (the full block is ~16K chars). */
private const val HEX_PREVIEW_CHARS = 192
@@ -102,7 +102,7 @@ fun RenderPs1Save(baseNote: Note) {
Spacer(Modifier.width(8.dp))
}
Text(
text = (if (save.icon == null) FLOPPY_PREFIX else "") + (save.title ?: stringResource(R.string.ps1_save_title)),
text = (if (save.icon == null) FLOPPY_PREFIX else "") + (save.title ?: stringRes(R.string.ps1_save_title)),
style = MaterialTheme.typography.titleMedium,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
@@ -113,7 +113,7 @@ fun RenderPs1Save(baseNote: Note) {
listOfNotNull(
save.filename,
save.region,
save.blockNumber?.let { stringResource(R.string.ps1_save_block, it) },
save.blockNumber?.let { stringRes(R.string.ps1_save_block, it) },
).joinToString(" · ")
if (details.isNotEmpty()) {
@@ -151,7 +151,8 @@ private fun Ps1SaveIconImage(icon: Ps1SaveIcon) {
remember(icon) {
icon.frames.map { pixels ->
Bitmap
.createBitmap(pixels, Ps1SaveIcon.SIZE, Ps1SaveIcon.SIZE, Bitmap.Config.ARGB_8888)
.createBitmap(Ps1SaveIcon.SIZE, Ps1SaveIcon.SIZE, Bitmap.Config.ARGB_8888)
.apply { setPixels(pixels, 0, Ps1SaveIcon.SIZE, 0, 0, Ps1SaveIcon.SIZE, Ps1SaveIcon.SIZE) }
.asImageBitmap()
}
}
@@ -159,10 +160,14 @@ private fun Ps1SaveIconImage(icon: Ps1SaveIcon) {
var frameIndex by remember(icon) { mutableIntStateOf(0) }
if (frames.size > 1) {
// Driven by the frame clock (not delay) so the ticker suspends whenever
// the composition stops drawing (backgrounded, behind another screen).
LaunchedEffect(icon) {
val start = withFrameMillis { it }
while (true) {
delay(ICON_FRAME_MILLIS)
frameIndex = (frameIndex + 1) % frames.size
withFrameMillis { now ->
frameIndex = (((now - start) / ICON_FRAME_MILLIS) % frames.size).toInt()
}
}
}
}
@@ -71,18 +71,9 @@ class Ps1SaveEvent(
/** Game region, from the `region` tag (may be null). */
fun region() = tags.firstTagValue("region")
/** Identifier of the memory card this block belongs to, from the `m` tag (may be null). */
fun memoryCardId() = tags.firstTagValue("m")
/** Block number within the card, from the `block` tag (null when missing or not a number). */
fun blockNumber() = tags.firstTagValue("block")?.toIntOrNull()
/** Position of this block in a multi-block save chain, from the `state` tag (may be null). */
fun blockState() = tags.firstTagValue("state")
/** SHA-256 of the block data, from the `x` tag (may be null). */
fun blockHash() = tags.firstTagValue("x")
/** Publisher-provided human-readable summary, from the NIP-31 `alt` tag (may be null). */
fun summary() = tags.alt()
@@ -35,12 +35,15 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
* (bits 0-4 red, 5-9 green, 10-14 blue); raw `0x0000` is transparent.
* - `0x80` the frames, each 16×16 pixels at 4bpp (128 bytes), low nibble
* first; pixel values index the CLUT.
*
* [frames] holds one 256-entry row-major ARGB pixel array per animation frame,
* ready to blit into a bitmap.
*/
@Immutable
class Ps1SaveIcon(
/**
* One 256-entry row-major ARGB pixel array per animation frame. Treat as
* frozen: the arrays back the class's `@Immutable` contract, so mutating
* them in place would not recompose build a new [Ps1SaveIcon] to change
* pixels.
*/
val frames: List<IntArray>,
) {
companion object {
@@ -72,10 +72,7 @@ class Ps1SaveEventTest {
assertEquals("SYNCED OVER NOST Card", event.saveTitle())
assertEquals("BASCUS-00001SOFTCARD", event.filename())
assertEquals("America", event.region())
assertEquals("synctest-f1fc95d1", event.memoryCardId())
assertEquals(1, event.blockNumber())
assertEquals("first", event.blockState())
assertEquals("4235e8c1d7164927b78dd50e0a12acbea04b2d80f0864fc3298e7d84225d8f41", event.blockHash())
assertEquals("PS1 save 'SYNCED OVER NOST Card' (BASCUS-00001SOFTCARD)", event.summary())
assertEquals("synctest-f1fc95d1-1", event.dTag())
}