craft-native 0.0.140 → 0.0.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,9 +6,16 @@ import android.content.Context
6
6
  import android.content.res.ColorStateList
7
7
  import android.content.res.Configuration
8
8
  import android.content.Intent
9
+ import android.media.AudioAttributes
10
+ import android.media.AudioFormat
11
+ import android.media.AudioTrack
9
12
  import android.graphics.BitmapFactory
13
+ import android.graphics.Canvas
10
14
  import android.graphics.Color
15
+ import android.graphics.DashPathEffect
16
+ import android.graphics.Matrix
11
17
  import android.graphics.Paint
18
+ import android.graphics.Path
12
19
  import android.graphics.Typeface
13
20
  import android.graphics.drawable.Drawable
14
21
  import android.graphics.drawable.GradientDrawable
@@ -28,11 +35,13 @@ import android.util.Base64
28
35
  import android.util.TypedValue
29
36
  import android.view.Gravity
30
37
  import android.view.KeyEvent
38
+ import android.view.MotionEvent
31
39
  import android.view.inputmethod.EditorInfo
32
40
  import android.text.InputType
33
41
  import android.text.Layout
34
42
  import android.view.View
35
43
  import android.view.ViewGroup
44
+ import android.view.ViewTreeObserver
36
45
  import android.widget.AdapterView
37
46
  import android.widget.ArrayAdapter
38
47
  import android.widget.Button
@@ -46,6 +55,8 @@ import android.widget.SeekBar
46
55
  import android.widget.Spinner
47
56
  import android.widget.Switch
48
57
  import android.widget.TextView
58
+ import android.widget.MediaController
59
+ import android.widget.VideoView
49
60
  import androidx.appcompat.app.AppCompatActivity
50
61
  import androidx.appcompat.app.AppCompatDelegate
51
62
  import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -61,6 +72,7 @@ import androidx.javascriptengine.Message
61
72
  import androidx.javascriptengine.MessagePort
62
73
  import androidx.javascriptengine.MessagePortClient
63
74
  import com.google.common.util.concurrent.ListenableFuture
75
+ import com.craft.runtime.CraftNative
64
76
  import org.json.JSONArray
65
77
  import org.json.JSONObject
66
78
  import java.net.URL
@@ -69,7 +81,10 @@ import java.util.concurrent.Future
69
81
  import java.util.UUID
70
82
  import kotlin.math.max
71
83
  import kotlin.math.min
84
+ import kotlin.math.PI
72
85
  import kotlin.math.roundToInt
86
+ import kotlin.math.roundToLong
87
+ import kotlin.math.sin
73
88
 
74
89
  private const val CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS = 30_000L
75
90
 
@@ -154,6 +169,214 @@ private fun craftNativeGridColumnCount(value: Any?): Int {
154
169
  return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
155
170
  }
156
171
 
172
+ /** A DOM-free SVG surface for the geometry emitted by the shared STX native protocol. */
173
+ private class CraftNativeSvgView(context: Context) : View(context) {
174
+ var drawing: JSONObject = JSONObject()
175
+ set(value) {
176
+ field = value
177
+ invalidate()
178
+ requestLayout()
179
+ }
180
+
181
+ private data class SvgPaint(
182
+ val fill: String? = "#000000",
183
+ val stroke: String? = null,
184
+ val strokeWidth: Float = 1f,
185
+ val color: String = "#000000",
186
+ val opacity: Float = 1f,
187
+ val typeface: Typeface = Typeface.DEFAULT,
188
+ ) {
189
+ fun merge(node: JSONObject): SvgPaint {
190
+ val props = node.optJSONObject("props") ?: JSONObject()
191
+ val style = node.optJSONObject("style") ?: JSONObject()
192
+ fun value(name: String): String? = when {
193
+ style.has(name) && !style.isNull(name) -> style.optString(name)
194
+ props.has(name) && !props.isNull(name) -> props.optString(name)
195
+ else -> null
196
+ }
197
+ val weight = value("fontWeight")
198
+ return copy(
199
+ fill = value("fill") ?: fill,
200
+ stroke = value("stroke") ?: stroke,
201
+ strokeWidth = value("strokeWidth")?.toFloatOrNull() ?: strokeWidth,
202
+ color = value("color") ?: color,
203
+ opacity = (value("opacity")?.toFloatOrNull() ?: 1f) * opacity,
204
+ typeface = if (weight in setOf("bold", "700")) Typeface.DEFAULT_BOLD else typeface,
205
+ )
206
+ }
207
+ }
208
+
209
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
210
+ val width = MeasureSpec.getSize(widthMeasureSpec)
211
+ val box = viewBox()
212
+ val naturalHeight = if (box != null && box.second > 0) (width * box.second / box.first).roundToInt() else 0
213
+ val height = when (MeasureSpec.getMode(heightMeasureSpec)) {
214
+ MeasureSpec.EXACTLY -> MeasureSpec.getSize(heightMeasureSpec)
215
+ MeasureSpec.AT_MOST -> min(naturalHeight, MeasureSpec.getSize(heightMeasureSpec))
216
+ else -> naturalHeight
217
+ }
218
+ setMeasuredDimension(resolveSize(width, widthMeasureSpec), resolveSize(height, heightMeasureSpec))
219
+ }
220
+
221
+ /** viewBox units to the view's pixels, set by each draw. */
222
+ private val viewTransform = Matrix()
223
+ private val inverseTransform = Matrix()
224
+
225
+ override fun onDraw(canvas: Canvas) {
226
+ super.onDraw(canvas)
227
+ val box = viewBox() ?: return
228
+ if (box.first <= 0 || box.second <= 0) return
229
+ // Centred at one scale (SVG's default, `xMidYMid meet`), or each axis
230
+ // stretched to fill (`preserveAspectRatio="none"`).
231
+ val stretch = drawing.optJSONObject("props")?.optString("preserveAspectRatio")?.trim() == "none"
232
+ var sx = width.toFloat() / box.first
233
+ var sy = height.toFloat() / box.second
234
+ var dx = 0f
235
+ var dy = 0f
236
+ if (!stretch) {
237
+ val scale = min(sx, sy)
238
+ sx = scale
239
+ sy = scale
240
+ dx = (width - box.first * scale) / 2f
241
+ dy = (height - box.second * scale) / 2f
242
+ }
243
+ if (!sx.isFinite() || !sy.isFinite() || sx <= 0f || sy <= 0f) return
244
+ viewTransform.reset()
245
+ viewTransform.postTranslate(-box.third, -box.fourth)
246
+ viewTransform.postScale(sx, sy)
247
+ viewTransform.postTranslate(dx, dy)
248
+ viewTransform.invert(inverseTransform)
249
+ canvas.save()
250
+ canvas.concat(viewTransform)
251
+ drawNodes(canvas, drawing.optJSONArray("children") ?: JSONArray(), SvgPaint().merge(drawing))
252
+ canvas.restore()
253
+ }
254
+
255
+ private fun viewBox(): Quadruple<Float, Float, Float, Float>? {
256
+ val values = drawing.optJSONObject("props")?.optJSONArray("viewBox") ?: return null
257
+ if (values.length() != 4) return null
258
+ val x = values.optDouble(0, Double.NaN).toFloat()
259
+ val y = values.optDouble(1, Double.NaN).toFloat()
260
+ val width = values.optDouble(2, Double.NaN).toFloat()
261
+ val height = values.optDouble(3, Double.NaN).toFloat()
262
+ if (!x.isFinite() || !y.isFinite() || !width.isFinite() || !height.isFinite()) return null
263
+ return Quadruple(width, height, x, y)
264
+ }
265
+
266
+ private fun drawNodes(canvas: Canvas, children: JSONArray, inherited: SvgPaint) {
267
+ for (index in 0 until children.length()) {
268
+ val node = children.optJSONObject(index) ?: continue
269
+ val style = node.optJSONObject("style") ?: JSONObject()
270
+ if (style.optString("display") == "none") continue
271
+ val paint = inherited.merge(node)
272
+ when (node.optString("type")) {
273
+ "G" -> drawNodes(canvas, node.optJSONArray("children") ?: JSONArray(), paint)
274
+ "SvgText" -> drawText(canvas, node, paint)
275
+ else -> drawPath(canvas, node, paint)
276
+ }
277
+ }
278
+ }
279
+
280
+ private fun drawPath(canvas: Canvas, node: JSONObject, style: SvgPaint) {
281
+ val props = node.optJSONObject("props") ?: JSONObject()
282
+ val geometry = props.optJSONArray("geometry") ?: return
283
+ val path = Path()
284
+ var index = 0
285
+ while (index < geometry.length()) {
286
+ val command = geometry.optDouble(index, Double.NaN).toInt()
287
+ fun number(offset: Int): Float = geometry.optDouble(index + offset, Double.NaN).toFloat()
288
+ when (command) {
289
+ 0 -> if (index + 2 < geometry.length()) { path.moveTo(number(1), number(2)); index += 3 } else break
290
+ 1 -> if (index + 2 < geometry.length()) { path.lineTo(number(1), number(2)); index += 3 } else break
291
+ 2 -> if (index + 6 < geometry.length()) { path.cubicTo(number(1), number(2), number(3), number(4), number(5), number(6)); index += 7 } else break
292
+ 3 -> if (index + 4 < geometry.length()) { path.quadTo(number(1), number(2), number(3), number(4)); index += 5 } else break
293
+ 4 -> { path.close(); index += 1 }
294
+ else -> break
295
+ }
296
+ }
297
+ val fill = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
298
+ color = resolveColor(style.fill, style.color, Color.TRANSPARENT)
299
+ alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
300
+ this.style = Paint.Style.FILL
301
+ }
302
+ if (style.fill != "none" && style.fill != "transparent") canvas.drawPath(path, fill)
303
+ if (!style.stroke.isNullOrBlank() && style.stroke != "none" && style.stroke != "transparent" && style.strokeWidth > 0) {
304
+ val stroke = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
305
+ color = resolveColor(style.stroke, style.color, Color.TRANSPARENT)
306
+ alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
307
+ this.style = Paint.Style.STROKE
308
+ strokeWidth = style.strokeWidth
309
+ strokeCap = when (props.optString("strokeLinecap")) {
310
+ "round" -> Paint.Cap.ROUND
311
+ "square" -> Paint.Cap.SQUARE
312
+ else -> Paint.Cap.BUTT
313
+ }
314
+ strokeJoin = when (props.optString("strokeLinejoin")) {
315
+ "round" -> Paint.Join.ROUND
316
+ "bevel" -> Paint.Join.BEVEL
317
+ else -> Paint.Join.MITER
318
+ }
319
+ props.optJSONArray("strokeDasharray")?.let { values ->
320
+ val dashes: List<Float> = (0 until values.length()).mapNotNull {
321
+ values.optDouble(it, Double.NaN).takeUnless { value -> value.isNaN() }?.toFloat()
322
+ }
323
+ if (dashes.isNotEmpty()) {
324
+ val dashPattern = dashes.toFloatArray()
325
+ val pattern: FloatArray = if (dashPattern.size % 2 == 1) {
326
+ FloatArray(dashPattern.size * 2) { index -> dashPattern[index % dashPattern.size] }
327
+ } else {
328
+ dashPattern
329
+ }
330
+ pathEffect = DashPathEffect(pattern, 0f)
331
+ }
332
+ }
333
+ }
334
+ // `vector-effect="non-scaling-stroke"`: the stroke is in the view's
335
+ // pixels, so a stretched chart's line keeps its width.
336
+ if (props.optString("vectorEffect") == "non-scaling-stroke") {
337
+ val line = Path(path).apply { transform(viewTransform) }
338
+ canvas.save()
339
+ canvas.concat(inverseTransform)
340
+ canvas.drawPath(line, stroke)
341
+ canvas.restore()
342
+ } else {
343
+ canvas.drawPath(path, stroke)
344
+ }
345
+ }
346
+ }
347
+
348
+ private fun drawText(canvas: Canvas, node: JSONObject, style: SvgPaint) {
349
+ val props = node.optJSONObject("props") ?: JSONObject()
350
+ val nodeStyle = node.optJSONObject("style") ?: JSONObject()
351
+ val text = buildString {
352
+ val children = node.optJSONArray("children") ?: JSONArray()
353
+ for (index in 0 until children.length()) if (children.opt(index) is String) append(children.optString(index))
354
+ }
355
+ if (text.isEmpty()) return
356
+ val size = (if (props.has("fontSize")) props.optDouble("fontSize", 12.0) else nodeStyle.optDouble("fontSize", 12.0))
357
+ .toFloat()
358
+ val paint = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
359
+ color = resolveColor(style.fill, style.color, Color.BLACK)
360
+ alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
361
+ textSize = size
362
+ typeface = style.typeface
363
+ }
364
+ var x = props.optDouble("x", 0.0).toFloat()
365
+ val y = props.optDouble("y", 0.0).toFloat()
366
+ when (props.optString("textAnchor")) {
367
+ "middle" -> x -= paint.measureText(text) / 2f
368
+ "end" -> x -= paint.measureText(text)
369
+ }
370
+ canvas.drawText(text, x, y, paint)
371
+ }
372
+
373
+ private fun resolveColor(value: String?, current: String, fallback: Int): Int = runCatching {
374
+ Color.parseColor(if (value == "currentColor") current else value ?: current)
375
+ }.getOrDefault(fallback)
376
+
377
+ private data class Quadruple<T1, T2, T3, T4>(val first: T1, val second: T2, val third: T3, val fourth: T4)
378
+ }
379
+
157
380
  /** A deterministic flex-shaped container shared by normal views and scroll content. */
158
381
  private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
159
382
  var onLayoutChanged: (() -> Unit)? = null
@@ -167,9 +390,11 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
167
390
  private var grid = false
168
391
  private var gridColumns = 1
169
392
  private var gridAutoRows: Int? = null
393
+ private var reverse = false
170
394
 
171
395
  fun configure(style: JSONObject, density: Float) {
172
396
  orientation = if (style.optString("flexDirection") in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
397
+ reverse = style.optString("flexDirection").endsWith("-reverse")
173
398
  wrap = style.optString("flexWrap") == "wrap" || style.optBoolean("flexWrap", false)
174
399
  alignItems = style.optString("alignItems", "stretch")
175
400
  alignContent = style.optString("alignContent", "flex-start")
@@ -214,7 +439,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
214
439
  val gap = if (orientation == HORIZONTAL) columnGap else rowGap
215
440
  val lines = mutableListOf<Line>()
216
441
  val absolute = mutableListOf<Entry>()
217
- var line = Line()
442
+ val pending = mutableListOf<Entry>()
218
443
  for (index in 0 until childCount) {
219
444
  val child = getChildAt(index)
220
445
  if (child.visibility == GONE) continue
@@ -236,18 +461,43 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
236
461
  val childHeight = measureChildHeight(child, style, height - paddingTop - paddingBottom, verticalMargins)
237
462
  val entry = Entry(child, style, childWidth, childHeight)
238
463
  if (style.position == "absolute") { absolute += entry; continue }
239
- val childMain = (if (orientation == HORIZONTAL) childWidth + horizontalMargins else childHeight + verticalMargins)
240
- val childCross = (if (orientation == HORIZONTAL) childHeight + verticalMargins else childWidth + horizontalMargins)
241
- val next = if (line.entries.isEmpty()) childMain else line.main + gap + childMain
242
- if (wrap && line.entries.isNotEmpty() && next > availableMain) {
243
- lines += line
244
- line = Line()
245
- }
246
- line.entries += entry
247
- line.main = if (line.entries.size == 1) childMain else line.main + gap + childMain
248
- line.cross = max(line.cross, childCross)
249
- }
250
- if (line.entries.isNotEmpty()) lines += line
464
+ pending += entry
465
+ }
466
+ if (pending.isNotEmpty()) {
467
+ val basis = pending.map { entry ->
468
+ if (orientation == HORIZONTAL) entry.width.toFloat() else entry.height.toFloat()
469
+ }.toFloatArray()
470
+ val margins = pending.flatMap { entry ->
471
+ val lp = entry.view.layoutParams as? MarginLayoutParams
472
+ if (orientation == HORIZONTAL) listOf((lp?.leftMargin ?: 0).toFloat(), (lp?.rightMargin ?: 0).toFloat())
473
+ else listOf((lp?.topMargin ?: 0).toFloat(), (lp?.bottomMargin ?: 0).toFloat())
474
+ }.toFloatArray()
475
+ val sharedLineIds = CraftNative.flexLines(availableMain.toFloat(), gap.toFloat(), wrap, basis, margins)
476
+ val lineIds = sharedLineIds?.takeIf { ids ->
477
+ ids.size >= pending.size && ids.firstOrNull() == 0 &&
478
+ ids.indices.drop(1).all { index -> ids[index] >= ids[index - 1] }
479
+ }
480
+ var line = Line()
481
+ pending.forEachIndexed { index, entry ->
482
+ val childMain = if (orientation == HORIZONTAL) entry.width + (margins[index * 2] + margins[index * 2 + 1]).roundToInt()
483
+ else entry.height + (margins[index * 2] + margins[index * 2 + 1]).roundToInt()
484
+ val lp = entry.view.layoutParams as? MarginLayoutParams
485
+ val crossMargins = if (orientation == HORIZONTAL) (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
486
+ else (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
487
+ val childCross = if (orientation == HORIZONTAL) entry.height + crossMargins else entry.width + crossMargins
488
+ if (index > 0 && lineIds != null && lineIds[index] != lineIds[index - 1]) {
489
+ lines += line
490
+ line = Line()
491
+ } else if (index > 0 && lineIds == null && wrap && line.entries.isNotEmpty() && line.main + gap + childMain > availableMain) {
492
+ lines += line
493
+ line = Line()
494
+ }
495
+ line.entries += entry
496
+ line.main = if (line.entries.size == 1) childMain else line.main + gap + childMain
497
+ line.cross = max(line.cross, childCross)
498
+ }
499
+ if (line.entries.isNotEmpty()) lines += line
500
+ }
251
501
  return lines to absolute
252
502
  }
253
503
 
@@ -361,6 +611,24 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
361
611
  else -> 0.0
362
612
  }
363
613
 
614
+ private fun justifyCode(): Int = when (justifyContent) {
615
+ "center" -> 1
616
+ "flex-end" -> 2
617
+ "space-between" -> 3
618
+ "space-around" -> 4
619
+ "space-evenly" -> 5
620
+ else -> 0
621
+ }
622
+
623
+ private fun alignContentCode(): Int = when (alignContent) {
624
+ "center" -> 1
625
+ "flex-end" -> 2
626
+ "space-between" -> 3
627
+ "space-around" -> 4
628
+ "stretch" -> 5
629
+ else -> 0
630
+ }
631
+
364
632
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
365
633
  val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)
366
634
  val width = View.MeasureSpec.getSize(widthMeasureSpec).takeUnless { widthMode == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
@@ -396,36 +664,66 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
396
664
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
397
665
  if (grid) {
398
666
  val layout = gridLayout(width, height)
399
- var rowTop = paddingTop
400
- for ((rowIndex, row) in layout.rows.withIndex()) {
401
- val rowHeight = layout.rowHeights[rowIndex]
402
- for ((column, entry) in row.withIndex()) {
403
- val align = entry.style.alignSelf ?: alignItems
404
- val lp = entry.view.layoutParams as? MarginLayoutParams
405
- val marginLeft = lp?.leftMargin ?: 0
406
- val marginRight = lp?.rightMargin ?: 0
407
- val marginTop = lp?.topMargin ?: 0
408
- val marginBottom = lp?.bottomMargin ?: 0
409
- val availableWidth = max(0, layout.cellWidth - marginLeft - marginRight)
410
- val availableHeight = max(0, rowHeight - marginTop - marginBottom)
411
- val childWidth = if (align == "stretch" && entry.style.width == null) availableWidth else min(availableWidth, entry.width)
412
- val childHeight = if (align == "stretch" && entry.style.height == null) availableHeight else min(availableHeight, entry.height)
413
- val x = paddingLeft + column * (layout.cellWidth + columnGap) + marginLeft + when (align) {
414
- "center" -> (availableWidth - childWidth) / 2
415
- "flex-end" -> availableWidth - childWidth
416
- else -> 0
417
- }
418
- val y = rowTop + marginTop + when (align) {
419
- "center" -> (availableHeight - childHeight) / 2
420
- "flex-end" -> availableHeight - childHeight
421
- else -> 0
422
- }
667
+ val contentWidth = width - paddingLeft - paddingRight
668
+ val contentHeight = height - paddingTop - paddingBottom
669
+ val flow = layout.rows.flatten()
670
+ val sharedFrames = CraftNative.grid(
671
+ contentWidth.toFloat(), contentHeight.toFloat(), gridColumns, columnGap.toFloat(), rowGap.toFloat(),
672
+ flow.map { it.width.toFloat() }.toFloatArray(), flow.map { it.height.toFloat() }.toFloatArray(),
673
+ flow.map { it.style.width?.resolve(layout.cellWidth)?.toFloat() ?: Float.NaN }.toFloatArray(),
674
+ flow.map { it.style.height?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
675
+ flow.map { it.style.minWidth?.resolve(layout.cellWidth)?.toFloat() ?: Float.NaN }.toFloatArray(),
676
+ flow.map { it.style.maxWidth?.resolve(layout.cellWidth)?.toFloat() ?: Float.NaN }.toFloatArray(),
677
+ flow.map { it.style.minHeight?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
678
+ flow.map { it.style.maxHeight?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
679
+ flow.flatMap {
680
+ val lp = it.view.layoutParams as? MarginLayoutParams
681
+ listOf((lp?.leftMargin ?: 0).toFloat(), (lp?.rightMargin ?: 0).toFloat(), (lp?.topMargin ?: 0).toFloat(), (lp?.bottomMargin ?: 0).toFloat())
682
+ }.toFloatArray(),
683
+ flow.flatMap {
684
+ val align = it.style.alignSelf ?: alignItems
685
+ listOf(if (align == "stretch") 1f else 0f, if (align == "center") 1f else if (align == "flex-end") 2f else 0f)
686
+ }.toFloatArray(),
687
+ )
688
+ if (sharedFrames != null) {
689
+ for ((index, entry) in flow.withIndex()) {
690
+ val offset = index * 4
691
+ val x = paddingLeft + sharedFrames[offset].roundToInt()
692
+ val y = paddingTop + sharedFrames[offset + 1].roundToInt()
693
+ val childWidth = sharedFrames[offset + 2].roundToInt()
694
+ val childHeight = sharedFrames[offset + 3].roundToInt()
423
695
  entry.view.layout(x, y, x + childWidth, y + childHeight)
424
696
  }
425
- rowTop += rowHeight + rowGap
697
+ } else {
698
+ var rowTop = paddingTop
699
+ for ((rowIndex, row) in layout.rows.withIndex()) {
700
+ val rowHeight = layout.rowHeights[rowIndex]
701
+ for ((column, entry) in row.withIndex()) {
702
+ val align = entry.style.alignSelf ?: alignItems
703
+ val lp = entry.view.layoutParams as? MarginLayoutParams
704
+ val marginLeft = lp?.leftMargin ?: 0
705
+ val marginRight = lp?.rightMargin ?: 0
706
+ val marginTop = lp?.topMargin ?: 0
707
+ val marginBottom = lp?.bottomMargin ?: 0
708
+ val availableWidth = max(0, layout.cellWidth - marginLeft - marginRight)
709
+ val availableHeight = max(0, rowHeight - marginTop - marginBottom)
710
+ val childWidth = if (align == "stretch" && entry.style.width == null) availableWidth else min(availableWidth, entry.width)
711
+ val childHeight = if (align == "stretch" && entry.style.height == null) availableHeight else min(availableHeight, entry.height)
712
+ val x = paddingLeft + column * (layout.cellWidth + columnGap) + marginLeft + when (align) {
713
+ "center" -> (availableWidth - childWidth) / 2
714
+ "flex-end" -> availableWidth - childWidth
715
+ else -> 0
716
+ }
717
+ val y = rowTop + marginTop + when (align) {
718
+ "center" -> (availableHeight - childHeight) / 2
719
+ "flex-end" -> availableHeight - childHeight
720
+ else -> 0
721
+ }
722
+ entry.view.layout(x, y, x + childWidth, y + childHeight)
723
+ }
724
+ rowTop += rowHeight + rowGap
725
+ }
426
726
  }
427
- val contentWidth = width - paddingLeft - paddingRight
428
- val contentHeight = height - paddingTop - paddingBottom
429
727
  for (entry in layout.absolute) {
430
728
  if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
431
729
  val childWidth = if (entry.style.left != null && entry.style.right != null) contentWidth - entry.style.left!! - entry.style.right!! else entry.width
@@ -448,8 +746,14 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
448
746
  val crossFree = max(0, availableCross - totalCross)
449
747
  var crossLeading = 0
450
748
  var crossBetween = crossGap
749
+ val sharedCross = if (wrap && lines.size > 1) {
750
+ CraftNative.flexLineDistribution(
751
+ availableCross.toFloat(), crossGap.toFloat(), alignContentCode(), lines.map { it.cross.toFloat() }.toFloatArray()
752
+ )
753
+ } else null
754
+ sharedCross?.second?.forEachIndexed { index, size -> lines[index].cross = size.roundToInt() }
451
755
  if (wrap && lines.size > 1) {
452
- when (alignContent) {
756
+ if (sharedCross == null) when (alignContent) {
453
757
  "center" -> crossLeading = crossFree / 2
454
758
  "flex-end" -> crossLeading = crossFree
455
759
  "space-between" -> crossBetween = crossFree / (lines.size - 1) + crossGap
@@ -464,23 +768,55 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
464
768
  }
465
769
  }
466
770
  var crossOffset = (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + crossLeading
467
- for (line in lines) {
771
+ for ((lineIndex, line) in lines.withIndex()) {
772
+ val lineOffset = sharedCross?.first?.get(lineIndex)?.roundToInt()?.let { (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + it } ?: crossOffset
468
773
  val rawFree = availableMain - line.main
469
- val factors = line.entries.map { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink ?: defaultFlexShrink(it.view) }
470
- val totalFactor = factors.sum()
471
- val distributedMain = line.entries.indices.sumOf { index ->
472
- val base = if (orientation == HORIZONTAL) line.entries[index].width else line.entries[index].height
473
- val primary = if (totalFactor > 0) max(0, (base + rawFree * factors[index] / totalFactor).toInt()) else base
474
- val constrained = if (orientation == HORIZONTAL) {
475
- clamp(primary, line.entries[index].style.minWidth?.resolve(contentWidth), line.entries[index].style.maxWidth?.resolve(contentWidth))
476
- } else {
477
- clamp(primary, line.entries[index].style.minHeight?.resolve(contentHeight), line.entries[index].style.maxHeight?.resolve(contentHeight))
478
- }
479
- val lp = line.entries[index].view.layoutParams as? MarginLayoutParams
480
- val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
774
+ val sharedPrimary = CraftNative.flexRow(
775
+ availableMain.toFloat(),
776
+ gap.toFloat(),
777
+ justifyCode(),
778
+ line.entries.map { entry -> if (orientation == HORIZONTAL) entry.width.toFloat() else entry.height.toFloat() }.toFloatArray(),
779
+ line.entries.map { it.style.flexGrow.toFloat() }.toFloatArray(),
780
+ line.entries.map { (it.style.flexShrink ?: defaultFlexShrink(it.view)).toFloat() }.toFloatArray(),
781
+ line.entries.map {
782
+ (if (orientation == HORIZONTAL) it.style.minWidth?.resolve(contentWidth) else it.style.minHeight?.resolve(contentHeight))?.toFloat()
783
+ ?: Float.NaN
784
+ }.toFloatArray(),
785
+ line.entries.map {
786
+ (if (orientation == HORIZONTAL) it.style.maxWidth?.resolve(contentWidth) else it.style.maxHeight?.resolve(contentHeight))?.toFloat()
787
+ ?: Float.NaN
788
+ }.toFloatArray(),
789
+ line.entries.flatMap { entry ->
790
+ val lp = entry.view.layoutParams as? MarginLayoutParams
791
+ if (orientation == HORIZONTAL) listOf((lp?.leftMargin ?: 0).toFloat(), (lp?.rightMargin ?: 0).toFloat())
792
+ else listOf((lp?.topMargin ?: 0).toFloat(), (lp?.bottomMargin ?: 0).toFloat())
793
+ }.toFloatArray(),
794
+ )
795
+ val sharedPositions = sharedPrimary?.takeIf { it.size == line.entries.size * 2 }
796
+ val distributedMain = if (sharedPrimary != null) {
797
+ line.entries.indices.sumOf { index ->
798
+ val lp = line.entries[index].view.layoutParams as? MarginLayoutParams
799
+ val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
481
800
  else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
482
- constrained + margins
483
- } + max(0, line.entries.size - 1) * gap
801
+ sharedPrimary[index].roundToInt() + margins
802
+ } + max(0, line.entries.size - 1) * gap
803
+ } else {
804
+ val factors = line.entries.map { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink ?: defaultFlexShrink(it.view) }
805
+ val totalFactor = factors.sum()
806
+ line.entries.indices.sumOf { index ->
807
+ val base = if (orientation == HORIZONTAL) line.entries[index].width else line.entries[index].height
808
+ val primary = if (totalFactor > 0) max(0, (base + rawFree * factors[index] / totalFactor).toInt()) else base
809
+ val constrained = if (orientation == HORIZONTAL) {
810
+ clamp(primary, line.entries[index].style.minWidth?.resolve(contentWidth), line.entries[index].style.maxWidth?.resolve(contentWidth))
811
+ } else {
812
+ clamp(primary, line.entries[index].style.minHeight?.resolve(contentHeight), line.entries[index].style.maxHeight?.resolve(contentHeight))
813
+ }
814
+ val lp = line.entries[index].view.layoutParams as? MarginLayoutParams
815
+ val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
816
+ else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
817
+ constrained + margins
818
+ } + max(0, line.entries.size - 1) * gap
819
+ }
484
820
  val free = max(0, availableMain - distributedMain)
485
821
  val (leading, between) = distribution(free, line.entries.size)
486
822
  var mainOffset = if (orientation == HORIZONTAL) paddingLeft else paddingTop
@@ -495,27 +831,43 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
495
831
  val align = entry.style.alignSelf ?: alignItems
496
832
  val crossLeading = if (orientation == HORIZONTAL) lp?.topMargin ?: 0 else lp?.leftMargin ?: 0
497
833
  val crossTrailing = if (orientation == HORIZONTAL) lp?.bottomMargin ?: 0 else lp?.rightMargin ?: 0
498
- val crossAvailable = max(0, line.cross - crossLeading - crossTrailing)
499
- val crossPosition = when (align) {
500
- "center" -> crossLeading + (crossAvailable - crossSize) / 2
501
- "flex-end" -> line.cross - crossTrailing - crossSize
834
+ val alignment = when (align) {
835
+ "center" -> 1
836
+ "flex-end" -> 2
837
+ else -> 0
838
+ }
839
+ val crossPosition = CraftNative.crossAxisPosition(
840
+ line.cross.toFloat(), crossSize.toFloat(), crossLeading.toFloat(), crossTrailing.toFloat(), alignment
841
+ )?.roundToInt() ?: when (alignment) {
842
+ 1 -> crossLeading + (line.cross - crossLeading - crossTrailing - crossSize) / 2
843
+ 2 -> line.cross - crossTrailing - crossSize
502
844
  else -> crossLeading
503
845
  }
504
- val x = if (orientation == HORIZONTAL) mainOffset + marginStart else crossOffset + crossPosition
505
- val y = if (orientation == HORIZONTAL) crossOffset + crossPosition else mainOffset + marginStart
506
- val primary = if (orientation == HORIZONTAL) entry.width else entry.height
507
- val distributed = if (totalFactor > 0) max(0, (primary + rawFree * factors[index] / totalFactor).toInt()) else primary
508
- val distributedPrimary = if (orientation == HORIZONTAL) {
509
- clamp(distributed, entry.style.minWidth?.resolve(contentWidth), entry.style.maxWidth?.resolve(contentWidth))
510
- } else {
511
- clamp(distributed, entry.style.minHeight?.resolve(contentHeight), entry.style.maxHeight?.resolve(contentHeight))
846
+ // Shared positions cover every non-reverse justification; the
847
+ // local main-axis rule remains the fallback for old runtimes
848
+ // that return widths only.
849
+ val sharedPosition = if (!reverse) sharedPositions?.get(index)?.roundToInt() else null
850
+ val positionedMain = sharedPosition?.let { (if (orientation == HORIZONTAL) paddingLeft else paddingTop) + it }
851
+ ?: mainOffset + marginStart
852
+ val x = if (orientation == HORIZONTAL) positionedMain else lineOffset + crossPosition
853
+ val y = if (orientation == HORIZONTAL) lineOffset + crossPosition else positionedMain
854
+ val distributedPrimary = sharedPrimary?.get(index)?.roundToInt() ?: run {
855
+ val primary = if (orientation == HORIZONTAL) entry.width else entry.height
856
+ val factor = if (rawFree >= 0) entry.style.flexGrow else entry.style.flexShrink ?: defaultFlexShrink(entry.view)
857
+ val totalFactor = line.entries.sumOf { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink ?: defaultFlexShrink(it.view) }
858
+ val distributed = if (totalFactor > 0) max(0, (primary + rawFree * factor / totalFactor).toInt()) else primary
859
+ if (orientation == HORIZONTAL) {
860
+ clamp(distributed, entry.style.minWidth?.resolve(contentWidth), entry.style.maxWidth?.resolve(contentWidth))
861
+ } else {
862
+ clamp(distributed, entry.style.minHeight?.resolve(contentHeight), entry.style.maxHeight?.resolve(contentHeight))
863
+ }
512
864
  }
513
865
  val w = if (orientation == HORIZONTAL) distributedPrimary else crossSize
514
866
  val h = if (orientation == HORIZONTAL) crossSize else distributedPrimary
515
867
  entry.view.layout(x, y, x + w, y + h)
516
- mainOffset += distributedPrimary + marginStart + marginEnd + gap + between
868
+ if (sharedPosition == null) mainOffset += distributedPrimary + marginStart + marginEnd + gap + between
517
869
  }
518
- crossOffset += line.cross + crossBetween
870
+ if (sharedCross == null) crossOffset += line.cross + crossBetween
519
871
  }
520
872
  for (entry in absolute) {
521
873
  if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
@@ -736,6 +1088,7 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
736
1088
  val content = CraftNativeFlexLayout(context)
737
1089
  private val blocker = View(context)
738
1090
  private var renderedVisible = false
1091
+ private var dismissSuppressedByRequest = false
739
1092
  private var animationGeneration = 0
740
1093
  var onRequestClose: (() -> Unit)? = null
741
1094
 
@@ -748,13 +1101,38 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
748
1101
  init {
749
1102
  isClickable = true
750
1103
  blocker.isClickable = true
751
- blocker.setOnTouchListener { _, _ -> true }
1104
+ blocker.setOnTouchListener { _, event ->
1105
+ if (event.action == MotionEvent.ACTION_UP) requestClose()
1106
+ true
1107
+ }
752
1108
  blocker.setBackgroundColor(Color.argb(82, 0, 0, 0))
753
1109
  addView(blocker, LayoutParams(-1, -1))
754
1110
  addView(content, LayoutParams(-1, -1))
755
1111
  }
756
1112
 
1113
+ fun requestClose() {
1114
+ val handler = onRequestClose ?: return
1115
+ dismissSuppressedByRequest = true
1116
+ isClickable = false
1117
+ blocker.isEnabled = false
1118
+ handler()
1119
+ }
1120
+
1121
+ fun consumeDismissSuppression(): Boolean {
1122
+ val suppressed = dismissSuppressedByRequest
1123
+ dismissSuppressedByRequest = false
1124
+ return suppressed
1125
+ }
1126
+
1127
+ fun clearDismissSuppression() {
1128
+ dismissSuppressedByRequest = false
1129
+ }
1130
+
757
1131
  fun setVisible(visible: Boolean, opacity: Float, animationType: String?) {
1132
+ if (visible) {
1133
+ isClickable = true
1134
+ blocker.isEnabled = true
1135
+ }
758
1136
  if (renderedVisible == visible && visibility == if (visible) View.VISIBLE else View.GONE) {
759
1137
  if (visible && alpha != opacity) alpha = opacity
760
1138
  return
@@ -806,6 +1184,9 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
806
1184
  visibility = View.GONE
807
1185
  translationY = 0f
808
1186
  alpha = 0f
1187
+ isClickable = true
1188
+ blocker.isEnabled = true
1189
+ dismissSuppressedByRequest = false
809
1190
  onRequestClose = null
810
1191
  }
811
1192
  }
@@ -817,6 +1198,232 @@ private data class LayoutBounds(
817
1198
  val height: Double,
818
1199
  )
819
1200
 
1201
+ /**
1202
+ * Android's default spinner style is indeterminate-only, so assigning
1203
+ * `isIndeterminate = false` to it is silently ignored. Use a determinate
1204
+ * ProgressBar style and carry over the platform spinner drawable instead;
1205
+ * that preserves the native look while allowing `animating={false}` to be a
1206
+ * genuinely stopped control.
1207
+ */
1208
+ private class CraftNativeActivityIndicator(context: Context) :
1209
+ android.widget.ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal) {
1210
+ init {
1211
+ val spinner = android.widget.ProgressBar(context).indeterminateDrawable
1212
+ indeterminateDrawable = spinner
1213
+ progressDrawable = spinner?.constantState?.newDrawable()?.mutate() ?: spinner
1214
+ }
1215
+ }
1216
+
1217
+ private class CraftNativeVideoView(context: Context) : VideoView(context) {
1218
+ private var sourceUri = ""
1219
+ private var shouldLoop = false
1220
+ private var shouldPlay = false
1221
+ private var muted = false
1222
+
1223
+ fun configure(source: Any?, autoplay: Boolean, paused: Boolean, loop: Boolean, muted: Boolean, controls: Boolean) {
1224
+ val uri = when (source) {
1225
+ is String -> source
1226
+ is JSONObject -> source.optString("uri")
1227
+ else -> ""
1228
+ }
1229
+ shouldLoop = loop
1230
+ shouldPlay = autoplay && !paused
1231
+ this.muted = muted
1232
+ setMediaController(if (controls) MediaController(context).also { it.setAnchorView(this) } else null)
1233
+ if (uri != sourceUri) {
1234
+ sourceUri = uri
1235
+ stopPlayback()
1236
+ if (uri.isBlank()) return
1237
+ setVideoURI(Uri.parse(uri))
1238
+ setOnPreparedListener { mediaPlayer ->
1239
+ mediaPlayer.isLooping = shouldLoop
1240
+ val volume = if (this.muted) 0f else 1f
1241
+ mediaPlayer.setVolume(volume, volume)
1242
+ if (shouldPlay) start()
1243
+ }
1244
+ setOnCompletionListener { if (shouldLoop && !isPlaying) start() }
1245
+ }
1246
+ if (uri.isBlank()) return
1247
+ runCatching {
1248
+ if (shouldPlay && !isPlaying) start()
1249
+ else if (!shouldPlay && isPlaying) pause()
1250
+ }
1251
+ }
1252
+
1253
+ fun stop() {
1254
+ stopPlayback()
1255
+ sourceUri = ""
1256
+ }
1257
+ }
1258
+
1259
+ /** The oscillator/gain subset of Web Audio used by native STX screens. */
1260
+ private class CraftNativeAudio {
1261
+ private data class ParameterEvent(val time: Double, val value: Double, val ramp: Boolean)
1262
+
1263
+ private class Parameter(initial: Double) {
1264
+ private var current = initial
1265
+ private val events = mutableListOf<ParameterEvent>()
1266
+
1267
+ @Synchronized fun set(value: Double) {
1268
+ current = value
1269
+ events.clear()
1270
+ }
1271
+
1272
+ @Synchronized fun schedule(value: Double, time: Double, ramp: Boolean) {
1273
+ events += ParameterEvent(time.coerceAtLeast(0.0), value, ramp)
1274
+ events.sortBy { it.time }
1275
+ }
1276
+
1277
+ @Synchronized fun valueAt(time: Double): Double {
1278
+ var previousTime = 0.0
1279
+ var previousValue = current
1280
+ for (event in events) {
1281
+ if (time < event.time) {
1282
+ if (!event.ramp || event.time <= previousTime) return previousValue
1283
+ val fraction = (time - previousTime) / (event.time - previousTime)
1284
+ return previousValue + (event.value - previousValue) * fraction
1285
+ }
1286
+ previousTime = event.time
1287
+ previousValue = event.value
1288
+ }
1289
+ return previousValue
1290
+ }
1291
+ }
1292
+
1293
+ private class OscillatorState {
1294
+ val frequency = Parameter(440.0)
1295
+ @Volatile var gain = Parameter(1.0)
1296
+ @Volatile var phase = 0.0
1297
+ @Volatile var active = false
1298
+ var contextStart = 0.0
1299
+ }
1300
+
1301
+ private val handler = Handler(Looper.getMainLooper())
1302
+ private val oscillators = mutableMapOf<String, OscillatorState>()
1303
+ private val gains = mutableMapOf<String, Parameter>()
1304
+ private val contextStarts = mutableMapOf<String, Double>()
1305
+ private var track: AudioTrack? = null
1306
+ @Volatile private var running = false
1307
+ private var renderThread: Thread? = null
1308
+
1309
+ @Synchronized fun handle(payload: JSONObject) {
1310
+ when (payload.optString("action")) {
1311
+ "createContext" -> {
1312
+ payload.optString("contextId").takeIf { it.isNotBlank() }?.let {
1313
+ contextStarts[it] = System.nanoTime() / 1_000_000_000.0
1314
+ }
1315
+ }
1316
+ "createOscillator" -> {
1317
+ val id = payload.optString("nodeId")
1318
+ if (id.isNotBlank()) oscillators.putIfAbsent(id, OscillatorState()).also {
1319
+ oscillators[id]?.contextStart = contextStarts[payload.optString("contextId")]
1320
+ ?: (System.nanoTime() / 1_000_000_000.0)
1321
+ oscillators[id]?.frequency?.set(payload.optDouble("frequency", 440.0).coerceAtLeast(0.0))
1322
+ }
1323
+ }
1324
+ "createGain" -> {
1325
+ val id = payload.optString("nodeId")
1326
+ if (id.isNotBlank()) gains[id] = Parameter(payload.optDouble("gain", 1.0).coerceAtLeast(0.0))
1327
+ }
1328
+ "connect" -> {
1329
+ val source = payload.optString("sourceId")
1330
+ oscillators[source]?.gain = gains[payload.optString("destinationId")] ?: Parameter(1.0)
1331
+ }
1332
+ "frequency" -> oscillators[payload.optString("nodeId")]?.frequency?.set(payload.optDouble("value", 0.0).coerceAtLeast(0.0))
1333
+ "frequencyAtTime", "frequencyRampToTime" -> oscillators[payload.optString("nodeId")]?.frequency?.schedule(
1334
+ payload.optDouble("value", 0.0).coerceAtLeast(0.0),
1335
+ payload.optDouble("time", 0.0),
1336
+ payload.optString("action") == "frequencyRampToTime",
1337
+ )
1338
+ "gain" -> gains[payload.optString("nodeId")]?.set(payload.optDouble("value", 0.0).coerceAtLeast(0.0))
1339
+ "gainAtTime", "gainRampToTime" -> gains[payload.optString("nodeId")]?.schedule(
1340
+ payload.optDouble("value", 0.0).coerceAtLeast(0.0),
1341
+ payload.optDouble("time", 0.0),
1342
+ payload.optString("action") == "gainRampToTime",
1343
+ )
1344
+ "start", "stop" -> {
1345
+ val state = oscillators[payload.optString("nodeId")] ?: return
1346
+ ensureTrack()
1347
+ val active = payload.optString("action") == "start"
1348
+ val delay = (payload.optDouble("delay", 0.0).coerceAtLeast(0.0) * 1000).roundToLong()
1349
+ handler.postDelayed({ state.active = active }, delay)
1350
+ }
1351
+ "close" -> close()
1352
+ }
1353
+ }
1354
+
1355
+ @Synchronized fun close() {
1356
+ oscillators.values.forEach { it.active = false }
1357
+ oscillators.clear()
1358
+ gains.clear()
1359
+ contextStarts.clear()
1360
+ running = false
1361
+ renderThread?.interrupt()
1362
+ renderThread = null
1363
+ track?.let { output ->
1364
+ runCatching {
1365
+ output.pause()
1366
+ output.flush()
1367
+ output.release()
1368
+ }
1369
+ }
1370
+ track = null
1371
+ }
1372
+
1373
+ @Synchronized private fun ensureTrack() {
1374
+ if (track != null) return
1375
+ val sampleRate = 44_100
1376
+ val minBuffer = AudioTrack.getMinBufferSize(sampleRate, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT)
1377
+ val format = AudioFormat.Builder()
1378
+ .setSampleRate(sampleRate)
1379
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
1380
+ .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
1381
+ .build()
1382
+ val attributes = AudioAttributes.Builder()
1383
+ .setUsage(AudioAttributes.USAGE_MEDIA)
1384
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
1385
+ .build()
1386
+ val created = AudioTrack.Builder()
1387
+ .setAudioAttributes(attributes)
1388
+ .setAudioFormat(format)
1389
+ .setBufferSizeInBytes(max(minBuffer, sampleRate / 4))
1390
+ .setTransferMode(AudioTrack.MODE_STREAM)
1391
+ .build()
1392
+ track = created
1393
+ running = true
1394
+ created.play()
1395
+ renderThread = Thread({ render(created, sampleRate) }, "craft-native-audio").also { it.start() }
1396
+ }
1397
+
1398
+ private fun render(output: AudioTrack, sampleRate: Int) {
1399
+ val buffer = ShortArray(1024 * 2)
1400
+ while (running && !Thread.currentThread().isInterrupted) {
1401
+ val snapshot = synchronized(this) {
1402
+ oscillators.mapValues { (id, state) ->
1403
+ Triple(state, state.gain, state.active)
1404
+ }
1405
+ }
1406
+ val now = System.nanoTime() / 1_000_000_000.0
1407
+ for (frame in 0 until 1024) {
1408
+ var sample = 0.0
1409
+ snapshot.forEach { (_, value) ->
1410
+ val (state, gain, active) = value
1411
+ if (!active) return@forEach
1412
+ val time = (now - state.contextStart).coerceAtLeast(0.0) + frame.toDouble() / sampleRate
1413
+ val frequency = state.frequency.valueAt(time)
1414
+ sample += sin(state.phase) * gain.valueAt(time)
1415
+ state.phase += 2 * PI * frequency / sampleRate
1416
+ if (state.phase >= 2 * PI) state.phase -= 2 * PI
1417
+ }
1418
+ val value = (sample.coerceIn(-1.0, 1.0) * Short.MAX_VALUE).toInt().toShort()
1419
+ buffer[frame * 2] = value
1420
+ buffer[frame * 2 + 1] = value
1421
+ }
1422
+ if (output.write(buffer, 0, buffer.size, AudioTrack.WRITE_BLOCKING) < 0) break
1423
+ }
1424
+ }
1425
+ }
1426
+
820
1427
  /** WebView-free host for a route bundle compiled by `stx native compile`. */
821
1428
  class MainActivity : AppCompatActivity() {
822
1429
  private class Screen(
@@ -854,6 +1461,7 @@ class MainActivity : AppCompatActivity() {
854
1461
  val modalVisibility = mutableMapOf<View, Boolean>()
855
1462
  val modalCloseHandlers = mutableMapOf<View, String>()
856
1463
  val layoutHandlers = mutableMapOf<View, String>()
1464
+ val layoutListeners = mutableMapOf<View, View.OnLayoutChangeListener>()
857
1465
  val lastLayoutBounds = mutableMapOf<View, LayoutBounds>()
858
1466
  val flatListRows = mutableMapOf<CraftNativeFlatList, MutableMap<String, MutableMap<String, View>>>()
859
1467
  val flatListOwners = mutableMapOf<String, String>()
@@ -862,11 +1470,13 @@ class MainActivity : AppCompatActivity() {
862
1470
  val capabilityScope = UUID.randomUUID().toString()
863
1471
  val pendingCapabilities = mutableSetOf<String>()
864
1472
  val capabilityTimeouts = mutableMapOf<String, Runnable>()
1473
+ val audio = CraftNativeAudio()
865
1474
  var active = true
866
1475
 
867
1476
  fun close() {
868
1477
  if (!active) return
869
1478
  active = false
1479
+ audio.close()
870
1480
  val finish = {
871
1481
  port?.close()
872
1482
  isolate.close()
@@ -1040,6 +1650,80 @@ class MainActivity : AppCompatActivity() {
1040
1650
  globalThis.__stxNativeRoute = ${route?.let { JSONObject.quote(it) } ?: "undefined"};
1041
1651
  globalThis.__stxNativeParams = ${params};
1042
1652
  """.trimIndent()
1653
+ val audioPrelude = """
1654
+ (function() {
1655
+ if (typeof globalThis.AudioContext !== 'function') {
1656
+ var nextAudioId = 0;
1657
+ function audioMessage(payload) {
1658
+ globalThis.__stxNativeBridge.postMessage({ type: 'AUDIO', payload: payload });
1659
+ }
1660
+ function audioParam(contextId, nodeId, action, initial) {
1661
+ var current = Number(initial) || 0;
1662
+ var param = {};
1663
+ Object.defineProperty(param, 'value', {
1664
+ configurable: true,
1665
+ enumerable: true,
1666
+ get: function() { return current; },
1667
+ set: function(value) {
1668
+ current = Number(value) || 0;
1669
+ audioMessage({ action: action, contextId: contextId, nodeId: nodeId, value: current });
1670
+ }
1671
+ });
1672
+ function schedule(value, when, ramp) {
1673
+ current = Number(value) || 0;
1674
+ var time = Number(when);
1675
+ audioMessage({ action: action + (ramp ? 'RampToTime' : 'AtTime'), contextId: contextId, nodeId: nodeId, value: current, time: isFinite(time) ? Math.max(0, time) : 0 });
1676
+ }
1677
+ param.setValueAtTime = function(value, when) { schedule(value, when, false); return param; };
1678
+ param.linearRampToValueAtTime = function(value, when) { schedule(value, when, true); return param; };
1679
+ return param;
1680
+ }
1681
+ function AudioContext() {
1682
+ this._id = 'audio-' + (++nextAudioId);
1683
+ this._started = Date.now() / 1000;
1684
+ this.state = 'running';
1685
+ this.destination = { _contextId: this._id, _nodeId: 'destination' };
1686
+ audioMessage({ action: 'createContext', contextId: this._id });
1687
+ }
1688
+ Object.defineProperty(AudioContext.prototype, 'currentTime', {
1689
+ get: function() { return Date.now() / 1000 - this._started; }
1690
+ });
1691
+ AudioContext.prototype.createOscillator = function() {
1692
+ var context = this;
1693
+ var node = { _contextId: context._id, _nodeId: context._id + '-osc-' + (++nextAudioId) };
1694
+ node.frequency = audioParam(context._id, node._nodeId, 'frequency', 440);
1695
+ node.connect = function(destination) {
1696
+ audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
1697
+ return destination;
1698
+ };
1699
+ node.start = function(when) {
1700
+ audioMessage({ action: 'start', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
1701
+ };
1702
+ node.stop = function(when) {
1703
+ audioMessage({ action: 'stop', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
1704
+ };
1705
+ audioMessage({ action: 'createOscillator', contextId: context._id, nodeId: node._nodeId, frequency: node.frequency.value });
1706
+ return node;
1707
+ };
1708
+ AudioContext.prototype.createGain = function() {
1709
+ var context = this;
1710
+ var node = { _contextId: context._id, _nodeId: context._id + '-gain-' + (++nextAudioId) };
1711
+ node.gain = audioParam(context._id, node._nodeId, 'gain', 1);
1712
+ node.connect = function(destination) {
1713
+ audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
1714
+ return destination;
1715
+ };
1716
+ audioMessage({ action: 'createGain', contextId: context._id, nodeId: node._nodeId, gain: node.gain.value });
1717
+ return node;
1718
+ };
1719
+ AudioContext.prototype.resume = function() { this.state = 'running'; return Promise.resolve(); };
1720
+ AudioContext.prototype.suspend = function() { this.state = 'suspended'; return Promise.resolve(); };
1721
+ AudioContext.prototype.close = function() { this.state = 'closed'; audioMessage({ action: 'close', contextId: this._id }); return Promise.resolve(); };
1722
+ globalThis.AudioContext = AudioContext;
1723
+ globalThis.webkitAudioContext = AudioContext;
1724
+ }
1725
+ })()
1726
+ """.trimIndent()
1043
1727
  val prelude = if (useMessagePorts) """
1044
1728
  (async function() {
1045
1729
  const port = await android.getNamedPort('craft');
@@ -1055,6 +1739,7 @@ class MainActivity : AppCompatActivity() {
1055
1739
  postMessage: function(value) { port.postMessage(value); },
1056
1740
  onMessage: function(callback) { port.onmessage = function(event) { callback(event.data); }; }
1057
1741
  };
1742
+ $audioPrelude
1058
1743
  })()
1059
1744
  """.trimIndent() else """
1060
1745
  $routePrelude
@@ -1069,6 +1754,7 @@ class MainActivity : AppCompatActivity() {
1069
1754
  postMessage: function(value) { console.log(${JSONObject.quote(prefix)} + value); },
1070
1755
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
1071
1756
  };
1757
+ $audioPrelude
1072
1758
  """.trimIndent()
1073
1759
  val setup = isolate.evaluateJavaScriptAsync(prelude)
1074
1760
  setup.addListener({
@@ -1206,6 +1892,7 @@ class MainActivity : AppCompatActivity() {
1206
1892
  capabilities.cancel(token)
1207
1893
  }
1208
1894
  }
1895
+ "AUDIO" -> screen.audio.handle(payload)
1209
1896
  }
1210
1897
  }
1211
1898
 
@@ -1243,9 +1930,30 @@ class MainActivity : AppCompatActivity() {
1243
1930
  val updated = screen.controls["id:$target"] ?: return false
1244
1931
  if (!updateParentLayoutStyle(updated, document.optJSONObject("style") ?: JSONObject())) return false
1245
1932
  }
1933
+ if (applied.isNotEmpty()) {
1934
+ scheduleLayoutEvents(screen)
1935
+ }
1246
1936
  return true
1247
1937
  }
1248
1938
 
1939
+ private fun scheduleLayoutEvents(screen: Screen) {
1940
+ if (!screen.active) return
1941
+ val observer = screen.view.viewTreeObserver
1942
+ if (observer.isAlive) {
1943
+ val listener = object : ViewTreeObserver.OnGlobalLayoutListener {
1944
+ override fun onGlobalLayout() {
1945
+ if (observer.isAlive) observer.removeOnGlobalLayoutListener(this)
1946
+ if (screen.active) emitLayoutEvents(screen)
1947
+ }
1948
+ }
1949
+ observer.addOnGlobalLayoutListener(listener)
1950
+ }
1951
+ screen.view.requestLayout()
1952
+ // A tree that is already settled may not produce a global-layout
1953
+ // callback; keep the event path live for that case as well.
1954
+ screen.view.postOnAnimation { if (screen.active) emitLayoutEvents(screen) }
1955
+ }
1956
+
1249
1957
  /**
1250
1958
  * Targeted mutations bypass the parent's child reconciliation. Rebind the
1251
1959
  * updated node's layout style explicitly so removing a width/height (or
@@ -1482,16 +2190,39 @@ class MainActivity : AppCompatActivity() {
1482
2190
  transparent = props.optBoolean("transparent", false)
1483
2191
  content.configure(style, resources.displayMetrics.density)
1484
2192
  reconcileChildren(screen, content, children, path, next, style, previousControls)
1485
- val visible = props.optBoolean("visible", false) && style.optString("display") != "none"
2193
+ // Dynamic :visible bindings arrive as a display style mutation;
2194
+ // static visible props remain authoritative when present.
2195
+ val visible = if (props.has("visible")) {
2196
+ // An explicit visible prop owns modal visibility. The
2197
+ // binding runtime may leave a stale display:none style
2198
+ // from the previous hidden state when it patches props.
2199
+ props.optBoolean("visible", false)
2200
+ } else {
2201
+ style.has("display") && style.optString("display") != "none"
2202
+ }
1486
2203
  val wasVisible = screen.modalVisibility[this]
1487
2204
  screen.modalVisibility[this] = visible
1488
2205
  val requestClose = events.optString("onRequestClose")
1489
2206
  if (requestClose.isNotBlank()) screen.modalCloseHandlers[this] = requestClose else screen.modalCloseHandlers.remove(this)
2207
+ onRequestClose = if (requestClose.isNotBlank()) {
2208
+ { send(screen, "EVENT", JSONObject().put("handlerName", requestClose).put("nativeEvent", JSONObject())) }
2209
+ } else {
2210
+ null
2211
+ }
1490
2212
  setVisible(visible, style.optDouble("opacity", 1.0).toFloat(), props.optString("animationType").ifBlank { null })
2213
+ if (visible) clearDismissSuppression()
2214
+ val suppressDismiss = !visible && consumeDismissSuppression()
1491
2215
  if (wasVisible != visible && (visible || wasVisible == true)) {
1492
2216
  val lifecycleHandler = if (visible) events.optString("onShow") else events.optString("onDismiss")
1493
- if (lifecycleHandler.isNotBlank()) {
1494
- send(screen, "EVENT", JSONObject().put("handlerName", lifecycleHandler).put("nativeEvent", JSONObject()))
2217
+ if (lifecycleHandler.isNotBlank() && !suppressDismiss) {
2218
+ // Deliver lifecycle callbacks after reconciliation returns to
2219
+ // the queue; the handler may immediately schedule another
2220
+ // render against this native view.
2221
+ post {
2222
+ if (screen.active && screen.modalVisibility[this] == visible) {
2223
+ send(screen, "EVENT", JSONObject().put("handlerName", lifecycleHandler).put("nativeEvent", JSONObject()))
2224
+ }
2225
+ }
1495
2226
  }
1496
2227
  }
1497
2228
  }
@@ -1579,7 +2310,7 @@ class MainActivity : AppCompatActivity() {
1579
2310
  }
1580
2311
  })
1581
2312
  }
1582
- "ActivityIndicator" -> (previous as? android.widget.ProgressBar ?: android.widget.ProgressBar(this)).apply {
2313
+ "ActivityIndicator" -> (previous as? CraftNativeActivityIndicator ?: CraftNativeActivityIndicator(this)).apply {
1583
2314
  val requestedSize = props.opt("size")
1584
2315
  val scale = when (requestedSize) {
1585
2316
  "large" -> 1.35f
@@ -1613,6 +2344,19 @@ class MainActivity : AppCompatActivity() {
1613
2344
  if (onError.isNotBlank()) screen.imageErrorHandlers[this] = onError else screen.imageErrorHandlers.remove(this)
1614
2345
  updateImage(screen, this, props.opt("source"))
1615
2346
  }
2347
+ "Video" -> (previous as? CraftNativeVideoView ?: CraftNativeVideoView(this)).apply {
2348
+ configure(
2349
+ source = props.opt("source"),
2350
+ autoplay = props.optBoolean("autoplay", props.optBoolean("autoPlay", false)),
2351
+ paused = props.optBoolean("paused", false),
2352
+ loop = props.optBoolean("loop", false),
2353
+ muted = props.optBoolean("muted", false),
2354
+ controls = props.optBoolean("controls", false),
2355
+ )
2356
+ }
2357
+ "Svg" -> (previous as? CraftNativeSvgView ?: CraftNativeSvgView(this)).apply {
2358
+ drawing = node
2359
+ }
1616
2360
  "ScrollView" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
1617
2361
  val horizontal = props.optBoolean("horizontal", false) || style.optString("flexDirection") in setOf("row", "row-reverse")
1618
2362
  setHorizontal(horizontal)
@@ -1711,6 +2455,9 @@ class MainActivity : AppCompatActivity() {
1711
2455
  reconcileChildren(screen, this, children, path, next, style, previousControls)
1712
2456
  }
1713
2457
  }
2458
+ if (control is CraftNativeFlexLayout) {
2459
+ control.onLayoutChanged = { if (screen.active) emitLayoutEvents(screen) }
2460
+ }
1714
2461
  val pressInHandler = events.optString("onPressIn").takeIf { it.isNotBlank() }
1715
2462
  val pressOutHandler = events.optString("onPressOut").takeIf { it.isNotBlank() }
1716
2463
  if (pressInHandler != null) screen.pressInHandlers[control] = pressInHandler else screen.pressInHandlers.remove(control)
@@ -1786,9 +2533,11 @@ class MainActivity : AppCompatActivity() {
1786
2533
  if (layoutHandler.isNotBlank()) {
1787
2534
  if (screen.layoutHandlers[control] != layoutHandler) screen.lastLayoutBounds.remove(control)
1788
2535
  screen.layoutHandlers[control] = layoutHandler
2536
+ ensureLayoutListener(screen, control)
1789
2537
  } else {
1790
2538
  screen.layoutHandlers.remove(control)
1791
2539
  screen.lastLayoutBounds.remove(control)
2540
+ screen.layoutListeners.remove(control)?.let(control::removeOnLayoutChangeListener)
1792
2541
  }
1793
2542
  if (control is ImageView) imageErrors[control]?.let { message ->
1794
2543
  val current = control.contentDescription?.toString().orEmpty()
@@ -1807,7 +2556,6 @@ class MainActivity : AppCompatActivity() {
1807
2556
  View.VISIBLE
1808
2557
  }
1809
2558
  }
1810
- if (type == "Modal" && !props.optBoolean("visible", false)) control.visibility = View.GONE
1811
2559
  val width = style.optDouble("width", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
1812
2560
  val height = style.optDouble("height", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
1813
2561
  control.layoutParams = LinearLayout.LayoutParams(
@@ -1968,7 +2716,9 @@ class MainActivity : AppCompatActivity() {
1968
2716
 
1969
2717
  private fun applyViewStyle(view: View, style: JSONObject) {
1970
2718
  view.alpha = style.optDouble("opacity", 1.0).toFloat()
1971
- view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
2719
+ if (view !is CraftNativeModalView) {
2720
+ view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
2721
+ }
1972
2722
  view.elevation = dp(style.optDouble("elevation", 0.0)).toFloat()
1973
2723
  val background = style.has("backgroundColor") && !style.isNull("backgroundColor")
1974
2724
  val border = style.optDouble("borderWidth", 0.0)
@@ -1997,6 +2747,15 @@ class MainActivity : AppCompatActivity() {
1997
2747
  }
1998
2748
  }
1999
2749
 
2750
+ private fun ensureLayoutListener(screen: Screen, view: View) {
2751
+ if (screen.layoutListeners.containsKey(view)) return
2752
+ val listener = View.OnLayoutChangeListener { changed, left, top, right, bottom, _, _, _, _ ->
2753
+ if (screen.active) emitLayoutEvent(screen, changed, left, top, right, bottom)
2754
+ }
2755
+ screen.layoutListeners[view] = listener
2756
+ view.addOnLayoutChangeListener(listener)
2757
+ }
2758
+
2000
2759
  private fun configureText(view: TextView, value: String, style: JSONObject) {
2001
2760
  view.text = when (style.optString("textTransform")) {
2002
2761
  "uppercase" -> value.uppercase()
@@ -2353,14 +3112,21 @@ class MainActivity : AppCompatActivity() {
2353
3112
  }
2354
3113
 
2355
3114
  private fun emitLayoutEvents(screen: Screen, view: View) {
3115
+ emitLayoutEvent(screen, view, view.left, view.top, view.right, view.bottom)
3116
+ if (view is ViewGroup) {
3117
+ for (index in 0 until view.childCount) emitLayoutEvents(screen, view.getChildAt(index))
3118
+ }
3119
+ }
3120
+
3121
+ private fun emitLayoutEvent(screen: Screen, view: View, left: Int, top: Int, right: Int, bottom: Int) {
2356
3122
  val handler = screen.layoutHandlers[view]
2357
3123
  if (handler != null && view.isShown) {
2358
3124
  val density = resources.displayMetrics.density
2359
3125
  val bounds = LayoutBounds(
2360
- x = view.left.toDouble() / density.toDouble(),
2361
- y = view.top.toDouble() / density.toDouble(),
2362
- width = view.width.toDouble() / density.toDouble(),
2363
- height = view.height.toDouble() / density.toDouble(),
3126
+ x = left.toDouble() / density.toDouble(),
3127
+ y = top.toDouble() / density.toDouble(),
3128
+ width = (right - left).toDouble() / density.toDouble(),
3129
+ height = (bottom - top).toDouble() / density.toDouble(),
2364
3130
  )
2365
3131
  if (screen.lastLayoutBounds[view] != bounds) {
2366
3132
  screen.lastLayoutBounds[view] = bounds
@@ -2373,9 +3139,6 @@ class MainActivity : AppCompatActivity() {
2373
3139
  .put("height", bounds.height))))
2374
3140
  }
2375
3141
  }
2376
- if (view is ViewGroup) {
2377
- for (index in 0 until view.childCount) emitLayoutEvents(screen, view.getChildAt(index))
2378
- }
2379
3142
  }
2380
3143
 
2381
3144
  private fun send(screen: Screen, type: String, payload: JSONObject, correlationId: String? = null) {
@@ -2440,6 +3203,8 @@ class MainActivity : AppCompatActivity() {
2440
3203
  releaseFlatList(screen, it, preservingInputDrafts)
2441
3204
  }
2442
3205
  released.filterIsInstance<CraftNativeModalView>().forEach { it.resetForTeardown() }
3206
+ released.filterIsInstance<CraftNativeVideoView>().forEach { it.stop() }
3207
+ released.filterIsInstance<CraftNativeFlexLayout>().forEach { it.onLayoutChanged = null }
2443
3208
  released.filterIsInstance<CraftNativeScrollView>().forEach { it.setRefreshHandler(null, false) }
2444
3209
  releaseImages(released)
2445
3210
  released.filterIsInstance<EditText>().forEach {
@@ -2455,6 +3220,10 @@ class MainActivity : AppCompatActivity() {
2455
3220
  screen.pressOutHandlers.keys.removeAll(released)
2456
3221
  screen.longPressHandlers.keys.removeAll(released)
2457
3222
  screen.layoutHandlers.keys.removeAll(released)
3223
+ screen.layoutListeners.forEach { (view, listener) ->
3224
+ if (view in released) view.removeOnLayoutChangeListener(listener)
3225
+ }
3226
+ screen.layoutListeners.keys.removeAll(released)
2458
3227
  screen.lastLayoutBounds.keys.removeAll(released)
2459
3228
  screen.scrollHandlers.keys.removeAll(released)
2460
3229
  screen.scrollBeginHandlers.keys.removeAll(released)
@@ -2515,9 +3284,7 @@ class MainActivity : AppCompatActivity() {
2515
3284
  val screen = screens.lastOrNull() ?: return back()
2516
3285
  val modal = screen.modalVisibility.entries.lastOrNull { (view, visible) -> visible && view.visibility == View.VISIBLE }
2517
3286
  if (modal != null) {
2518
- screen.modalCloseHandlers[modal.key]?.let { handler ->
2519
- send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject()))
2520
- }
3287
+ (modal.key as? CraftNativeModalView)?.requestClose()
2521
3288
  return
2522
3289
  }
2523
3290
  back()