craft-native 0.0.139 → 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.
@@ -1,4 +1,7 @@
1
1
  import Foundation
2
+ import AVFoundation
3
+ import AVKit
4
+ import Darwin
2
5
  import JavaScriptCore
3
6
  import OSLog
4
7
  import SwiftUI
@@ -18,6 +21,477 @@ private func craftNativeGridColumnCount(_ value: Any?) -> Int {
18
21
  return max(1, tracks.count)
19
22
  }
20
23
 
24
+ /// Optional bridge to the shared Zig flex rule. Native hosts retain their
25
+ /// local implementation when no Craft runtime is linked (the normal SDK
26
+ /// fallback), while runtime-backed apps get one cross-platform calculation.
27
+ private enum CraftNativeFlexBridge {
28
+ private struct Item {
29
+ var basis: Float
30
+ var grow: Float
31
+ var shrink: Float
32
+ var min: Float
33
+ var max: Float
34
+ var marginStart: Float
35
+ var marginEnd: Float
36
+ }
37
+
38
+ private typealias Function = @convention(c) (
39
+ Float, Float, UInt32, UnsafeRawPointer, Int, UnsafeMutableRawPointer, UnsafeMutableRawPointer
40
+ ) -> Int32
41
+
42
+ private static let function: Function? = {
43
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_flex_row_justified") else { return nil }
44
+ return unsafeBitCast(symbol, to: Function.self)
45
+ }()
46
+
47
+ static func widths(available: CGFloat, gap: CGFloat, justify: UInt32, items: [(basis: CGFloat, grow: CGFloat, shrink: CGFloat, min: CGFloat?, max: CGFloat?, leading: CGFloat, trailing: CGFloat)]) -> [(width: CGFloat, position: CGFloat)]? {
48
+ guard let function, !items.isEmpty else { return nil }
49
+ var input = items.map { item in
50
+ Item(
51
+ basis: Float(item.basis), grow: Float(item.grow), shrink: Float(item.shrink),
52
+ min: item.min.map(Float.init) ?? .nan, max: item.max.map(Float.init) ?? .nan,
53
+ marginStart: Float(item.leading), marginEnd: Float(item.trailing)
54
+ )
55
+ }
56
+ var output = [Float](repeating: 0, count: input.count)
57
+ var positions = [Float](repeating: 0, count: input.count)
58
+ let status = input.withUnsafeBufferPointer { inputBuffer in
59
+ output.withUnsafeMutableBufferPointer { outputBuffer in
60
+ positions.withUnsafeMutableBufferPointer { positionBuffer in
61
+ function(Float(available), Float(gap), justify, UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!), UnsafeMutableRawPointer(positionBuffer.baseAddress!))
62
+ }
63
+ }
64
+ }
65
+ guard status == 0, output.allSatisfy({ $0.isFinite }), positions.allSatisfy({ $0.isFinite }) else { return nil }
66
+ return zip(output, positions).map { (width: CGFloat($0), position: CGFloat($1)) }
67
+ }
68
+ }
69
+
70
+ private func craftNativeJustifyCode(_ value: String) -> UInt32 {
71
+ switch value {
72
+ case "center": return 1
73
+ case "flex-end": return 2
74
+ case "space-between": return 3
75
+ case "space-around": return 4
76
+ case "space-evenly": return 5
77
+ default: return 0
78
+ }
79
+ }
80
+
81
+ private enum CraftNativeFlexLineBridge {
82
+ private struct Item {
83
+ var basis: Float
84
+ var marginStart: Float
85
+ var marginEnd: Float
86
+ }
87
+
88
+ private typealias Function = @convention(c) (
89
+ Float, Float, UInt32, UnsafeRawPointer, Int, UnsafeMutableRawPointer
90
+ ) -> Int32
91
+
92
+ private static let function: Function? = {
93
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_flex_line_breaks") else { return nil }
94
+ return unsafeBitCast(symbol, to: Function.self)
95
+ }()
96
+
97
+ static func lines(available: CGFloat, gap: CGFloat, wrap: Bool, items: [(basis: CGFloat, leading: CGFloat, trailing: CGFloat)]) -> [Int]? {
98
+ guard let function, !items.isEmpty else { return nil }
99
+ var input = items.map { Item(basis: Float($0.basis), marginStart: Float($0.leading), marginEnd: Float($0.trailing)) }
100
+ var output = [Int32](repeating: 0, count: input.count)
101
+ let status = input.withUnsafeBufferPointer { inputBuffer in
102
+ output.withUnsafeMutableBufferPointer { outputBuffer in
103
+ function(Float(available), Float(gap), wrap ? 1 : 0, UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
104
+ }
105
+ }
106
+ guard status > 0 else { return nil }
107
+ return output.map(Int.init)
108
+ }
109
+ }
110
+
111
+ private enum CraftNativeFlexLineDistributionBridge {
112
+ private typealias Function = @convention(c) (
113
+ Float, Float, UInt32, UnsafeRawPointer, Int, UnsafeMutableRawPointer, UnsafeMutableRawPointer
114
+ ) -> Int32
115
+
116
+ private static let function: Function? = {
117
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_flex_line_distribution") else { return nil }
118
+ return unsafeBitCast(symbol, to: Function.self)
119
+ }()
120
+
121
+ static func distribute(available: CGFloat, gap: CGFloat, alignment: UInt32, lineCrosses: [CGFloat]) -> (offsets: [CGFloat], sizes: [CGFloat])? {
122
+ guard let function, !lineCrosses.isEmpty else { return nil }
123
+ var input = lineCrosses.map(Float.init)
124
+ var offsets = [Float](repeating: 0, count: input.count)
125
+ var sizes = [Float](repeating: 0, count: input.count)
126
+ let status = input.withUnsafeBufferPointer { inputBuffer in
127
+ offsets.withUnsafeMutableBufferPointer { offsetBuffer in
128
+ sizes.withUnsafeMutableBufferPointer { sizeBuffer in
129
+ function(Float(available), Float(gap), alignment, UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(offsetBuffer.baseAddress!), UnsafeMutableRawPointer(sizeBuffer.baseAddress!))
130
+ }
131
+ }
132
+ }
133
+ guard status == 0, offsets.allSatisfy({ $0.isFinite }), sizes.allSatisfy({ $0.isFinite }) else { return nil }
134
+ return (offsets.map { CGFloat($0) }, sizes.map { CGFloat($0) })
135
+ }
136
+ }
137
+
138
+ /// Optional bridge to the shared cross-axis alignment rule. The local rule is
139
+ /// retained for runtimes built before this symbol was exported.
140
+ private enum CraftNativeCrossAxisBridge {
141
+ private typealias Function = @convention(c) (Float, Float, Float, Float, UInt32) -> Float
142
+
143
+ private static let function: Function? = {
144
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_cross_axis_position") else { return nil }
145
+ return unsafeBitCast(symbol, to: Function.self)
146
+ }()
147
+
148
+ static func position(available: CGFloat, size: CGFloat, leading: CGFloat, trailing: CGFloat, alignment: UInt32) -> CGFloat? {
149
+ guard let function else { return nil }
150
+ let result = function(Float(available), Float(size), Float(leading), Float(trailing), alignment)
151
+ return result.isFinite ? CGFloat(result) : nil
152
+ }
153
+ }
154
+
155
+ private enum CraftNativeGridBridge {
156
+ private struct Item {
157
+ var intrinsicWidth: Float
158
+ var intrinsicHeight: Float
159
+ var explicitWidth: Float
160
+ var explicitHeight: Float
161
+ var minWidth: Float
162
+ var maxWidth: Float
163
+ var minHeight: Float
164
+ var maxHeight: Float
165
+ var marginLeft: Float
166
+ var marginRight: Float
167
+ var marginTop: Float
168
+ var marginBottom: Float
169
+ var stretch: UInt32
170
+ var alignment: UInt32
171
+ }
172
+
173
+ private struct Frame {
174
+ var x: Float
175
+ var y: Float
176
+ var width: Float
177
+ var height: Float
178
+ }
179
+
180
+ private typealias Function = @convention(c) (
181
+ Float, Float, UInt32, Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer
182
+ ) -> Int32
183
+
184
+ private static let function: Function? = {
185
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid") else { return nil }
186
+ return unsafeBitCast(symbol, to: Function.self)
187
+ }()
188
+
189
+ static func frames(
190
+ availableWidth: CGFloat,
191
+ availableHeight: CGFloat,
192
+ columns: Int,
193
+ columnGap: CGFloat,
194
+ rowGap: CGFloat,
195
+ items: [(intrinsicWidth: CGFloat, intrinsicHeight: CGFloat, explicitWidth: CGFloat?, explicitHeight: CGFloat?, minWidth: CGFloat?, maxWidth: CGFloat?, minHeight: CGFloat?, maxHeight: CGFloat?, marginLeft: CGFloat, marginRight: CGFloat, marginTop: CGFloat, marginBottom: CGFloat, stretch: Bool, alignment: UInt32)]
196
+ ) -> [(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat)]? {
197
+ guard let function, !items.isEmpty else { return nil }
198
+ var input = items.map { item in
199
+ Item(
200
+ intrinsicWidth: Float(item.intrinsicWidth), intrinsicHeight: Float(item.intrinsicHeight),
201
+ explicitWidth: item.explicitWidth.map(Float.init) ?? .nan, explicitHeight: item.explicitHeight.map(Float.init) ?? .nan,
202
+ minWidth: item.minWidth.map(Float.init) ?? .nan, maxWidth: item.maxWidth.map(Float.init) ?? .nan,
203
+ minHeight: item.minHeight.map(Float.init) ?? .nan, maxHeight: item.maxHeight.map(Float.init) ?? .nan,
204
+ marginLeft: Float(item.marginLeft), marginRight: Float(item.marginRight),
205
+ marginTop: Float(item.marginTop), marginBottom: Float(item.marginBottom),
206
+ stretch: item.stretch ? 1 : 0, alignment: item.alignment
207
+ )
208
+ }
209
+ var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: input.count)
210
+ let status = input.withUnsafeBufferPointer { inputBuffer in
211
+ output.withUnsafeMutableBufferPointer { outputBuffer in
212
+ function(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
213
+ }
214
+ }
215
+ guard status == 0, output.allSatisfy({ frame in
216
+ frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite
217
+ }) else { return nil }
218
+ return output.map { (CGFloat($0.x), CGFloat($0.y), CGFloat($0.width), CGFloat($0.height)) }
219
+ }
220
+ }
221
+
222
+ private final class CraftNativeVideoView: UIView {
223
+ private let playerController = AVPlayerViewController()
224
+ private var player: AVPlayer?
225
+ private var sourceURI = ""
226
+ private var endObserver: NSObjectProtocol?
227
+ private var loops = false
228
+
229
+ init(host: UIViewController) {
230
+ super.init(frame: .zero)
231
+ clipsToBounds = true
232
+ host.addChild(playerController)
233
+ addSubview(playerController.view)
234
+ playerController.didMove(toParent: host)
235
+ }
236
+
237
+ required init?(coder: NSCoder) {
238
+ super.init(coder: coder)
239
+ clipsToBounds = true
240
+ addSubview(playerController.view)
241
+ }
242
+
243
+ override func layoutSubviews() {
244
+ super.layoutSubviews()
245
+ playerController.view.frame = bounds
246
+ }
247
+
248
+ func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, controls: Bool, resizeMode: String?) {
249
+ let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String ?? ""
250
+ if uri != sourceURI {
251
+ if let observer = endObserver { NotificationCenter.default.removeObserver(observer) }
252
+ endObserver = nil
253
+ player?.pause()
254
+ player = nil
255
+ sourceURI = uri
256
+ loops = false
257
+ guard let url = URL(string: uri), !uri.isEmpty, url.scheme != nil else {
258
+ playerController.player = nil
259
+ return
260
+ }
261
+ let next = AVPlayer(url: url)
262
+ player = next
263
+ playerController.player = next
264
+ }
265
+ guard let player else { return }
266
+ player.isMuted = muted
267
+ playerController.showsPlaybackControls = controls
268
+ playerController.videoGravity = resizeMode == "cover" ? .resizeAspectFill
269
+ : resizeMode == "stretch" ? .resize
270
+ : .resizeAspect
271
+ if loops != loop {
272
+ if let observer = endObserver { NotificationCenter.default.removeObserver(observer) }
273
+ endObserver = nil
274
+ loops = loop
275
+ if loop, let item = player.currentItem {
276
+ endObserver = NotificationCenter.default.addObserver(
277
+ forName: .AVPlayerItemDidPlayToEndTime,
278
+ object: item,
279
+ queue: .main
280
+ ) { [weak player] _ in
281
+ player?.seek(to: .zero) { _ in player?.play() }
282
+ }
283
+ }
284
+ }
285
+ if paused || !autoplay { player.pause() } else { player.play() }
286
+ }
287
+
288
+ func stop() {
289
+ player?.pause()
290
+ if let observer = endObserver { NotificationCenter.default.removeObserver(observer) }
291
+ endObserver = nil
292
+ player = nil
293
+ playerController.player = nil
294
+ sourceURI = ""
295
+ if playerController.parent != nil {
296
+ playerController.willMove(toParent: nil)
297
+ playerController.view.removeFromSuperview()
298
+ playerController.removeFromParent()
299
+ }
300
+ }
301
+
302
+ deinit { stop() }
303
+ }
304
+
305
+ /// The small Web Audio subset used by native STX screens: oscillator and gain
306
+ /// nodes connected to the context destination. JavaScriptCore has no Web Audio
307
+ /// implementation, so the screen installs a compatible surface and routes the
308
+ /// graph to one AVAudioEngine source node per oscillator.
309
+ private final class CraftNativeAudio {
310
+ private struct ParameterEvent {
311
+ let time: Double
312
+ let value: Double
313
+ let ramp: Bool
314
+ }
315
+
316
+ private final class Parameter {
317
+ private let lock = NSLock()
318
+ private var current: Double
319
+ private var events: [ParameterEvent] = []
320
+
321
+ init(_ value: Double) {
322
+ current = value
323
+ }
324
+
325
+ func set(_ value: Double) {
326
+ lock.lock(); defer { lock.unlock() }
327
+ current = value
328
+ events.removeAll()
329
+ }
330
+
331
+ func schedule(_ value: Double, at time: Double, ramp: Bool) {
332
+ lock.lock(); defer { lock.unlock() }
333
+ events.append(ParameterEvent(time: max(0, time), value: value, ramp: ramp))
334
+ events.sort { $0.time < $1.time }
335
+ }
336
+
337
+ func value(at time: Double) -> Double {
338
+ lock.lock(); defer { lock.unlock() }
339
+ var previousTime = 0.0
340
+ var previousValue = current
341
+ for event in events {
342
+ if time < event.time {
343
+ guard event.ramp, event.time > previousTime else { return previousValue }
344
+ let fraction = (time - previousTime) / (event.time - previousTime)
345
+ return previousValue + (event.value - previousValue) * fraction
346
+ }
347
+ previousTime = event.time
348
+ previousValue = event.value
349
+ }
350
+ return previousValue
351
+ }
352
+ }
353
+
354
+ private final class OscillatorState {
355
+ let lock = NSLock()
356
+ let frequency = Parameter(440)
357
+ var gain = Parameter(1)
358
+ var phase = 0.0
359
+ var active = false
360
+ let contextStart: TimeInterval
361
+
362
+ init(contextStart: TimeInterval) {
363
+ self.contextStart = contextStart
364
+ }
365
+ }
366
+
367
+ private let engine = AVAudioEngine()
368
+ private var format: AVAudioFormat?
369
+ private var sources: [String: (state: OscillatorState, node: AVAudioSourceNode)] = [:]
370
+ private var gains: [String: Parameter] = [:]
371
+ private var contextStarts: [String: TimeInterval] = [:]
372
+ private var closed = false
373
+
374
+ func handle(_ payload: [String: Any]) {
375
+ guard !closed, let action = payload["action"] as? String else { return }
376
+ switch action {
377
+ case "createContext":
378
+ closed = false
379
+ if let contextID = payload["contextId"] as? String {
380
+ contextStarts[contextID] = Date().timeIntervalSince1970
381
+ }
382
+ case "createOscillator":
383
+ guard let id = payload["nodeId"] as? String else { return }
384
+ let contextStart = (payload["contextId"] as? String).flatMap { contextStarts[$0] } ?? Date().timeIntervalSince1970
385
+ createOscillator(id: id, frequency: number(payload["frequency"]) ?? 440, contextStart: contextStart)
386
+ case "createGain":
387
+ guard let id = payload["nodeId"] as? String else { return }
388
+ gains[id] = Parameter(number(payload["gain"]) ?? 1)
389
+ case "connect":
390
+ guard let sourceID = payload["sourceId"] as? String else { return }
391
+ let destinationID = payload["destinationId"] as? String
392
+ if var source = sources[sourceID] {
393
+ source.state.lock.lock()
394
+ source.state.gain = destinationID.flatMap { gains[$0] } ?? Parameter(1)
395
+ source.state.lock.unlock()
396
+ sources[sourceID] = source
397
+ }
398
+ case "frequency":
399
+ guard let id = payload["nodeId"] as? String, let value = number(payload["value"]), let source = sources[id] else { return }
400
+ source.state.frequency.set(max(0, value))
401
+ case "frequencyAtTime", "frequencyRampToTime":
402
+ guard let id = payload["nodeId"] as? String, let value = number(payload["value"]), let time = number(payload["time"]), let source = sources[id] else { return }
403
+ source.state.frequency.schedule(max(0, value), at: time, ramp: action == "frequencyRampToTime")
404
+ case "gain":
405
+ guard let id = payload["nodeId"] as? String, let value = number(payload["value"]) else { return }
406
+ gains[id]?.set(max(0, value))
407
+ case "gainAtTime", "gainRampToTime":
408
+ guard let id = payload["nodeId"] as? String, let value = number(payload["value"]), let time = number(payload["time"]) else { return }
409
+ gains[id]?.schedule(max(0, value), at: time, ramp: action == "gainRampToTime")
410
+ case "start", "stop":
411
+ guard let id = payload["nodeId"] as? String, let source = sources[id] else { return }
412
+ if action == "start" { start(source.state, after: max(0, number(payload["delay"]) ?? 0)) }
413
+ else { stop(source.state, after: max(0, number(payload["delay"]) ?? 0)) }
414
+ case "close":
415
+ close()
416
+ default:
417
+ break
418
+ }
419
+ }
420
+
421
+ func close() {
422
+ guard !closed else { return }
423
+ closed = true
424
+ sources.values.forEach { source in
425
+ source.state.lock.lock(); source.state.active = false; source.state.lock.unlock()
426
+ engine.detach(source.node)
427
+ }
428
+ sources.removeAll()
429
+ gains.removeAll()
430
+ contextStarts.removeAll()
431
+ engine.stop()
432
+ }
433
+
434
+ private func createOscillator(id: String, frequency: Double, contextStart: TimeInterval) {
435
+ guard sources[id] == nil else { return }
436
+ guard let format = audioFormat() else { return }
437
+ let state = OscillatorState(contextStart: contextStart)
438
+ state.frequency.set(max(0, frequency))
439
+ let node = AVAudioSourceNode(format: format) { _, _, frameCount, audioBufferList in
440
+ let buffers = UnsafeMutableAudioBufferListPointer(audioBufferList)
441
+ state.lock.lock()
442
+ let active = state.active
443
+ let frequency = state.frequency
444
+ let gain = state.gain
445
+ var phase = state.phase
446
+ state.lock.unlock()
447
+ let startTime = max(0, Date().timeIntervalSince1970 - state.contextStart)
448
+ for buffer in buffers {
449
+ guard let data = buffer.mData else { continue }
450
+ let samples = data.assumingMemoryBound(to: Float.self)
451
+ for frame in 0..<Int(frameCount) {
452
+ let frameTime = startTime + Double(frame) / format.sampleRate
453
+ let frameFrequency = frequency.value(at: frameTime)
454
+ let frameGain = gain.value(at: frameTime)
455
+ samples[frame] = active ? Float(sin(phase) * frameGain) : 0
456
+ phase += 2 * Double.pi * frameFrequency / format.sampleRate
457
+ if phase >= 2 * Double.pi { phase -= 2 * Double.pi }
458
+ }
459
+ }
460
+ state.lock.lock(); state.phase = phase; state.lock.unlock()
461
+ return noErr
462
+ }
463
+ engine.attach(node)
464
+ engine.connect(node, to: engine.mainMixerNode, format: format)
465
+ sources[id] = (state, node)
466
+ }
467
+
468
+ private func audioFormat() -> AVAudioFormat? {
469
+ if let format { return format }
470
+ let created = AVAudioFormat(standardFormatWithSampleRate: 44_100, channels: 2)
471
+ format = created
472
+ return created
473
+ }
474
+
475
+ private func start(_ state: OscillatorState, after delay: Double) {
476
+ if !engine.isRunning {
477
+ try? engine.start()
478
+ }
479
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
480
+ state.lock.lock(); state.active = true; state.lock.unlock()
481
+ }
482
+ }
483
+
484
+ private func stop(_ state: OscillatorState, after delay: Double) {
485
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
486
+ state.lock.lock(); state.active = false; state.lock.unlock()
487
+ }
488
+ }
489
+
490
+ private func number(_ value: Any?) -> Double? {
491
+ (value as? NSNumber).map { $0.doubleValue }
492
+ }
493
+ }
494
+
21
495
  /// A WebView-free host for the first stx native vertical slice. The bundled
22
496
  /// JavaScript sends whole, compiled view trees to UIKit and receives control
23
497
  /// events and Craft API replies through JavaScriptCore.
@@ -249,6 +723,7 @@ private final class CraftNativeFlowView: UIStackView {
249
723
  var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
250
724
  var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
251
725
  var gridAutoRows: CGFloat? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
726
+ var reverse = false { didSet { if reverse != oldValue { invalidateMeasurements() } } }
252
727
  var padding = UIEdgeInsets.zero {
253
728
  didSet {
254
729
  layoutMargins = padding
@@ -572,44 +1047,73 @@ private final class CraftNativeFlowView: UIStackView {
572
1047
  // Lines: one, or as many as wrapping needs.
573
1048
  var lines: [Range<Int>] = []
574
1049
  let lineLimit = definiteMain ?? roomMain
575
- var start = 0
576
- var used: CGFloat = 0
577
- for index in items.indices {
578
- let size = items[index].basis + items[index].mainMargins
579
- let next = index == start ? size : used + mainGap + size
580
- if wrap, index > start, next > lineLimit {
1050
+ if let lineIds = CraftNativeFlexLineBridge.lines(
1051
+ available: lineLimit,
1052
+ gap: mainGap,
1053
+ wrap: wrap,
1054
+ items: items.map { ($0.basis, $0.mainLeading, $0.mainTrailing) }
1055
+ ) {
1056
+ var start = 0
1057
+ for index in 1..<items.count where lineIds[index] != lineIds[index - 1] {
581
1058
  lines.append(start..<index)
582
1059
  start = index
583
- used = size
584
- } else {
585
- used = next
586
1060
  }
1061
+ lines.append(start..<items.count)
1062
+ } else {
1063
+ var start = 0
1064
+ var used: CGFloat = 0
1065
+ for index in items.indices {
1066
+ let size = items[index].basis + items[index].mainMargins
1067
+ let next = index == start ? size : used + mainGap + size
1068
+ if wrap, index > start, next > lineLimit {
1069
+ lines.append(start..<index)
1070
+ start = index
1071
+ used = size
1072
+ } else {
1073
+ used = next
1074
+ }
1075
+ }
1076
+ if start < items.count { lines.append(start..<items.count) }
587
1077
  }
588
- if start < items.count { lines.append(start..<items.count) }
589
1078
 
590
1079
  var lineMains: [CGFloat] = []
591
1080
  var lineCrosses: [CGFloat] = []
1081
+ var sharedLines: [[(width: CGFloat, position: CGFloat)]?] = []
592
1082
  for line in lines {
593
1083
  let gaps = CGFloat(max(0, line.count - 1)) * mainGap
594
1084
  let hypothetical = line.reduce(0) { $0 + items[$1].basis + items[$1].mainMargins } + gaps
595
1085
  // A row sized by its content is as wide as that content, up to the
596
1086
  // room it has; a column sized by its content is as tall as it is.
597
1087
  let lineMain = definiteMain ?? (horizontal ? min(hypothetical, roomMain) : hypothetical)
598
- let free = lineMain - hypothetical
599
- if free > 0 {
600
- let total = line.reduce(0) { $0 + items[$1].grow }
601
- for index in line {
602
- let share = total > 0 ? free * items[index].grow / total : 0
603
- items[index].main = clamp(items[index].basis + share, items[index].minMain, items[index].maxMain)
604
- }
605
- } else if free < 0 {
606
- let total = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
607
- for index in line {
608
- let share = total > 0 ? -free * items[index].shrink * items[index].basis / total : 0
609
- items[index].main = clamp(items[index].basis - share, items[index].minMain, items[index].maxMain)
1088
+ let sharedWidths = CraftNativeFlexBridge.widths(
1089
+ available: lineMain,
1090
+ gap: mainGap,
1091
+ justify: craftNativeJustifyCode(justifyContent),
1092
+ items: line.map { index in
1093
+ let item = items[index]
1094
+ return (item.basis, item.grow, item.shrink, item.minMain, item.maxMain, item.mainLeading, item.mainTrailing)
610
1095
  }
1096
+ )
1097
+ sharedLines.append(sharedWidths)
1098
+ if let sharedWidths {
1099
+ for (offset, index) in line.enumerated() { items[index].main = sharedWidths[offset].width }
611
1100
  } else {
612
- for index in line { items[index].main = items[index].basis }
1101
+ let free = lineMain - hypothetical
1102
+ if free > 0 {
1103
+ let total = line.reduce(0) { $0 + items[$1].grow }
1104
+ for index in line {
1105
+ let share = total > 0 ? free * items[index].grow / total : 0
1106
+ items[index].main = clamp(items[index].basis + share, items[index].minMain, items[index].maxMain)
1107
+ }
1108
+ } else if free < 0 {
1109
+ let total = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
1110
+ for index in line {
1111
+ let share = total > 0 ? -free * items[index].shrink * items[index].basis / total : 0
1112
+ items[index].main = clamp(items[index].basis - share, items[index].minMain, items[index].maxMain)
1113
+ }
1114
+ } else {
1115
+ for index in line { items[index].main = items[index].basis }
1116
+ }
613
1117
  }
614
1118
 
615
1119
  var lineCross: CGFloat = 0
@@ -646,19 +1150,50 @@ private final class CraftNativeFlowView: UIStackView {
646
1150
  ?? (lineCrosses.reduce(0, +) + CGFloat(max(0, lineCrosses.count - 1)) * crossGap)
647
1151
  var crossLeading: CGFloat = 0
648
1152
  var crossBetween = crossGap
1153
+ var sharedCrossOffsets: [CGFloat]? = nil
649
1154
  if wrap, let definiteCross, lineCrosses.count > 1 {
650
- let free = max(0, definiteCross - (lineCrosses.reduce(0, +) + CGFloat(lineCrosses.count - 1) * crossGap))
651
- switch alignContent {
652
- case "center": crossLeading = free / 2
653
- case "flex-end": crossLeading = free
654
- case "space-between": crossBetween = free / CGFloat(lineCrosses.count - 1) + crossGap
655
- case "space-around":
656
- crossBetween = free / CGFloat(lineCrosses.count) + crossGap
657
- crossLeading = free / CGFloat(lineCrosses.count * 2)
658
- case "stretch":
659
- let extra = free / CGFloat(lineCrosses.count)
660
- lineCrosses = lineCrosses.map { $0 + extra }
661
- default: break
1155
+ let alignment: UInt32 = switch alignContent {
1156
+ case "center": 1
1157
+ case "flex-end": 2
1158
+ case "space-between": 3
1159
+ case "space-around": 4
1160
+ case "stretch": 5
1161
+ default: 0
1162
+ }
1163
+ if let distributed = CraftNativeFlexLineDistributionBridge.distribute(
1164
+ available: definiteCross,
1165
+ gap: crossGap,
1166
+ alignment: alignment,
1167
+ lineCrosses: lineCrosses
1168
+ ) {
1169
+ sharedCrossOffsets = distributed.offsets
1170
+ lineCrosses = distributed.sizes
1171
+ for (lineIndex, line) in lines.enumerated() {
1172
+ for index in line where items[index].fixedCross == nil && items[index].align == "stretch" {
1173
+ let item = items[index]
1174
+ items[index].cross = clamp(lineCrosses[lineIndex] - item.crossMargins, item.minCross, item.maxCross)
1175
+ }
1176
+ }
1177
+ } else {
1178
+ let free = max(0, definiteCross - (lineCrosses.reduce(0, +) + CGFloat(lineCrosses.count - 1) * crossGap))
1179
+ switch alignContent {
1180
+ case "center": crossLeading = free / 2
1181
+ case "flex-end": crossLeading = free
1182
+ case "space-between": crossBetween = free / CGFloat(lineCrosses.count - 1) + crossGap
1183
+ case "space-around":
1184
+ crossBetween = free / CGFloat(lineCrosses.count) + crossGap
1185
+ crossLeading = free / CGFloat(lineCrosses.count * 2)
1186
+ case "stretch":
1187
+ let extra = free / CGFloat(lineCrosses.count)
1188
+ lineCrosses = lineCrosses.map { $0 + extra }
1189
+ for (lineIndex, line) in lines.enumerated() {
1190
+ for index in line where items[index].fixedCross == nil && items[index].align == "stretch" {
1191
+ let item = items[index]
1192
+ items[index].cross = clamp(lineCrosses[lineIndex] - item.crossMargins, item.minCross, item.maxCross)
1193
+ }
1194
+ }
1195
+ default: break
1196
+ }
662
1197
  }
663
1198
  contentCross = definiteCross
664
1199
  }
@@ -679,23 +1214,36 @@ private final class CraftNativeFlowView: UIStackView {
679
1214
  default: leading = 0; between = 0
680
1215
  }
681
1216
  let lineCross = lineCrosses[lineIndex]
1217
+ let lineOffset = sharedCrossOffsets?[lineIndex] ?? crossOffset
1218
+ let sharedLine = sharedLines[lineIndex]
1219
+ let useSharedPositions = !reverse && sharedLine != nil
682
1220
  var mainOffset = leading
683
- for index in line {
1221
+ for (offset, index) in line.enumerated() {
684
1222
  let item = items[index]
685
- let crossPosition: CGFloat
686
- switch item.align {
687
- case "center": crossPosition = item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
688
- case "flex-end": crossPosition = lineCross - item.crossTrailing - item.cross
689
- default: crossPosition = item.crossLeading
690
- }
691
- let mainPosition = mainOffset + item.mainLeading
1223
+ let alignment = item.align == "center" ? UInt32(1) : item.align == "flex-end" ? UInt32(2) : UInt32(0)
1224
+ let crossPosition = CraftNativeCrossAxisBridge.position(
1225
+ available: lineCross,
1226
+ size: item.cross,
1227
+ leading: item.crossLeading,
1228
+ trailing: item.crossTrailing,
1229
+ alignment: alignment
1230
+ ) ?? {
1231
+ switch alignment {
1232
+ case 1: return item.crossLeading + (lineCross - item.crossMargins - item.cross) / 2
1233
+ case 2: return lineCross - item.crossTrailing - item.cross
1234
+ default: return item.crossLeading
1235
+ }
1236
+ }()
1237
+ let mainPosition = useSharedPositions
1238
+ ? (sharedLine?[offset].position ?? (mainOffset + item.mainLeading))
1239
+ : mainOffset + item.mainLeading
692
1240
  let frame = horizontal
693
- ? CGRect(x: padding.left + mainPosition, y: padding.top + crossOffset + crossPosition, width: item.main, height: item.cross)
694
- : CGRect(x: padding.left + crossOffset + crossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
1241
+ ? CGRect(x: padding.left + mainPosition, y: padding.top + lineOffset + crossPosition, width: item.main, height: item.cross)
1242
+ : CGRect(x: padding.left + lineOffset + crossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
695
1243
  frames.append((item.view, frame))
696
- mainOffset += item.mainMargins + item.main + mainGap + between
1244
+ if !useSharedPositions { mainOffset += item.mainMargins + item.main + mainGap + between }
697
1245
  }
698
- crossOffset += lineCross + crossBetween
1246
+ if sharedCrossOffsets == nil { crossOffset += lineCross + crossBetween }
699
1247
  }
700
1248
  }
701
1249
  return horizontal ? CGSize(width: contentMain, height: contentCross) : CGSize(width: contentCross, height: contentMain)
@@ -746,7 +1294,35 @@ private final class CraftNativeFlowView: UIStackView {
746
1294
  }
747
1295
  let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
748
1296
  let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
1297
+ let sharedFrames = CraftNativeGridBridge.frames(
1298
+ availableWidth: innerWidth ?? width,
1299
+ availableHeight: innerHeight ?? height,
1300
+ columns: columns,
1301
+ columnGap: horizontalGap,
1302
+ rowGap: verticalGap,
1303
+ items: children.enumerated().map { index, child in
1304
+ let style = child.craftLayoutStyle
1305
+ let align = style.alignSelf ?? alignItems
1306
+ let size = sizes[index]
1307
+ let alignment: UInt32 = align == "center" ? 1 : align == "flex-end" ? 2 : 0
1308
+ return (
1309
+ size.width, size.height,
1310
+ style.width?.resolve(cellWidth), style.height?.resolve(nil),
1311
+ style.minWidth?.resolve(cellWidth), style.maxWidth?.resolve(cellWidth),
1312
+ style.minHeight?.resolve(nil), style.maxHeight?.resolve(nil),
1313
+ style.marginLeft, style.marginRight, style.marginTop, style.marginBottom,
1314
+ align == "stretch", alignment
1315
+ )
1316
+ }
1317
+ )
749
1318
  if place {
1319
+ if let sharedFrames {
1320
+ for (index, child) in children.enumerated() {
1321
+ let frame = sharedFrames[index]
1322
+ frames.append((child, CGRect(x: padding.left + frame.x, y: padding.top + frame.y, width: frame.width, height: frame.height)))
1323
+ }
1324
+ return CGSize(width: width, height: innerHeight ?? height)
1325
+ }
750
1326
  var rowTop = padding.top
751
1327
  for (index, child) in children.enumerated() {
752
1328
  let style = child.craftLayoutStyle
@@ -775,6 +1351,7 @@ private final class CraftNativeModalView: UIView {
775
1351
  let contentStack = CraftNativeFlowView()
776
1352
  private let blocker = UIView()
777
1353
  private var renderedVisible = false
1354
+ private var dismissSuppressedByRequest = false
778
1355
  private var animationGeneration = 0
779
1356
  var onRequestClose: (() -> Void)?
780
1357
 
@@ -796,6 +1373,7 @@ private final class CraftNativeModalView: UIView {
796
1373
  required init?(coder: NSCoder) { nil }
797
1374
 
798
1375
  func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
1376
+ if visible { isUserInteractionEnabled = true }
799
1377
  if renderedVisible == visible && isHidden == !visible {
800
1378
  if visible, alpha != opacity { alpha = opacity }
801
1379
  return
@@ -850,10 +1428,27 @@ private final class CraftNativeModalView: UIView {
850
1428
  isHidden = true
851
1429
  transform = .identity
852
1430
  alpha = 0
1431
+ isUserInteractionEnabled = true
1432
+ dismissSuppressedByRequest = false
853
1433
  onRequestClose = nil
854
1434
  }
855
1435
 
856
- @objc private func blockerTapped() { onRequestClose?() }
1436
+ func requestClose() {
1437
+ guard let onRequestClose else { return }
1438
+ dismissSuppressedByRequest = true
1439
+ isUserInteractionEnabled = false
1440
+ onRequestClose()
1441
+ }
1442
+
1443
+ func consumeDismissSuppression() -> Bool {
1444
+ let suppressed = dismissSuppressedByRequest
1445
+ dismissSuppressedByRequest = false
1446
+ return suppressed
1447
+ }
1448
+
1449
+ func clearDismissSuppression() { dismissSuppressedByRequest = false }
1450
+
1451
+ @objc private func blockerTapped() { requestClose() }
857
1452
 
858
1453
  override func layoutSubviews() {
859
1454
  super.layoutSubviews()
@@ -1310,8 +1905,11 @@ private final class CraftNativeGradientLayer: CAGradientLayer {
1310
1905
  layer.follow(view)
1311
1906
  guard existing == nil else { return }
1312
1907
  view.layer.insertSublayer(layer, at: 0)
1908
+ // The layer's bounds, not the view's: UIKit does not promise KVO for
1909
+ // UIView.bounds, and a screen laid out by frame never reported it, so
1910
+ // every gradient stayed zero-sized. CALayer's own bounds always do.
1313
1911
  layer.observations = [
1314
- view.observe(\.bounds) { [weak layer] view, _ in layer?.follow(view) },
1912
+ view.layer.observe(\.bounds) { [weak layer, weak view] _, _ in if let view { layer?.follow(view) } },
1315
1913
  view.layer.observe(\.cornerRadius) { [weak layer, weak view] _, _ in if let view { layer?.follow(view) } },
1316
1914
  ]
1317
1915
  }
@@ -1529,6 +2127,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1529
2127
  /// A drawing's shapes are not views: a change to one redraws its `Svg`.
1530
2128
  private var svgOwners: [String: String] = [:]
1531
2129
  private let mutationDocument = CraftNativeMutationDocument()
2130
+ private let audio = CraftNativeAudio()
1532
2131
  private let capabilityScope = UUID().uuidString
1533
2132
  private var pendingCapabilityRequests = Set<String>()
1534
2133
  private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
@@ -1616,6 +2215,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1616
2215
  guard !runtimeDisposed else { return }
1617
2216
  runtimeDisposed = true
1618
2217
  jsContext.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
2218
+ audio.close()
1619
2219
  }
1620
2220
 
1621
2221
  private func refreshTraitDefaults() {
@@ -1899,6 +2499,76 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1899
2499
  keep('navigation', {
1900
2500
  setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
1901
2501
  });
2502
+ if (typeof globalThis.AudioContext !== 'function') {
2503
+ var nextAudioId = 0;
2504
+ function audioMessage(payload) {
2505
+ globalThis.__stxNativeBridge.postMessage({ type: 'AUDIO', payload: payload });
2506
+ }
2507
+ function audioParam(contextId, nodeId, action, initial) {
2508
+ var current = Number(initial) || 0;
2509
+ var param = {};
2510
+ Object.defineProperty(param, 'value', {
2511
+ configurable: true,
2512
+ enumerable: true,
2513
+ get: function() { return current; },
2514
+ set: function(value) {
2515
+ current = Number(value) || 0;
2516
+ audioMessage({ action: action, contextId: contextId, nodeId: nodeId, value: current });
2517
+ }
2518
+ });
2519
+ function schedule(value, when, ramp) {
2520
+ current = Number(value) || 0;
2521
+ var time = Number(when);
2522
+ audioMessage({ action: action + (ramp ? 'RampToTime' : 'AtTime'), contextId: contextId, nodeId: nodeId, value: current, time: isFinite(time) ? Math.max(0, time) : 0 });
2523
+ }
2524
+ param.setValueAtTime = function(value, when) { schedule(value, when, false); return param; };
2525
+ param.linearRampToValueAtTime = function(value, when) { schedule(value, when, true); return param; };
2526
+ return param;
2527
+ }
2528
+ function AudioContext() {
2529
+ this._id = 'audio-' + (++nextAudioId);
2530
+ this._started = Date.now() / 1000;
2531
+ this.state = 'running';
2532
+ this.destination = { _contextId: this._id, _nodeId: 'destination' };
2533
+ audioMessage({ action: 'createContext', contextId: this._id });
2534
+ }
2535
+ Object.defineProperty(AudioContext.prototype, 'currentTime', {
2536
+ get: function() { return Date.now() / 1000 - this._started; }
2537
+ });
2538
+ AudioContext.prototype.createOscillator = function() {
2539
+ var context = this;
2540
+ var node = { _contextId: context._id, _nodeId: context._id + '-osc-' + (++nextAudioId) };
2541
+ node.frequency = audioParam(context._id, node._nodeId, 'frequency', 440);
2542
+ node.connect = function(destination) {
2543
+ audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
2544
+ return destination;
2545
+ };
2546
+ node.start = function(when) {
2547
+ audioMessage({ action: 'start', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
2548
+ };
2549
+ node.stop = function(when) {
2550
+ audioMessage({ action: 'stop', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
2551
+ };
2552
+ audioMessage({ action: 'createOscillator', contextId: context._id, nodeId: node._nodeId, frequency: node.frequency.value });
2553
+ return node;
2554
+ };
2555
+ AudioContext.prototype.createGain = function() {
2556
+ var context = this;
2557
+ var node = { _contextId: context._id, _nodeId: context._id + '-gain-' + (++nextAudioId) };
2558
+ node.gain = audioParam(context._id, node._nodeId, 'gain', 1);
2559
+ node.connect = function(destination) {
2560
+ audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
2561
+ return destination;
2562
+ };
2563
+ audioMessage({ action: 'createGain', contextId: context._id, nodeId: node._nodeId, gain: node.gain.value });
2564
+ return node;
2565
+ };
2566
+ AudioContext.prototype.resume = function() { this.state = 'running'; return Promise.resolve(); };
2567
+ AudioContext.prototype.suspend = function() { this.state = 'suspended'; return Promise.resolve(); };
2568
+ AudioContext.prototype.close = function() { this.state = 'closed'; audioMessage({ action: 'close', contextId: this._id }); return Promise.resolve(); };
2569
+ globalThis.AudioContext = AudioContext;
2570
+ globalThis.webkitAudioContext = AudioContext;
2571
+ }
1902
2572
  })();
1903
2573
  """)
1904
2574
  }
@@ -2328,6 +2998,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2328
2998
  pendingCapabilityRequests.remove(requestToken)
2329
2999
  pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
2330
3000
  CraftNativeActions.cancel(requestToken: requestToken)
3001
+ case "AUDIO":
3002
+ audio.handle(payload)
2331
3003
  default:
2332
3004
  break
2333
3005
  }
@@ -2399,7 +3071,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2399
3071
  child.removeFromSuperview()
2400
3072
  }
2401
3073
  if next.view.superview !== rootStack { rootStack.addArrangedSubview(next.view) }
2402
- rootStack.setLayoutStyle(next.style, for: next.view)
3074
+ // A native screen root fills the safe-area viewport unless the bundle
3075
+ // explicitly sizes it. Without this default, an unstyled SafeAreaView
3076
+ // measures to its scroll content and leaves no viewport to scroll.
3077
+ var rootStyle = next.style
3078
+ if rootStyle["flex"] == nil, rootStyle["height"] == nil { rootStyle["flex"] = 1 }
3079
+ rootStack.setLayoutStyle(rootStyle, for: next.view)
2403
3080
  rootStack.invalidateMeasurements()
2404
3081
  renderedRoot = next
2405
3082
  trackContentScrollView()
@@ -2562,7 +3239,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2562
3239
  case "Modal":
2563
3240
  let modal = result as! CraftNativeModalView
2564
3241
  modal.transparent = props["transparent"] as? Bool == true
2565
- let visible = props["visible"] as? Bool == true && style["display"] as? String != "none"
3242
+ let visible: Bool
3243
+ if let propVisible = props["visible"] as? Bool {
3244
+ // An explicit visible prop owns modal visibility. The
3245
+ // binding runtime may leave a stale display:none style
3246
+ // from the previous hidden state when it patches props.
3247
+ visible = propVisible
3248
+ } else {
3249
+ let display = style["display"] as? String
3250
+ visible = display != nil && display != "none"
3251
+ }
2566
3252
  let modalID = ObjectIdentifier(modal)
2567
3253
  let wasVisible = modalVisibility[modalID]
2568
3254
  modalVisibility[modalID] = visible
@@ -2570,9 +3256,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2570
3256
  modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
2571
3257
  { [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
2572
3258
  }
3259
+ if visible { modal.clearDismissSuppression() }
3260
+ let suppressDismiss = !visible && modal.consumeDismissSuppression()
2573
3261
  if wasVisible != visible, visible || wasVisible == true {
2574
3262
  let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
2575
- if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
3263
+ if let lifecycleHandler = nonEmptyHandler(lifecycleHandler), !suppressDismiss {
2576
3264
  send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
2577
3265
  }
2578
3266
  }
@@ -2663,6 +3351,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2663
3351
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
2664
3352
  updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
2665
3353
  updateImage(image, source: props["source"])
3354
+ case "Video":
3355
+ let video = result as! CraftNativeVideoView
3356
+ let autoplay = (props["autoplay"] as? Bool) == true || (props["autoPlay"] as? Bool) == true
3357
+ video.configure(
3358
+ source: props["source"],
3359
+ autoplay: autoplay,
3360
+ paused: (props["paused"] as? Bool) == true,
3361
+ loop: (props["loop"] as? Bool) == true,
3362
+ muted: (props["muted"] as? Bool) == true,
3363
+ controls: (props["controls"] as? Bool) == true,
3364
+ resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String
3365
+ )
2666
3366
  case "ScrollView":
2667
3367
  let scroll = result as! CraftNativeScrollView
2668
3368
  let flexDirection = style["flexDirection"] as? String
@@ -2744,7 +3444,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2744
3444
  return textView
2745
3445
  }
2746
3446
  let field = UITextField()
2747
- field.borderStyle = .roundedRect
2748
3447
  field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
2749
3448
  field.addTarget(self, action: #selector(textFocused(_:)), for: .editingDidBegin)
2750
3449
  field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
@@ -2770,6 +3469,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2770
3469
  return UIActivityIndicatorView(style: .medium)
2771
3470
  case "Image":
2772
3471
  return UIImageView()
3472
+ case "Video":
3473
+ return CraftNativeVideoView(host: self)
2773
3474
  case "Icon":
2774
3475
  let icon = CraftNativeIconView()
2775
3476
  icon.contentMode = .scaleAspectFit
@@ -2790,7 +3491,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2790
3491
  }
2791
3492
 
2792
3493
  private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
2793
- // The current stx native compiler emits key in props; accept the IR
3494
+ // The current @stacksjs/stx native compiler emits key in props; accept the IR
2794
3495
  // field too so keyed children keep working when the compiler adopts it.
2795
3496
  [node["id"], node["key"], props["key"], props["testID"]]
2796
3497
  .compactMap { $0 as? String }
@@ -3013,6 +3714,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3013
3714
  imageLoadHandlers.removeValue(forKey: id)
3014
3715
  imageLoadEndHandlers.removeValue(forKey: id)
3015
3716
  imageErrorHandlers.removeValue(forKey: id)
3717
+ if let video = node.view as? CraftNativeVideoView { video.stop() }
3016
3718
  for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
3017
3719
  }
3018
3720
 
@@ -3259,6 +3961,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3259
3961
  private func configureStack(_ stack: CraftNativeFlowView, style: [String: Any]) {
3260
3962
  let direction = style["flexDirection"] as? String
3261
3963
  stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
3964
+ stack.reverse = direction?.hasSuffix("-reverse") == true
3262
3965
  stack.wrap = style["flexWrap"] as? String == "wrap" || style["flexWrap"] as? Bool == true
3263
3966
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
3264
3967
  stack.alignContent = style["alignContent"] as? String ?? "flex-start"
@@ -3282,6 +3985,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3282
3985
  updateDimension(width, constraint: &node.widthConstraint, anchor: view.widthAnchor)
3283
3986
  updateDimension(height, constraint: &node.heightConstraint, anchor: view.heightAnchor)
3284
3987
  view.backgroundColor = color(style["backgroundColor"]) ?? .clear
3988
+ // A field the style dresses itself (`border-0`, a border or a
3989
+ // background, as a search field inside its own pill is) draws no
3990
+ // system box of its own; a bare one keeps the rounded rectangle.
3991
+ if let field = view as? UITextField {
3992
+ field.borderStyle = style["borderWidth"] != nil || style["backgroundColor"] != nil ? .none : .roundedRect
3993
+ }
3285
3994
  CraftNativeGradientLayer.apply(
3286
3995
  to: view,
3287
3996
  direction: style["gradientDirection"] as? String,