craft-native 0.0.140 → 0.0.141

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,15 @@ 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
11
16
  import android.graphics.Paint
17
+ import android.graphics.Path
12
18
  import android.graphics.Typeface
13
19
  import android.graphics.drawable.Drawable
14
20
  import android.graphics.drawable.GradientDrawable
@@ -28,11 +34,13 @@ import android.util.Base64
28
34
  import android.util.TypedValue
29
35
  import android.view.Gravity
30
36
  import android.view.KeyEvent
37
+ import android.view.MotionEvent
31
38
  import android.view.inputmethod.EditorInfo
32
39
  import android.text.InputType
33
40
  import android.text.Layout
34
41
  import android.view.View
35
42
  import android.view.ViewGroup
43
+ import android.view.ViewTreeObserver
36
44
  import android.widget.AdapterView
37
45
  import android.widget.ArrayAdapter
38
46
  import android.widget.Button
@@ -46,6 +54,8 @@ import android.widget.SeekBar
46
54
  import android.widget.Spinner
47
55
  import android.widget.Switch
48
56
  import android.widget.TextView
57
+ import android.widget.MediaController
58
+ import android.widget.VideoView
49
59
  import androidx.appcompat.app.AppCompatActivity
50
60
  import androidx.appcompat.app.AppCompatDelegate
51
61
  import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
@@ -61,6 +71,7 @@ import androidx.javascriptengine.Message
61
71
  import androidx.javascriptengine.MessagePort
62
72
  import androidx.javascriptengine.MessagePortClient
63
73
  import com.google.common.util.concurrent.ListenableFuture
74
+ import com.craft.runtime.CraftNative
64
75
  import org.json.JSONArray
65
76
  import org.json.JSONObject
66
77
  import java.net.URL
@@ -69,7 +80,10 @@ import java.util.concurrent.Future
69
80
  import java.util.UUID
70
81
  import kotlin.math.max
71
82
  import kotlin.math.min
83
+ import kotlin.math.PI
72
84
  import kotlin.math.roundToInt
85
+ import kotlin.math.roundToLong
86
+ import kotlin.math.sin
73
87
 
74
88
  private const val CRAFT_NATIVE_CAPABILITY_TIMEOUT_MS = 30_000L
75
89
 
@@ -154,6 +168,184 @@ private fun craftNativeGridColumnCount(value: Any?): Int {
154
168
  return max(1, trimmed.split(Regex("\\s+")).count { it.isNotBlank() })
155
169
  }
156
170
 
171
+ /** A DOM-free SVG surface for the geometry emitted by the shared STX native protocol. */
172
+ private class CraftNativeSvgView(context: Context) : View(context) {
173
+ var drawing: JSONObject = JSONObject()
174
+ set(value) {
175
+ field = value
176
+ invalidate()
177
+ requestLayout()
178
+ }
179
+
180
+ private data class SvgPaint(
181
+ val fill: String? = "#000000",
182
+ val stroke: String? = null,
183
+ val strokeWidth: Float = 1f,
184
+ val color: String = "#000000",
185
+ val opacity: Float = 1f,
186
+ val typeface: Typeface = Typeface.DEFAULT,
187
+ ) {
188
+ fun merge(node: JSONObject): SvgPaint {
189
+ val props = node.optJSONObject("props") ?: JSONObject()
190
+ val style = node.optJSONObject("style") ?: JSONObject()
191
+ fun value(name: String): String? = when {
192
+ style.has(name) && !style.isNull(name) -> style.optString(name)
193
+ props.has(name) && !props.isNull(name) -> props.optString(name)
194
+ else -> null
195
+ }
196
+ val weight = value("fontWeight")
197
+ return copy(
198
+ fill = value("fill") ?: fill,
199
+ stroke = value("stroke") ?: stroke,
200
+ strokeWidth = value("strokeWidth")?.toFloatOrNull() ?: strokeWidth,
201
+ color = value("color") ?: color,
202
+ opacity = (value("opacity")?.toFloatOrNull() ?: 1f) * opacity,
203
+ typeface = if (weight in setOf("bold", "700")) Typeface.DEFAULT_BOLD else typeface,
204
+ )
205
+ }
206
+ }
207
+
208
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
209
+ val width = MeasureSpec.getSize(widthMeasureSpec)
210
+ val box = viewBox()
211
+ val naturalHeight = if (box != null && box.second > 0) (width * box.second / box.first).roundToInt() else 0
212
+ val height = when (MeasureSpec.getMode(heightMeasureSpec)) {
213
+ MeasureSpec.EXACTLY -> MeasureSpec.getSize(heightMeasureSpec)
214
+ MeasureSpec.AT_MOST -> min(naturalHeight, MeasureSpec.getSize(heightMeasureSpec))
215
+ else -> naturalHeight
216
+ }
217
+ setMeasuredDimension(resolveSize(width, widthMeasureSpec), resolveSize(height, heightMeasureSpec))
218
+ }
219
+
220
+ override fun onDraw(canvas: Canvas) {
221
+ super.onDraw(canvas)
222
+ val box = viewBox() ?: return
223
+ if (box.first <= 0 || box.second <= 0) return
224
+ val scale = min(width.toFloat() / box.first, height.toFloat() / box.second)
225
+ if (!scale.isFinite() || scale <= 0f) return
226
+ canvas.save()
227
+ canvas.translate((width - box.first * scale) / 2f, (height - box.second * scale) / 2f)
228
+ canvas.scale(scale, scale)
229
+ canvas.translate(-box.third, -box.fourth)
230
+ drawNodes(canvas, drawing.optJSONArray("children") ?: JSONArray(), SvgPaint().merge(drawing))
231
+ canvas.restore()
232
+ }
233
+
234
+ private fun viewBox(): Quadruple<Float, Float, Float, Float>? {
235
+ val values = drawing.optJSONObject("props")?.optJSONArray("viewBox") ?: return null
236
+ if (values.length() != 4) return null
237
+ val x = values.optDouble(0, Double.NaN).toFloat()
238
+ val y = values.optDouble(1, Double.NaN).toFloat()
239
+ val width = values.optDouble(2, Double.NaN).toFloat()
240
+ val height = values.optDouble(3, Double.NaN).toFloat()
241
+ if (!x.isFinite() || !y.isFinite() || !width.isFinite() || !height.isFinite()) return null
242
+ return Quadruple(width, height, x, y)
243
+ }
244
+
245
+ private fun drawNodes(canvas: Canvas, children: JSONArray, inherited: SvgPaint) {
246
+ for (index in 0 until children.length()) {
247
+ val node = children.optJSONObject(index) ?: continue
248
+ val style = node.optJSONObject("style") ?: JSONObject()
249
+ if (style.optString("display") == "none") continue
250
+ val paint = inherited.merge(node)
251
+ when (node.optString("type")) {
252
+ "G" -> drawNodes(canvas, node.optJSONArray("children") ?: JSONArray(), paint)
253
+ "SvgText" -> drawText(canvas, node, paint)
254
+ else -> drawPath(canvas, node, paint)
255
+ }
256
+ }
257
+ }
258
+
259
+ private fun drawPath(canvas: Canvas, node: JSONObject, style: SvgPaint) {
260
+ val props = node.optJSONObject("props") ?: JSONObject()
261
+ val geometry = props.optJSONArray("geometry") ?: return
262
+ val path = Path()
263
+ var index = 0
264
+ while (index < geometry.length()) {
265
+ val command = geometry.optDouble(index, Double.NaN).toInt()
266
+ fun number(offset: Int): Float = geometry.optDouble(index + offset, Double.NaN).toFloat()
267
+ when (command) {
268
+ 0 -> if (index + 2 < geometry.length()) { path.moveTo(number(1), number(2)); index += 3 } else break
269
+ 1 -> if (index + 2 < geometry.length()) { path.lineTo(number(1), number(2)); index += 3 } else break
270
+ 2 -> if (index + 6 < geometry.length()) { path.cubicTo(number(1), number(2), number(3), number(4), number(5), number(6)); index += 7 } else break
271
+ 3 -> if (index + 4 < geometry.length()) { path.quadTo(number(1), number(2), number(3), number(4)); index += 5 } else break
272
+ 4 -> { path.close(); index += 1 }
273
+ else -> break
274
+ }
275
+ }
276
+ val fill = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
277
+ color = resolveColor(style.fill, style.color, Color.TRANSPARENT)
278
+ alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
279
+ this.style = Paint.Style.FILL
280
+ }
281
+ if (style.fill != "none" && style.fill != "transparent") canvas.drawPath(path, fill)
282
+ if (!style.stroke.isNullOrBlank() && style.stroke != "none" && style.stroke != "transparent" && style.strokeWidth > 0) {
283
+ val stroke = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
284
+ color = resolveColor(style.stroke, style.color, Color.TRANSPARENT)
285
+ alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
286
+ this.style = Paint.Style.STROKE
287
+ strokeWidth = style.strokeWidth
288
+ strokeCap = when (props.optString("strokeLinecap")) {
289
+ "round" -> Paint.Cap.ROUND
290
+ "square" -> Paint.Cap.SQUARE
291
+ else -> Paint.Cap.BUTT
292
+ }
293
+ strokeJoin = when (props.optString("strokeLinejoin")) {
294
+ "round" -> Paint.Join.ROUND
295
+ "bevel" -> Paint.Join.BEVEL
296
+ else -> Paint.Join.MITER
297
+ }
298
+ props.optJSONArray("strokeDasharray")?.let { values ->
299
+ val dashes: List<Float> = (0 until values.length()).mapNotNull {
300
+ values.optDouble(it, Double.NaN).takeUnless { value -> value.isNaN() }?.toFloat()
301
+ }
302
+ if (dashes.isNotEmpty()) {
303
+ val dashPattern = dashes.toFloatArray()
304
+ val pattern: FloatArray = if (dashPattern.size % 2 == 1) {
305
+ FloatArray(dashPattern.size * 2) { index -> dashPattern[index % dashPattern.size] }
306
+ } else {
307
+ dashPattern
308
+ }
309
+ pathEffect = DashPathEffect(pattern, 0f)
310
+ }
311
+ }
312
+ }
313
+ canvas.drawPath(path, stroke)
314
+ }
315
+ }
316
+
317
+ private fun drawText(canvas: Canvas, node: JSONObject, style: SvgPaint) {
318
+ val props = node.optJSONObject("props") ?: JSONObject()
319
+ val nodeStyle = node.optJSONObject("style") ?: JSONObject()
320
+ val text = buildString {
321
+ val children = node.optJSONArray("children") ?: JSONArray()
322
+ for (index in 0 until children.length()) if (children.opt(index) is String) append(children.optString(index))
323
+ }
324
+ if (text.isEmpty()) return
325
+ val size = (if (props.has("fontSize")) props.optDouble("fontSize", 12.0) else nodeStyle.optDouble("fontSize", 12.0))
326
+ .toFloat()
327
+ val paint = android.graphics.Paint(Paint.ANTI_ALIAS_FLAG).apply {
328
+ color = resolveColor(style.fill, style.color, Color.BLACK)
329
+ alpha = (style.opacity * 255f).roundToInt().coerceIn(0, 255)
330
+ textSize = size
331
+ typeface = style.typeface
332
+ }
333
+ var x = props.optDouble("x", 0.0).toFloat()
334
+ val y = props.optDouble("y", 0.0).toFloat()
335
+ when (props.optString("textAnchor")) {
336
+ "middle" -> x -= paint.measureText(text) / 2f
337
+ "end" -> x -= paint.measureText(text)
338
+ }
339
+ canvas.drawText(text, x, y, paint)
340
+ }
341
+
342
+ private fun resolveColor(value: String?, current: String, fallback: Int): Int = runCatching {
343
+ Color.parseColor(if (value == "currentColor") current else value ?: current)
344
+ }.getOrDefault(fallback)
345
+
346
+ private data class Quadruple<T1, T2, T3, T4>(val first: T1, val second: T2, val third: T3, val fourth: T4)
347
+ }
348
+
157
349
  /** A deterministic flex-shaped container shared by normal views and scroll content. */
158
350
  private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
159
351
  var onLayoutChanged: (() -> Unit)? = null
@@ -167,9 +359,11 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
167
359
  private var grid = false
168
360
  private var gridColumns = 1
169
361
  private var gridAutoRows: Int? = null
362
+ private var reverse = false
170
363
 
171
364
  fun configure(style: JSONObject, density: Float) {
172
365
  orientation = if (style.optString("flexDirection") in setOf("row", "row-reverse")) HORIZONTAL else VERTICAL
366
+ reverse = style.optString("flexDirection").endsWith("-reverse")
173
367
  wrap = style.optString("flexWrap") == "wrap" || style.optBoolean("flexWrap", false)
174
368
  alignItems = style.optString("alignItems", "stretch")
175
369
  alignContent = style.optString("alignContent", "flex-start")
@@ -214,7 +408,7 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
214
408
  val gap = if (orientation == HORIZONTAL) columnGap else rowGap
215
409
  val lines = mutableListOf<Line>()
216
410
  val absolute = mutableListOf<Entry>()
217
- var line = Line()
411
+ val pending = mutableListOf<Entry>()
218
412
  for (index in 0 until childCount) {
219
413
  val child = getChildAt(index)
220
414
  if (child.visibility == GONE) continue
@@ -236,18 +430,42 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
236
430
  val childHeight = measureChildHeight(child, style, height - paddingTop - paddingBottom, verticalMargins)
237
431
  val entry = Entry(child, style, childWidth, childHeight)
238
432
  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
433
+ pending += entry
434
+ }
435
+ if (pending.isNotEmpty()) {
436
+ val basis = pending.map { entry ->
437
+ if (orientation == HORIZONTAL) entry.width.toFloat() else entry.height.toFloat()
438
+ }.toFloatArray()
439
+ val margins = pending.flatMap { entry ->
440
+ val lp = entry.view.layoutParams as? MarginLayoutParams
441
+ if (orientation == HORIZONTAL) listOf((lp?.leftMargin ?: 0).toFloat(), (lp?.rightMargin ?: 0).toFloat())
442
+ else listOf((lp?.topMargin ?: 0).toFloat(), (lp?.bottomMargin ?: 0).toFloat())
443
+ }.toFloatArray()
444
+ val sharedLineIds = CraftNative.flexLines(availableMain.toFloat(), gap.toFloat(), wrap, basis, margins)
445
+ val lineIds = sharedLineIds?.takeIf { ids ->
446
+ ids.size >= pending.size && ids.firstOrNull() == 0 && ids.zipWithNext().all { (left, right) -> right >= left }
447
+ }
448
+ var line = Line()
449
+ pending.forEachIndexed { index, entry ->
450
+ val childMain = if (orientation == HORIZONTAL) entry.width + (margins[index * 2] + margins[index * 2 + 1]).roundToInt()
451
+ else entry.height + (margins[index * 2] + margins[index * 2 + 1]).roundToInt()
452
+ val lp = entry.view.layoutParams as? MarginLayoutParams
453
+ val crossMargins = if (orientation == HORIZONTAL) (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
454
+ else (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
455
+ val childCross = if (orientation == HORIZONTAL) entry.height + crossMargins else entry.width + crossMargins
456
+ if (index > 0 && lineIds != null && lineIds[index] != lineIds[index - 1]) {
457
+ lines += line
458
+ line = Line()
459
+ } else if (index > 0 && lineIds == null && wrap && line.entries.isNotEmpty() && line.main + gap + childMain > availableMain) {
460
+ lines += line
461
+ line = Line()
462
+ }
463
+ line.entries += entry
464
+ line.main = if (line.entries.size == 1) childMain else line.main + gap + childMain
465
+ line.cross = max(line.cross, childCross)
466
+ }
467
+ if (line.entries.isNotEmpty()) lines += line
468
+ }
251
469
  return lines to absolute
252
470
  }
253
471
 
@@ -361,6 +579,24 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
361
579
  else -> 0.0
362
580
  }
363
581
 
582
+ private fun justifyCode(): Int = when (justifyContent) {
583
+ "center" -> 1
584
+ "flex-end" -> 2
585
+ "space-between" -> 3
586
+ "space-around" -> 4
587
+ "space-evenly" -> 5
588
+ else -> 0
589
+ }
590
+
591
+ private fun alignContentCode(): Int = when (alignContent) {
592
+ "center" -> 1
593
+ "flex-end" -> 2
594
+ "space-between" -> 3
595
+ "space-around" -> 4
596
+ "stretch" -> 5
597
+ else -> 0
598
+ }
599
+
364
600
  override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
365
601
  val widthMode = View.MeasureSpec.getMode(widthMeasureSpec)
366
602
  val width = View.MeasureSpec.getSize(widthMeasureSpec).takeUnless { widthMode == View.MeasureSpec.UNSPECIFIED } ?: 1_000_000
@@ -396,36 +632,66 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
396
632
  override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
397
633
  if (grid) {
398
634
  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
- }
635
+ val contentWidth = width - paddingLeft - paddingRight
636
+ val contentHeight = height - paddingTop - paddingBottom
637
+ val flow = layout.rows.flatten()
638
+ val sharedFrames = CraftNative.grid(
639
+ contentWidth.toFloat(), contentHeight.toFloat(), gridColumns, columnGap.toFloat(), rowGap.toFloat(),
640
+ flow.map { it.width.toFloat() }.toFloatArray(), flow.map { it.height.toFloat() }.toFloatArray(),
641
+ flow.map { it.style.width?.resolve(layout.cellWidth)?.toFloat() ?: Float.NaN }.toFloatArray(),
642
+ flow.map { it.style.height?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
643
+ flow.map { it.style.minWidth?.resolve(layout.cellWidth)?.toFloat() ?: Float.NaN }.toFloatArray(),
644
+ flow.map { it.style.maxWidth?.resolve(layout.cellWidth)?.toFloat() ?: Float.NaN }.toFloatArray(),
645
+ flow.map { it.style.minHeight?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
646
+ flow.map { it.style.maxHeight?.resolve(null)?.toFloat() ?: Float.NaN }.toFloatArray(),
647
+ flow.flatMap {
648
+ val lp = it.view.layoutParams as? MarginLayoutParams
649
+ listOf((lp?.leftMargin ?: 0).toFloat(), (lp?.rightMargin ?: 0).toFloat(), (lp?.topMargin ?: 0).toFloat(), (lp?.bottomMargin ?: 0).toFloat())
650
+ }.toFloatArray(),
651
+ flow.flatMap {
652
+ val align = it.style.alignSelf ?: alignItems
653
+ listOf(if (align == "stretch") 1f else 0f, if (align == "center") 1f else if (align == "flex-end") 2f else 0f)
654
+ }.toFloatArray(),
655
+ )
656
+ if (sharedFrames != null) {
657
+ for ((index, entry) in flow.withIndex()) {
658
+ val offset = index * 4
659
+ val x = paddingLeft + sharedFrames[offset].roundToInt()
660
+ val y = paddingTop + sharedFrames[offset + 1].roundToInt()
661
+ val childWidth = sharedFrames[offset + 2].roundToInt()
662
+ val childHeight = sharedFrames[offset + 3].roundToInt()
423
663
  entry.view.layout(x, y, x + childWidth, y + childHeight)
424
664
  }
425
- rowTop += rowHeight + rowGap
665
+ } else {
666
+ var rowTop = paddingTop
667
+ for ((rowIndex, row) in layout.rows.withIndex()) {
668
+ val rowHeight = layout.rowHeights[rowIndex]
669
+ for ((column, entry) in row.withIndex()) {
670
+ val align = entry.style.alignSelf ?: alignItems
671
+ val lp = entry.view.layoutParams as? MarginLayoutParams
672
+ val marginLeft = lp?.leftMargin ?: 0
673
+ val marginRight = lp?.rightMargin ?: 0
674
+ val marginTop = lp?.topMargin ?: 0
675
+ val marginBottom = lp?.bottomMargin ?: 0
676
+ val availableWidth = max(0, layout.cellWidth - marginLeft - marginRight)
677
+ val availableHeight = max(0, rowHeight - marginTop - marginBottom)
678
+ val childWidth = if (align == "stretch" && entry.style.width == null) availableWidth else min(availableWidth, entry.width)
679
+ val childHeight = if (align == "stretch" && entry.style.height == null) availableHeight else min(availableHeight, entry.height)
680
+ val x = paddingLeft + column * (layout.cellWidth + columnGap) + marginLeft + when (align) {
681
+ "center" -> (availableWidth - childWidth) / 2
682
+ "flex-end" -> availableWidth - childWidth
683
+ else -> 0
684
+ }
685
+ val y = rowTop + marginTop + when (align) {
686
+ "center" -> (availableHeight - childHeight) / 2
687
+ "flex-end" -> availableHeight - childHeight
688
+ else -> 0
689
+ }
690
+ entry.view.layout(x, y, x + childWidth, y + childHeight)
691
+ }
692
+ rowTop += rowHeight + rowGap
693
+ }
426
694
  }
427
- val contentWidth = width - paddingLeft - paddingRight
428
- val contentHeight = height - paddingTop - paddingBottom
429
695
  for (entry in layout.absolute) {
430
696
  if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
431
697
  val childWidth = if (entry.style.left != null && entry.style.right != null) contentWidth - entry.style.left!! - entry.style.right!! else entry.width
@@ -448,8 +714,14 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
448
714
  val crossFree = max(0, availableCross - totalCross)
449
715
  var crossLeading = 0
450
716
  var crossBetween = crossGap
717
+ val sharedCross = if (wrap && lines.size > 1) {
718
+ CraftNative.flexLineDistribution(
719
+ availableCross.toFloat(), crossGap.toFloat(), alignContentCode(), lines.map { it.cross.toFloat() }.toFloatArray()
720
+ )
721
+ } else null
722
+ sharedCross?.second?.forEachIndexed { index, size -> lines[index].cross = size.roundToInt() }
451
723
  if (wrap && lines.size > 1) {
452
- when (alignContent) {
724
+ if (sharedCross == null) when (alignContent) {
453
725
  "center" -> crossLeading = crossFree / 2
454
726
  "flex-end" -> crossLeading = crossFree
455
727
  "space-between" -> crossBetween = crossFree / (lines.size - 1) + crossGap
@@ -464,23 +736,55 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
464
736
  }
465
737
  }
466
738
  var crossOffset = (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + crossLeading
467
- for (line in lines) {
739
+ for ((lineIndex, line) in lines.withIndex()) {
740
+ val lineOffset = sharedCross?.first?.get(lineIndex)?.roundToInt()?.let { (if (orientation == HORIZONTAL) paddingTop else paddingLeft) + it } ?: crossOffset
468
741
  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)
742
+ val sharedPrimary = CraftNative.flexRow(
743
+ availableMain.toFloat(),
744
+ gap.toFloat(),
745
+ justifyCode(),
746
+ line.entries.map { entry -> if (orientation == HORIZONTAL) entry.width.toFloat() else entry.height.toFloat() }.toFloatArray(),
747
+ line.entries.map { it.style.flexGrow.toFloat() }.toFloatArray(),
748
+ line.entries.map { (it.style.flexShrink ?: defaultFlexShrink(it.view)).toFloat() }.toFloatArray(),
749
+ line.entries.map {
750
+ (if (orientation == HORIZONTAL) it.style.minWidth?.resolve(contentWidth) else it.style.minHeight?.resolve(contentHeight))?.toFloat()
751
+ ?: Float.NaN
752
+ }.toFloatArray(),
753
+ line.entries.map {
754
+ (if (orientation == HORIZONTAL) it.style.maxWidth?.resolve(contentWidth) else it.style.maxHeight?.resolve(contentHeight))?.toFloat()
755
+ ?: Float.NaN
756
+ }.toFloatArray(),
757
+ line.entries.flatMap { entry ->
758
+ val lp = entry.view.layoutParams as? MarginLayoutParams
759
+ if (orientation == HORIZONTAL) listOf((lp?.leftMargin ?: 0).toFloat(), (lp?.rightMargin ?: 0).toFloat())
760
+ else listOf((lp?.topMargin ?: 0).toFloat(), (lp?.bottomMargin ?: 0).toFloat())
761
+ }.toFloatArray(),
762
+ )
763
+ val sharedPositions = sharedPrimary?.takeIf { it.size == line.entries.size * 2 }
764
+ val distributedMain = if (sharedPrimary != null) {
765
+ line.entries.indices.sumOf { index ->
766
+ val lp = line.entries[index].view.layoutParams as? MarginLayoutParams
767
+ val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
481
768
  else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
482
- constrained + margins
483
- } + max(0, line.entries.size - 1) * gap
769
+ sharedPrimary[index].roundToInt() + margins
770
+ } + max(0, line.entries.size - 1) * gap
771
+ } else {
772
+ val factors = line.entries.map { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink ?: defaultFlexShrink(it.view) }
773
+ val totalFactor = factors.sum()
774
+ line.entries.indices.sumOf { index ->
775
+ val base = if (orientation == HORIZONTAL) line.entries[index].width else line.entries[index].height
776
+ val primary = if (totalFactor > 0) max(0, (base + rawFree * factors[index] / totalFactor).toInt()) else base
777
+ val constrained = if (orientation == HORIZONTAL) {
778
+ clamp(primary, line.entries[index].style.minWidth?.resolve(contentWidth), line.entries[index].style.maxWidth?.resolve(contentWidth))
779
+ } else {
780
+ clamp(primary, line.entries[index].style.minHeight?.resolve(contentHeight), line.entries[index].style.maxHeight?.resolve(contentHeight))
781
+ }
782
+ val lp = line.entries[index].view.layoutParams as? MarginLayoutParams
783
+ val margins = if (orientation == HORIZONTAL) (lp?.leftMargin ?: 0) + (lp?.rightMargin ?: 0)
784
+ else (lp?.topMargin ?: 0) + (lp?.bottomMargin ?: 0)
785
+ constrained + margins
786
+ } + max(0, line.entries.size - 1) * gap
787
+ }
484
788
  val free = max(0, availableMain - distributedMain)
485
789
  val (leading, between) = distribution(free, line.entries.size)
486
790
  var mainOffset = if (orientation == HORIZONTAL) paddingLeft else paddingTop
@@ -495,27 +799,43 @@ private class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
495
799
  val align = entry.style.alignSelf ?: alignItems
496
800
  val crossLeading = if (orientation == HORIZONTAL) lp?.topMargin ?: 0 else lp?.leftMargin ?: 0
497
801
  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
802
+ val alignment = when (align) {
803
+ "center" -> 1
804
+ "flex-end" -> 2
805
+ else -> 0
806
+ }
807
+ val crossPosition = CraftNative.crossAxisPosition(
808
+ line.cross.toFloat(), crossSize.toFloat(), crossLeading.toFloat(), crossTrailing.toFloat(), alignment
809
+ )?.roundToInt() ?: when (alignment) {
810
+ 1 -> crossLeading + (line.cross - crossLeading - crossTrailing - crossSize) / 2
811
+ 2 -> line.cross - crossTrailing - crossSize
502
812
  else -> crossLeading
503
813
  }
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))
814
+ // Shared positions cover every non-reverse justification; the
815
+ // local main-axis rule remains the fallback for old runtimes
816
+ // that return widths only.
817
+ val sharedPosition = if (!reverse) sharedPositions?.get(index)?.roundToInt() else null
818
+ val positionedMain = sharedPosition?.let { (if (orientation == HORIZONTAL) paddingLeft else paddingTop) + it }
819
+ ?: mainOffset + marginStart
820
+ val x = if (orientation == HORIZONTAL) positionedMain else lineOffset + crossPosition
821
+ val y = if (orientation == HORIZONTAL) lineOffset + crossPosition else positionedMain
822
+ val distributedPrimary = sharedPrimary?.get(index)?.roundToInt() ?: run {
823
+ val primary = if (orientation == HORIZONTAL) entry.width else entry.height
824
+ val factor = if (rawFree >= 0) entry.style.flexGrow else entry.style.flexShrink ?: defaultFlexShrink(entry.view)
825
+ val totalFactor = line.entries.sumOf { if (rawFree >= 0) it.style.flexGrow else it.style.flexShrink ?: defaultFlexShrink(it.view) }
826
+ val distributed = if (totalFactor > 0) max(0, (primary + rawFree * factor / totalFactor).toInt()) else primary
827
+ if (orientation == HORIZONTAL) {
828
+ clamp(distributed, entry.style.minWidth?.resolve(contentWidth), entry.style.maxWidth?.resolve(contentWidth))
829
+ } else {
830
+ clamp(distributed, entry.style.minHeight?.resolve(contentHeight), entry.style.maxHeight?.resolve(contentHeight))
831
+ }
512
832
  }
513
833
  val w = if (orientation == HORIZONTAL) distributedPrimary else crossSize
514
834
  val h = if (orientation == HORIZONTAL) crossSize else distributedPrimary
515
835
  entry.view.layout(x, y, x + w, y + h)
516
- mainOffset += distributedPrimary + marginStart + marginEnd + gap + between
836
+ if (sharedPosition == null) mainOffset += distributedPrimary + marginStart + marginEnd + gap + between
517
837
  }
518
- crossOffset += line.cross + crossBetween
838
+ if (sharedCross == null) crossOffset += line.cross + crossBetween
519
839
  }
520
840
  for (entry in absolute) {
521
841
  if (entry.view is CraftNativeModalView && entry.view.visibility == View.VISIBLE) entry.view.bringToFront()
@@ -736,6 +1056,7 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
736
1056
  val content = CraftNativeFlexLayout(context)
737
1057
  private val blocker = View(context)
738
1058
  private var renderedVisible = false
1059
+ private var dismissSuppressedByRequest = false
739
1060
  private var animationGeneration = 0
740
1061
  var onRequestClose: (() -> Unit)? = null
741
1062
 
@@ -748,13 +1069,38 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
748
1069
  init {
749
1070
  isClickable = true
750
1071
  blocker.isClickable = true
751
- blocker.setOnTouchListener { _, _ -> true }
1072
+ blocker.setOnTouchListener { _, event ->
1073
+ if (event.action == MotionEvent.ACTION_UP) requestClose()
1074
+ true
1075
+ }
752
1076
  blocker.setBackgroundColor(Color.argb(82, 0, 0, 0))
753
1077
  addView(blocker, LayoutParams(-1, -1))
754
1078
  addView(content, LayoutParams(-1, -1))
755
1079
  }
756
1080
 
1081
+ fun requestClose() {
1082
+ val handler = onRequestClose ?: return
1083
+ dismissSuppressedByRequest = true
1084
+ isClickable = false
1085
+ blocker.isEnabled = false
1086
+ handler()
1087
+ }
1088
+
1089
+ fun consumeDismissSuppression(): Boolean {
1090
+ val suppressed = dismissSuppressedByRequest
1091
+ dismissSuppressedByRequest = false
1092
+ return suppressed
1093
+ }
1094
+
1095
+ fun clearDismissSuppression() {
1096
+ dismissSuppressedByRequest = false
1097
+ }
1098
+
757
1099
  fun setVisible(visible: Boolean, opacity: Float, animationType: String?) {
1100
+ if (visible) {
1101
+ isClickable = true
1102
+ blocker.isEnabled = true
1103
+ }
758
1104
  if (renderedVisible == visible && visibility == if (visible) View.VISIBLE else View.GONE) {
759
1105
  if (visible && alpha != opacity) alpha = opacity
760
1106
  return
@@ -806,6 +1152,9 @@ private class CraftNativeModalView(context: Context) : FrameLayout(context) {
806
1152
  visibility = View.GONE
807
1153
  translationY = 0f
808
1154
  alpha = 0f
1155
+ isClickable = true
1156
+ blocker.isEnabled = true
1157
+ dismissSuppressedByRequest = false
809
1158
  onRequestClose = null
810
1159
  }
811
1160
  }
@@ -817,6 +1166,232 @@ private data class LayoutBounds(
817
1166
  val height: Double,
818
1167
  )
819
1168
 
1169
+ /**
1170
+ * Android's default spinner style is indeterminate-only, so assigning
1171
+ * `isIndeterminate = false` to it is silently ignored. Use a determinate
1172
+ * ProgressBar style and carry over the platform spinner drawable instead;
1173
+ * that preserves the native look while allowing `animating={false}` to be a
1174
+ * genuinely stopped control.
1175
+ */
1176
+ private class CraftNativeActivityIndicator(context: Context) :
1177
+ android.widget.ProgressBar(context, null, android.R.attr.progressBarStyleHorizontal) {
1178
+ init {
1179
+ val spinner = android.widget.ProgressBar(context).indeterminateDrawable
1180
+ indeterminateDrawable = spinner
1181
+ progressDrawable = spinner?.constantState?.newDrawable()?.mutate() ?: spinner
1182
+ }
1183
+ }
1184
+
1185
+ private class CraftNativeVideoView(context: Context) : VideoView(context) {
1186
+ private var sourceUri = ""
1187
+ private var shouldLoop = false
1188
+ private var shouldPlay = false
1189
+ private var muted = false
1190
+
1191
+ fun configure(source: Any?, autoplay: Boolean, paused: Boolean, loop: Boolean, muted: Boolean, controls: Boolean) {
1192
+ val uri = when (source) {
1193
+ is String -> source
1194
+ is JSONObject -> source.optString("uri")
1195
+ else -> ""
1196
+ }
1197
+ shouldLoop = loop
1198
+ shouldPlay = autoplay && !paused
1199
+ this.muted = muted
1200
+ setMediaController(if (controls) MediaController(context).also { it.setAnchorView(this) } else null)
1201
+ if (uri != sourceUri) {
1202
+ sourceUri = uri
1203
+ stopPlayback()
1204
+ if (uri.isBlank()) return
1205
+ setVideoURI(Uri.parse(uri))
1206
+ setOnPreparedListener { mediaPlayer ->
1207
+ mediaPlayer.isLooping = shouldLoop
1208
+ val volume = if (this.muted) 0f else 1f
1209
+ mediaPlayer.setVolume(volume, volume)
1210
+ if (shouldPlay) start()
1211
+ }
1212
+ setOnCompletionListener { if (shouldLoop && !isPlaying) start() }
1213
+ }
1214
+ if (uri.isBlank()) return
1215
+ runCatching {
1216
+ if (shouldPlay && !isPlaying) start()
1217
+ else if (!shouldPlay && isPlaying) pause()
1218
+ }
1219
+ }
1220
+
1221
+ fun stop() {
1222
+ stopPlayback()
1223
+ sourceUri = ""
1224
+ }
1225
+ }
1226
+
1227
+ /** The oscillator/gain subset of Web Audio used by native STX screens. */
1228
+ private class CraftNativeAudio {
1229
+ private data class ParameterEvent(val time: Double, val value: Double, val ramp: Boolean)
1230
+
1231
+ private class Parameter(initial: Double) {
1232
+ private var current = initial
1233
+ private val events = mutableListOf<ParameterEvent>()
1234
+
1235
+ @Synchronized fun set(value: Double) {
1236
+ current = value
1237
+ events.clear()
1238
+ }
1239
+
1240
+ @Synchronized fun schedule(value: Double, time: Double, ramp: Boolean) {
1241
+ events += ParameterEvent(time.coerceAtLeast(0.0), value, ramp)
1242
+ events.sortBy { it.time }
1243
+ }
1244
+
1245
+ @Synchronized fun valueAt(time: Double): Double {
1246
+ var previousTime = 0.0
1247
+ var previousValue = current
1248
+ for (event in events) {
1249
+ if (time < event.time) {
1250
+ if (!event.ramp || event.time <= previousTime) return previousValue
1251
+ val fraction = (time - previousTime) / (event.time - previousTime)
1252
+ return previousValue + (event.value - previousValue) * fraction
1253
+ }
1254
+ previousTime = event.time
1255
+ previousValue = event.value
1256
+ }
1257
+ return previousValue
1258
+ }
1259
+ }
1260
+
1261
+ private class OscillatorState {
1262
+ val frequency = Parameter(440.0)
1263
+ @Volatile var gain = Parameter(1.0)
1264
+ @Volatile var phase = 0.0
1265
+ @Volatile var active = false
1266
+ var contextStart = 0.0
1267
+ }
1268
+
1269
+ private val handler = Handler(Looper.getMainLooper())
1270
+ private val oscillators = mutableMapOf<String, OscillatorState>()
1271
+ private val gains = mutableMapOf<String, Parameter>()
1272
+ private val contextStarts = mutableMapOf<String, Double>()
1273
+ private var track: AudioTrack? = null
1274
+ @Volatile private var running = false
1275
+ private var renderThread: Thread? = null
1276
+
1277
+ @Synchronized fun handle(payload: JSONObject) {
1278
+ when (payload.optString("action")) {
1279
+ "createContext" -> {
1280
+ payload.optString("contextId").takeIf { it.isNotBlank() }?.let {
1281
+ contextStarts[it] = System.nanoTime() / 1_000_000_000.0
1282
+ }
1283
+ }
1284
+ "createOscillator" -> {
1285
+ val id = payload.optString("nodeId")
1286
+ if (id.isNotBlank()) oscillators.putIfAbsent(id, OscillatorState()).also {
1287
+ oscillators[id]?.contextStart = contextStarts[payload.optString("contextId")]
1288
+ ?: (System.nanoTime() / 1_000_000_000.0)
1289
+ oscillators[id]?.frequency?.set(payload.optDouble("frequency", 440.0).coerceAtLeast(0.0))
1290
+ }
1291
+ }
1292
+ "createGain" -> {
1293
+ val id = payload.optString("nodeId")
1294
+ if (id.isNotBlank()) gains[id] = Parameter(payload.optDouble("gain", 1.0).coerceAtLeast(0.0))
1295
+ }
1296
+ "connect" -> {
1297
+ val source = payload.optString("sourceId")
1298
+ oscillators[source]?.gain = gains[payload.optString("destinationId")] ?: Parameter(1.0)
1299
+ }
1300
+ "frequency" -> oscillators[payload.optString("nodeId")]?.frequency?.set(payload.optDouble("value", 0.0).coerceAtLeast(0.0))
1301
+ "frequencyAtTime", "frequencyRampToTime" -> oscillators[payload.optString("nodeId")]?.frequency?.schedule(
1302
+ payload.optDouble("value", 0.0).coerceAtLeast(0.0),
1303
+ payload.optDouble("time", 0.0),
1304
+ payload.optString("action") == "frequencyRampToTime",
1305
+ )
1306
+ "gain" -> gains[payload.optString("nodeId")]?.set(payload.optDouble("value", 0.0).coerceAtLeast(0.0))
1307
+ "gainAtTime", "gainRampToTime" -> gains[payload.optString("nodeId")]?.schedule(
1308
+ payload.optDouble("value", 0.0).coerceAtLeast(0.0),
1309
+ payload.optDouble("time", 0.0),
1310
+ payload.optString("action") == "gainRampToTime",
1311
+ )
1312
+ "start", "stop" -> {
1313
+ val state = oscillators[payload.optString("nodeId")] ?: return
1314
+ ensureTrack()
1315
+ val active = payload.optString("action") == "start"
1316
+ val delay = (payload.optDouble("delay", 0.0).coerceAtLeast(0.0) * 1000).roundToLong()
1317
+ handler.postDelayed({ state.active = active }, delay)
1318
+ }
1319
+ "close" -> close()
1320
+ }
1321
+ }
1322
+
1323
+ @Synchronized fun close() {
1324
+ oscillators.values.forEach { it.active = false }
1325
+ oscillators.clear()
1326
+ gains.clear()
1327
+ contextStarts.clear()
1328
+ running = false
1329
+ renderThread?.interrupt()
1330
+ renderThread = null
1331
+ track?.let { output ->
1332
+ runCatching {
1333
+ output.pause()
1334
+ output.flush()
1335
+ output.release()
1336
+ }
1337
+ }
1338
+ track = null
1339
+ }
1340
+
1341
+ @Synchronized private fun ensureTrack() {
1342
+ if (track != null) return
1343
+ val sampleRate = 44_100
1344
+ val minBuffer = AudioTrack.getMinBufferSize(sampleRate, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT)
1345
+ val format = AudioFormat.Builder()
1346
+ .setSampleRate(sampleRate)
1347
+ .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
1348
+ .setChannelMask(AudioFormat.CHANNEL_OUT_STEREO)
1349
+ .build()
1350
+ val attributes = AudioAttributes.Builder()
1351
+ .setUsage(AudioAttributes.USAGE_MEDIA)
1352
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
1353
+ .build()
1354
+ val created = AudioTrack.Builder()
1355
+ .setAudioAttributes(attributes)
1356
+ .setAudioFormat(format)
1357
+ .setBufferSizeInBytes(max(minBuffer, sampleRate / 4))
1358
+ .setTransferMode(AudioTrack.MODE_STREAM)
1359
+ .build()
1360
+ track = created
1361
+ running = true
1362
+ created.play()
1363
+ renderThread = Thread({ render(created, sampleRate) }, "craft-native-audio").also { it.start() }
1364
+ }
1365
+
1366
+ private fun render(output: AudioTrack, sampleRate: Int) {
1367
+ val buffer = ShortArray(1024 * 2)
1368
+ while (running && !Thread.currentThread().isInterrupted) {
1369
+ val snapshot = synchronized(this) {
1370
+ oscillators.mapValues { (id, state) ->
1371
+ Triple(state, state.gain, state.active)
1372
+ }
1373
+ }
1374
+ val now = System.nanoTime() / 1_000_000_000.0
1375
+ for (frame in 0 until 1024) {
1376
+ var sample = 0.0
1377
+ snapshot.forEach { (_, value) ->
1378
+ val (state, gain, active) = value
1379
+ if (!active) return@forEach
1380
+ val time = (now - state.contextStart).coerceAtLeast(0.0) + frame.toDouble() / sampleRate
1381
+ val frequency = state.frequency.valueAt(time)
1382
+ sample += sin(state.phase) * gain.valueAt(time)
1383
+ state.phase += 2 * PI * frequency / sampleRate
1384
+ if (state.phase >= 2 * PI) state.phase -= 2 * PI
1385
+ }
1386
+ val value = (sample.coerceIn(-1.0, 1.0) * Short.MAX_VALUE).toInt().toShort()
1387
+ buffer[frame * 2] = value
1388
+ buffer[frame * 2 + 1] = value
1389
+ }
1390
+ if (output.write(buffer, 0, buffer.size, AudioTrack.WRITE_BLOCKING) < 0) break
1391
+ }
1392
+ }
1393
+ }
1394
+
820
1395
  /** WebView-free host for a route bundle compiled by `stx native compile`. */
821
1396
  class MainActivity : AppCompatActivity() {
822
1397
  private class Screen(
@@ -854,6 +1429,7 @@ class MainActivity : AppCompatActivity() {
854
1429
  val modalVisibility = mutableMapOf<View, Boolean>()
855
1430
  val modalCloseHandlers = mutableMapOf<View, String>()
856
1431
  val layoutHandlers = mutableMapOf<View, String>()
1432
+ val layoutListeners = mutableMapOf<View, View.OnLayoutChangeListener>()
857
1433
  val lastLayoutBounds = mutableMapOf<View, LayoutBounds>()
858
1434
  val flatListRows = mutableMapOf<CraftNativeFlatList, MutableMap<String, MutableMap<String, View>>>()
859
1435
  val flatListOwners = mutableMapOf<String, String>()
@@ -862,11 +1438,13 @@ class MainActivity : AppCompatActivity() {
862
1438
  val capabilityScope = UUID.randomUUID().toString()
863
1439
  val pendingCapabilities = mutableSetOf<String>()
864
1440
  val capabilityTimeouts = mutableMapOf<String, Runnable>()
1441
+ val audio = CraftNativeAudio()
865
1442
  var active = true
866
1443
 
867
1444
  fun close() {
868
1445
  if (!active) return
869
1446
  active = false
1447
+ audio.close()
870
1448
  val finish = {
871
1449
  port?.close()
872
1450
  isolate.close()
@@ -1040,6 +1618,80 @@ class MainActivity : AppCompatActivity() {
1040
1618
  globalThis.__stxNativeRoute = ${route?.let { JSONObject.quote(it) } ?: "undefined"};
1041
1619
  globalThis.__stxNativeParams = ${params};
1042
1620
  """.trimIndent()
1621
+ val audioPrelude = """
1622
+ (function() {
1623
+ if (typeof globalThis.AudioContext !== 'function') {
1624
+ var nextAudioId = 0;
1625
+ function audioMessage(payload) {
1626
+ globalThis.__stxNativeBridge.postMessage({ type: 'AUDIO', payload: payload });
1627
+ }
1628
+ function audioParam(contextId, nodeId, action, initial) {
1629
+ var current = Number(initial) || 0;
1630
+ var param = {};
1631
+ Object.defineProperty(param, 'value', {
1632
+ configurable: true,
1633
+ enumerable: true,
1634
+ get: function() { return current; },
1635
+ set: function(value) {
1636
+ current = Number(value) || 0;
1637
+ audioMessage({ action: action, contextId: contextId, nodeId: nodeId, value: current });
1638
+ }
1639
+ });
1640
+ function schedule(value, when, ramp) {
1641
+ current = Number(value) || 0;
1642
+ var time = Number(when);
1643
+ audioMessage({ action: action + (ramp ? 'RampToTime' : 'AtTime'), contextId: contextId, nodeId: nodeId, value: current, time: isFinite(time) ? Math.max(0, time) : 0 });
1644
+ }
1645
+ param.setValueAtTime = function(value, when) { schedule(value, when, false); return param; };
1646
+ param.linearRampToValueAtTime = function(value, when) { schedule(value, when, true); return param; };
1647
+ return param;
1648
+ }
1649
+ function AudioContext() {
1650
+ this._id = 'audio-' + (++nextAudioId);
1651
+ this._started = Date.now() / 1000;
1652
+ this.state = 'running';
1653
+ this.destination = { _contextId: this._id, _nodeId: 'destination' };
1654
+ audioMessage({ action: 'createContext', contextId: this._id });
1655
+ }
1656
+ Object.defineProperty(AudioContext.prototype, 'currentTime', {
1657
+ get: function() { return Date.now() / 1000 - this._started; }
1658
+ });
1659
+ AudioContext.prototype.createOscillator = function() {
1660
+ var context = this;
1661
+ var node = { _contextId: context._id, _nodeId: context._id + '-osc-' + (++nextAudioId) };
1662
+ node.frequency = audioParam(context._id, node._nodeId, 'frequency', 440);
1663
+ node.connect = function(destination) {
1664
+ audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
1665
+ return destination;
1666
+ };
1667
+ node.start = function(when) {
1668
+ audioMessage({ action: 'start', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
1669
+ };
1670
+ node.stop = function(when) {
1671
+ audioMessage({ action: 'stop', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
1672
+ };
1673
+ audioMessage({ action: 'createOscillator', contextId: context._id, nodeId: node._nodeId, frequency: node.frequency.value });
1674
+ return node;
1675
+ };
1676
+ AudioContext.prototype.createGain = function() {
1677
+ var context = this;
1678
+ var node = { _contextId: context._id, _nodeId: context._id + '-gain-' + (++nextAudioId) };
1679
+ node.gain = audioParam(context._id, node._nodeId, 'gain', 1);
1680
+ node.connect = function(destination) {
1681
+ audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
1682
+ return destination;
1683
+ };
1684
+ audioMessage({ action: 'createGain', contextId: context._id, nodeId: node._nodeId, gain: node.gain.value });
1685
+ return node;
1686
+ };
1687
+ AudioContext.prototype.resume = function() { this.state = 'running'; return Promise.resolve(); };
1688
+ AudioContext.prototype.suspend = function() { this.state = 'suspended'; return Promise.resolve(); };
1689
+ AudioContext.prototype.close = function() { this.state = 'closed'; audioMessage({ action: 'close', contextId: this._id }); return Promise.resolve(); };
1690
+ globalThis.AudioContext = AudioContext;
1691
+ globalThis.webkitAudioContext = AudioContext;
1692
+ }
1693
+ })()
1694
+ """.trimIndent()
1043
1695
  val prelude = if (useMessagePorts) """
1044
1696
  (async function() {
1045
1697
  const port = await android.getNamedPort('craft');
@@ -1055,6 +1707,7 @@ class MainActivity : AppCompatActivity() {
1055
1707
  postMessage: function(value) { port.postMessage(value); },
1056
1708
  onMessage: function(callback) { port.onmessage = function(event) { callback(event.data); }; }
1057
1709
  };
1710
+ $audioPrelude
1058
1711
  })()
1059
1712
  """.trimIndent() else """
1060
1713
  $routePrelude
@@ -1069,6 +1722,7 @@ class MainActivity : AppCompatActivity() {
1069
1722
  postMessage: function(value) { console.log(${JSONObject.quote(prefix)} + value); },
1070
1723
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
1071
1724
  };
1725
+ $audioPrelude
1072
1726
  """.trimIndent()
1073
1727
  val setup = isolate.evaluateJavaScriptAsync(prelude)
1074
1728
  setup.addListener({
@@ -1206,6 +1860,7 @@ class MainActivity : AppCompatActivity() {
1206
1860
  capabilities.cancel(token)
1207
1861
  }
1208
1862
  }
1863
+ "AUDIO" -> screen.audio.handle(payload)
1209
1864
  }
1210
1865
  }
1211
1866
 
@@ -1243,9 +1898,30 @@ class MainActivity : AppCompatActivity() {
1243
1898
  val updated = screen.controls["id:$target"] ?: return false
1244
1899
  if (!updateParentLayoutStyle(updated, document.optJSONObject("style") ?: JSONObject())) return false
1245
1900
  }
1901
+ if (applied.isNotEmpty()) {
1902
+ scheduleLayoutEvents(screen)
1903
+ }
1246
1904
  return true
1247
1905
  }
1248
1906
 
1907
+ private fun scheduleLayoutEvents(screen: Screen) {
1908
+ if (!screen.active) return
1909
+ val observer = screen.view.viewTreeObserver
1910
+ if (observer.isAlive) {
1911
+ val listener = object : ViewTreeObserver.OnGlobalLayoutListener {
1912
+ override fun onGlobalLayout() {
1913
+ if (observer.isAlive) observer.removeOnGlobalLayoutListener(this)
1914
+ if (screen.active) emitLayoutEvents(screen)
1915
+ }
1916
+ }
1917
+ observer.addOnGlobalLayoutListener(listener)
1918
+ }
1919
+ screen.view.requestLayout()
1920
+ // A tree that is already settled may not produce a global-layout
1921
+ // callback; keep the event path live for that case as well.
1922
+ screen.view.postOnAnimation { if (screen.active) emitLayoutEvents(screen) }
1923
+ }
1924
+
1249
1925
  /**
1250
1926
  * Targeted mutations bypass the parent's child reconciliation. Rebind the
1251
1927
  * updated node's layout style explicitly so removing a width/height (or
@@ -1482,16 +2158,39 @@ class MainActivity : AppCompatActivity() {
1482
2158
  transparent = props.optBoolean("transparent", false)
1483
2159
  content.configure(style, resources.displayMetrics.density)
1484
2160
  reconcileChildren(screen, content, children, path, next, style, previousControls)
1485
- val visible = props.optBoolean("visible", false) && style.optString("display") != "none"
2161
+ // Dynamic :visible bindings arrive as a display style mutation;
2162
+ // static visible props remain authoritative when present.
2163
+ val visible = if (props.has("visible")) {
2164
+ // An explicit visible prop owns modal visibility. The
2165
+ // binding runtime may leave a stale display:none style
2166
+ // from the previous hidden state when it patches props.
2167
+ props.optBoolean("visible", false)
2168
+ } else {
2169
+ style.has("display") && style.optString("display") != "none"
2170
+ }
1486
2171
  val wasVisible = screen.modalVisibility[this]
1487
2172
  screen.modalVisibility[this] = visible
1488
2173
  val requestClose = events.optString("onRequestClose")
1489
2174
  if (requestClose.isNotBlank()) screen.modalCloseHandlers[this] = requestClose else screen.modalCloseHandlers.remove(this)
2175
+ onRequestClose = if (requestClose.isNotBlank()) {
2176
+ { send(screen, "EVENT", JSONObject().put("handlerName", requestClose).put("nativeEvent", JSONObject())) }
2177
+ } else {
2178
+ null
2179
+ }
1490
2180
  setVisible(visible, style.optDouble("opacity", 1.0).toFloat(), props.optString("animationType").ifBlank { null })
2181
+ if (visible) clearDismissSuppression()
2182
+ val suppressDismiss = !visible && consumeDismissSuppression()
1491
2183
  if (wasVisible != visible && (visible || wasVisible == true)) {
1492
2184
  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()))
2185
+ if (lifecycleHandler.isNotBlank() && !suppressDismiss) {
2186
+ // Deliver lifecycle callbacks after reconciliation returns to
2187
+ // the queue; the handler may immediately schedule another
2188
+ // render against this native view.
2189
+ post {
2190
+ if (screen.active && screen.modalVisibility[this] == visible) {
2191
+ send(screen, "EVENT", JSONObject().put("handlerName", lifecycleHandler).put("nativeEvent", JSONObject()))
2192
+ }
2193
+ }
1495
2194
  }
1496
2195
  }
1497
2196
  }
@@ -1579,7 +2278,7 @@ class MainActivity : AppCompatActivity() {
1579
2278
  }
1580
2279
  })
1581
2280
  }
1582
- "ActivityIndicator" -> (previous as? android.widget.ProgressBar ?: android.widget.ProgressBar(this)).apply {
2281
+ "ActivityIndicator" -> (previous as? CraftNativeActivityIndicator ?: CraftNativeActivityIndicator(this)).apply {
1583
2282
  val requestedSize = props.opt("size")
1584
2283
  val scale = when (requestedSize) {
1585
2284
  "large" -> 1.35f
@@ -1613,6 +2312,19 @@ class MainActivity : AppCompatActivity() {
1613
2312
  if (onError.isNotBlank()) screen.imageErrorHandlers[this] = onError else screen.imageErrorHandlers.remove(this)
1614
2313
  updateImage(screen, this, props.opt("source"))
1615
2314
  }
2315
+ "Video" -> (previous as? CraftNativeVideoView ?: CraftNativeVideoView(this)).apply {
2316
+ configure(
2317
+ source = props.opt("source"),
2318
+ autoplay = props.optBoolean("autoplay", props.optBoolean("autoPlay", false)),
2319
+ paused = props.optBoolean("paused", false),
2320
+ loop = props.optBoolean("loop", false),
2321
+ muted = props.optBoolean("muted", false),
2322
+ controls = props.optBoolean("controls", false),
2323
+ )
2324
+ }
2325
+ "Svg" -> (previous as? CraftNativeSvgView ?: CraftNativeSvgView(this)).apply {
2326
+ drawing = node
2327
+ }
1616
2328
  "ScrollView" -> (previous as? CraftNativeScrollView ?: CraftNativeScrollView(this)).apply {
1617
2329
  val horizontal = props.optBoolean("horizontal", false) || style.optString("flexDirection") in setOf("row", "row-reverse")
1618
2330
  setHorizontal(horizontal)
@@ -1711,6 +2423,9 @@ class MainActivity : AppCompatActivity() {
1711
2423
  reconcileChildren(screen, this, children, path, next, style, previousControls)
1712
2424
  }
1713
2425
  }
2426
+ if (control is CraftNativeFlexLayout) {
2427
+ control.onLayoutChanged = { if (screen.active) emitLayoutEvents(screen) }
2428
+ }
1714
2429
  val pressInHandler = events.optString("onPressIn").takeIf { it.isNotBlank() }
1715
2430
  val pressOutHandler = events.optString("onPressOut").takeIf { it.isNotBlank() }
1716
2431
  if (pressInHandler != null) screen.pressInHandlers[control] = pressInHandler else screen.pressInHandlers.remove(control)
@@ -1786,9 +2501,11 @@ class MainActivity : AppCompatActivity() {
1786
2501
  if (layoutHandler.isNotBlank()) {
1787
2502
  if (screen.layoutHandlers[control] != layoutHandler) screen.lastLayoutBounds.remove(control)
1788
2503
  screen.layoutHandlers[control] = layoutHandler
2504
+ ensureLayoutListener(screen, control)
1789
2505
  } else {
1790
2506
  screen.layoutHandlers.remove(control)
1791
2507
  screen.lastLayoutBounds.remove(control)
2508
+ screen.layoutListeners.remove(control)?.let(control::removeOnLayoutChangeListener)
1792
2509
  }
1793
2510
  if (control is ImageView) imageErrors[control]?.let { message ->
1794
2511
  val current = control.contentDescription?.toString().orEmpty()
@@ -1807,7 +2524,6 @@ class MainActivity : AppCompatActivity() {
1807
2524
  View.VISIBLE
1808
2525
  }
1809
2526
  }
1810
- if (type == "Modal" && !props.optBoolean("visible", false)) control.visibility = View.GONE
1811
2527
  val width = style.optDouble("width", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
1812
2528
  val height = style.optDouble("height", Double.NaN).takeUnless { it.isNaN() }?.let(::dp)
1813
2529
  control.layoutParams = LinearLayout.LayoutParams(
@@ -1968,7 +2684,9 @@ class MainActivity : AppCompatActivity() {
1968
2684
 
1969
2685
  private fun applyViewStyle(view: View, style: JSONObject) {
1970
2686
  view.alpha = style.optDouble("opacity", 1.0).toFloat()
1971
- view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
2687
+ if (view !is CraftNativeModalView) {
2688
+ view.visibility = if (style.optString("display") == "none") View.GONE else View.VISIBLE
2689
+ }
1972
2690
  view.elevation = dp(style.optDouble("elevation", 0.0)).toFloat()
1973
2691
  val background = style.has("backgroundColor") && !style.isNull("backgroundColor")
1974
2692
  val border = style.optDouble("borderWidth", 0.0)
@@ -1997,6 +2715,15 @@ class MainActivity : AppCompatActivity() {
1997
2715
  }
1998
2716
  }
1999
2717
 
2718
+ private fun ensureLayoutListener(screen: Screen, view: View) {
2719
+ if (screen.layoutListeners.containsKey(view)) return
2720
+ val listener = View.OnLayoutChangeListener { changed, left, top, right, bottom, _, _, _, _ ->
2721
+ if (screen.active) emitLayoutEvent(screen, changed, left, top, right, bottom)
2722
+ }
2723
+ screen.layoutListeners[view] = listener
2724
+ view.addOnLayoutChangeListener(listener)
2725
+ }
2726
+
2000
2727
  private fun configureText(view: TextView, value: String, style: JSONObject) {
2001
2728
  view.text = when (style.optString("textTransform")) {
2002
2729
  "uppercase" -> value.uppercase()
@@ -2353,14 +3080,21 @@ class MainActivity : AppCompatActivity() {
2353
3080
  }
2354
3081
 
2355
3082
  private fun emitLayoutEvents(screen: Screen, view: View) {
3083
+ emitLayoutEvent(screen, view, view.left, view.top, view.right, view.bottom)
3084
+ if (view is ViewGroup) {
3085
+ for (index in 0 until view.childCount) emitLayoutEvents(screen, view.getChildAt(index))
3086
+ }
3087
+ }
3088
+
3089
+ private fun emitLayoutEvent(screen: Screen, view: View, left: Int, top: Int, right: Int, bottom: Int) {
2356
3090
  val handler = screen.layoutHandlers[view]
2357
3091
  if (handler != null && view.isShown) {
2358
3092
  val density = resources.displayMetrics.density
2359
3093
  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(),
3094
+ x = left.toDouble() / density.toDouble(),
3095
+ y = top.toDouble() / density.toDouble(),
3096
+ width = (right - left).toDouble() / density.toDouble(),
3097
+ height = (bottom - top).toDouble() / density.toDouble(),
2364
3098
  )
2365
3099
  if (screen.lastLayoutBounds[view] != bounds) {
2366
3100
  screen.lastLayoutBounds[view] = bounds
@@ -2373,9 +3107,6 @@ class MainActivity : AppCompatActivity() {
2373
3107
  .put("height", bounds.height))))
2374
3108
  }
2375
3109
  }
2376
- if (view is ViewGroup) {
2377
- for (index in 0 until view.childCount) emitLayoutEvents(screen, view.getChildAt(index))
2378
- }
2379
3110
  }
2380
3111
 
2381
3112
  private fun send(screen: Screen, type: String, payload: JSONObject, correlationId: String? = null) {
@@ -2440,6 +3171,8 @@ class MainActivity : AppCompatActivity() {
2440
3171
  releaseFlatList(screen, it, preservingInputDrafts)
2441
3172
  }
2442
3173
  released.filterIsInstance<CraftNativeModalView>().forEach { it.resetForTeardown() }
3174
+ released.filterIsInstance<CraftNativeVideoView>().forEach { it.stop() }
3175
+ released.filterIsInstance<CraftNativeFlexLayout>().forEach { it.onLayoutChanged = null }
2443
3176
  released.filterIsInstance<CraftNativeScrollView>().forEach { it.setRefreshHandler(null, false) }
2444
3177
  releaseImages(released)
2445
3178
  released.filterIsInstance<EditText>().forEach {
@@ -2455,6 +3188,10 @@ class MainActivity : AppCompatActivity() {
2455
3188
  screen.pressOutHandlers.keys.removeAll(released)
2456
3189
  screen.longPressHandlers.keys.removeAll(released)
2457
3190
  screen.layoutHandlers.keys.removeAll(released)
3191
+ screen.layoutListeners.forEach { (view, listener) ->
3192
+ if (view in released) view.removeOnLayoutChangeListener(listener)
3193
+ }
3194
+ screen.layoutListeners.keys.removeAll(released)
2458
3195
  screen.lastLayoutBounds.keys.removeAll(released)
2459
3196
  screen.scrollHandlers.keys.removeAll(released)
2460
3197
  screen.scrollBeginHandlers.keys.removeAll(released)
@@ -2515,9 +3252,7 @@ class MainActivity : AppCompatActivity() {
2515
3252
  val screen = screens.lastOrNull() ?: return back()
2516
3253
  val modal = screen.modalVisibility.entries.lastOrNull { (view, visible) -> visible && view.visibility == View.VISIBLE }
2517
3254
  if (modal != null) {
2518
- screen.modalCloseHandlers[modal.key]?.let { handler ->
2519
- send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", JSONObject()))
2520
- }
3255
+ (modal.key as? CraftNativeModalView)?.requestClose()
2521
3256
  return
2522
3257
  }
2523
3258
  back()