craft-native 0.0.140 → 0.0.142

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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()
@@ -1194,14 +1789,36 @@ private final class CraftNativeSvgView: UIView {
1194
1789
  }
1195
1790
  }
1196
1791
 
1792
+ /// viewBox units to the view's points, set by each draw.
1793
+ private var viewTransform = CGAffineTransform.identity
1794
+
1795
+ /// viewBox to view: centred at one scale (SVG's default, `xMidYMid meet`),
1796
+ /// or each axis stretched to fill (`preserveAspectRatio="none"`), as a
1797
+ /// chart a page sizes to its width and a fixed height asks for.
1798
+ static func transform(box: CGRect, bounds: CGSize, preserveAspectRatio: String?) -> CGAffineTransform? {
1799
+ var sx = bounds.width / box.width
1800
+ var sy = bounds.height / box.height
1801
+ var dx: CGFloat = 0
1802
+ var dy: CGFloat = 0
1803
+ if preserveAspectRatio?.trimmingCharacters(in: .whitespaces) != "none" {
1804
+ let scale = min(sx, sy)
1805
+ sx = scale
1806
+ sy = scale
1807
+ dx = (bounds.width - box.width * scale) / 2
1808
+ dy = (bounds.height - box.height * scale) / 2
1809
+ }
1810
+ guard sx.isFinite, sy.isFinite, sx > 0, sy > 0 else { return nil }
1811
+ return CGAffineTransform(translationX: dx, y: dy).scaledBy(x: sx, y: sy).translatedBy(x: -box.minX, y: -box.minY)
1812
+ }
1813
+
1197
1814
  override func draw(_ rect: CGRect) {
1198
1815
  guard let context = UIGraphicsGetCurrentContext() else { return }
1816
+ viewTransform = .identity
1199
1817
  if let box = viewBox {
1200
- let scale = min(bounds.width / box.width, bounds.height / box.height)
1201
- guard scale.isFinite, scale > 0 else { return }
1202
- context.translateBy(x: (bounds.width - box.width * scale) / 2, y: (bounds.height - box.height * scale) / 2)
1203
- context.scaleBy(x: scale, y: scale)
1204
- context.translateBy(x: -box.minX, y: -box.minY)
1818
+ let props = drawing["props"] as? [String: Any] ?? [:]
1819
+ guard let transform = CraftNativeSvgView.transform(box: box, bounds: bounds.size, preserveAspectRatio: props["preserveAspectRatio"] as? String) else { return }
1820
+ viewTransform = transform
1821
+ context.concatenate(transform)
1205
1822
  }
1206
1823
  draw(children: drawing["children"] as? [Any] ?? [], paint: Paint().merged(drawing), in: context)
1207
1824
  }
@@ -1225,12 +1842,25 @@ private final class CraftNativeSvgView: UIView {
1225
1842
  path.fill()
1226
1843
  }
1227
1844
  if let stroke = own.resolve(own.stroke), own.strokeWidth > 0 {
1845
+ // `vector-effect="non-scaling-stroke"`: the stroke is in the
1846
+ // view's points, so a stretched chart's line keeps its width
1847
+ // and a round cap stays a round dot.
1848
+ let nonScaling = (shape["props"] as? [String: Any])?["vectorEffect"] as? String == "non-scaling-stroke"
1849
+ let line: UIBezierPath
1850
+ if nonScaling, let copy = path.copy() as? UIBezierPath {
1851
+ copy.apply(viewTransform)
1852
+ context.concatenate(viewTransform.inverted())
1853
+ line = copy
1854
+ }
1855
+ else {
1856
+ line = path
1857
+ }
1228
1858
  stroke.setStroke()
1229
- path.lineWidth = own.strokeWidth
1230
- path.lineCapStyle = own.linecap == "round" ? .round : own.linecap == "square" ? .square : .butt
1231
- path.lineJoinStyle = own.linejoin == "round" ? .round : own.linejoin == "bevel" ? .bevel : .miter
1232
- if let dashes = own.dashes, !dashes.isEmpty { path.setLineDash(dashes, count: dashes.count, phase: 0) }
1233
- path.stroke()
1859
+ line.lineWidth = own.strokeWidth
1860
+ line.lineCapStyle = own.linecap == "round" ? .round : own.linecap == "square" ? .square : .butt
1861
+ line.lineJoinStyle = own.linejoin == "round" ? .round : own.linejoin == "bevel" ? .bevel : .miter
1862
+ if let dashes = own.dashes, !dashes.isEmpty { line.setLineDash(dashes, count: dashes.count, phase: 0) }
1863
+ line.stroke()
1234
1864
  }
1235
1865
  context.restoreGState()
1236
1866
  }
@@ -1532,6 +2162,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1532
2162
  /// A drawing's shapes are not views: a change to one redraws its `Svg`.
1533
2163
  private var svgOwners: [String: String] = [:]
1534
2164
  private let mutationDocument = CraftNativeMutationDocument()
2165
+ private let audio = CraftNativeAudio()
1535
2166
  private let capabilityScope = UUID().uuidString
1536
2167
  private var pendingCapabilityRequests = Set<String>()
1537
2168
  private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
@@ -1619,6 +2250,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1619
2250
  guard !runtimeDisposed else { return }
1620
2251
  runtimeDisposed = true
1621
2252
  jsContext.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
2253
+ audio.close()
1622
2254
  }
1623
2255
 
1624
2256
  private func refreshTraitDefaults() {
@@ -1902,6 +2534,76 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
1902
2534
  keep('navigation', {
1903
2535
  setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
1904
2536
  });
2537
+ if (typeof globalThis.AudioContext !== 'function') {
2538
+ var nextAudioId = 0;
2539
+ function audioMessage(payload) {
2540
+ globalThis.__stxNativeBridge.postMessage({ type: 'AUDIO', payload: payload });
2541
+ }
2542
+ function audioParam(contextId, nodeId, action, initial) {
2543
+ var current = Number(initial) || 0;
2544
+ var param = {};
2545
+ Object.defineProperty(param, 'value', {
2546
+ configurable: true,
2547
+ enumerable: true,
2548
+ get: function() { return current; },
2549
+ set: function(value) {
2550
+ current = Number(value) || 0;
2551
+ audioMessage({ action: action, contextId: contextId, nodeId: nodeId, value: current });
2552
+ }
2553
+ });
2554
+ function schedule(value, when, ramp) {
2555
+ current = Number(value) || 0;
2556
+ var time = Number(when);
2557
+ audioMessage({ action: action + (ramp ? 'RampToTime' : 'AtTime'), contextId: contextId, nodeId: nodeId, value: current, time: isFinite(time) ? Math.max(0, time) : 0 });
2558
+ }
2559
+ param.setValueAtTime = function(value, when) { schedule(value, when, false); return param; };
2560
+ param.linearRampToValueAtTime = function(value, when) { schedule(value, when, true); return param; };
2561
+ return param;
2562
+ }
2563
+ function AudioContext() {
2564
+ this._id = 'audio-' + (++nextAudioId);
2565
+ this._started = Date.now() / 1000;
2566
+ this.state = 'running';
2567
+ this.destination = { _contextId: this._id, _nodeId: 'destination' };
2568
+ audioMessage({ action: 'createContext', contextId: this._id });
2569
+ }
2570
+ Object.defineProperty(AudioContext.prototype, 'currentTime', {
2571
+ get: function() { return Date.now() / 1000 - this._started; }
2572
+ });
2573
+ AudioContext.prototype.createOscillator = function() {
2574
+ var context = this;
2575
+ var node = { _contextId: context._id, _nodeId: context._id + '-osc-' + (++nextAudioId) };
2576
+ node.frequency = audioParam(context._id, node._nodeId, 'frequency', 440);
2577
+ node.connect = function(destination) {
2578
+ audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
2579
+ return destination;
2580
+ };
2581
+ node.start = function(when) {
2582
+ audioMessage({ action: 'start', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
2583
+ };
2584
+ node.stop = function(when) {
2585
+ audioMessage({ action: 'stop', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
2586
+ };
2587
+ audioMessage({ action: 'createOscillator', contextId: context._id, nodeId: node._nodeId, frequency: node.frequency.value });
2588
+ return node;
2589
+ };
2590
+ AudioContext.prototype.createGain = function() {
2591
+ var context = this;
2592
+ var node = { _contextId: context._id, _nodeId: context._id + '-gain-' + (++nextAudioId) };
2593
+ node.gain = audioParam(context._id, node._nodeId, 'gain', 1);
2594
+ node.connect = function(destination) {
2595
+ audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
2596
+ return destination;
2597
+ };
2598
+ audioMessage({ action: 'createGain', contextId: context._id, nodeId: node._nodeId, gain: node.gain.value });
2599
+ return node;
2600
+ };
2601
+ AudioContext.prototype.resume = function() { this.state = 'running'; return Promise.resolve(); };
2602
+ AudioContext.prototype.suspend = function() { this.state = 'suspended'; return Promise.resolve(); };
2603
+ AudioContext.prototype.close = function() { this.state = 'closed'; audioMessage({ action: 'close', contextId: this._id }); return Promise.resolve(); };
2604
+ globalThis.AudioContext = AudioContext;
2605
+ globalThis.webkitAudioContext = AudioContext;
2606
+ }
1905
2607
  })();
1906
2608
  """)
1907
2609
  }
@@ -2331,6 +3033,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2331
3033
  pendingCapabilityRequests.remove(requestToken)
2332
3034
  pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
2333
3035
  CraftNativeActions.cancel(requestToken: requestToken)
3036
+ case "AUDIO":
3037
+ audio.handle(payload)
2334
3038
  default:
2335
3039
  break
2336
3040
  }
@@ -2402,7 +3106,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2402
3106
  child.removeFromSuperview()
2403
3107
  }
2404
3108
  if next.view.superview !== rootStack { rootStack.addArrangedSubview(next.view) }
2405
- rootStack.setLayoutStyle(next.style, for: next.view)
3109
+ // A native screen root fills the safe-area viewport unless the bundle
3110
+ // explicitly sizes it. Without this default, an unstyled SafeAreaView
3111
+ // measures to its scroll content and leaves no viewport to scroll.
3112
+ var rootStyle = next.style
3113
+ if rootStyle["flex"] == nil, rootStyle["height"] == nil { rootStyle["flex"] = 1 }
3114
+ rootStack.setLayoutStyle(rootStyle, for: next.view)
2406
3115
  rootStack.invalidateMeasurements()
2407
3116
  renderedRoot = next
2408
3117
  trackContentScrollView()
@@ -2565,7 +3274,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2565
3274
  case "Modal":
2566
3275
  let modal = result as! CraftNativeModalView
2567
3276
  modal.transparent = props["transparent"] as? Bool == true
2568
- let visible = props["visible"] as? Bool == true && style["display"] as? String != "none"
3277
+ let visible: Bool
3278
+ if let propVisible = props["visible"] as? Bool {
3279
+ // An explicit visible prop owns modal visibility. The
3280
+ // binding runtime may leave a stale display:none style
3281
+ // from the previous hidden state when it patches props.
3282
+ visible = propVisible
3283
+ } else {
3284
+ let display = style["display"] as? String
3285
+ visible = display != nil && display != "none"
3286
+ }
2569
3287
  let modalID = ObjectIdentifier(modal)
2570
3288
  let wasVisible = modalVisibility[modalID]
2571
3289
  modalVisibility[modalID] = visible
@@ -2573,9 +3291,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2573
3291
  modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
2574
3292
  { [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
2575
3293
  }
3294
+ if visible { modal.clearDismissSuppression() }
3295
+ let suppressDismiss = !visible && modal.consumeDismissSuppression()
2576
3296
  if wasVisible != visible, visible || wasVisible == true {
2577
3297
  let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
2578
- if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
3298
+ if let lifecycleHandler = nonEmptyHandler(lifecycleHandler), !suppressDismiss {
2579
3299
  send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
2580
3300
  }
2581
3301
  }
@@ -2666,6 +3386,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2666
3386
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
2667
3387
  updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
2668
3388
  updateImage(image, source: props["source"])
3389
+ case "Video":
3390
+ let video = result as! CraftNativeVideoView
3391
+ let autoplay = (props["autoplay"] as? Bool) == true || (props["autoPlay"] as? Bool) == true
3392
+ video.configure(
3393
+ source: props["source"],
3394
+ autoplay: autoplay,
3395
+ paused: (props["paused"] as? Bool) == true,
3396
+ loop: (props["loop"] as? Bool) == true,
3397
+ muted: (props["muted"] as? Bool) == true,
3398
+ controls: (props["controls"] as? Bool) == true,
3399
+ resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String
3400
+ )
2669
3401
  case "ScrollView":
2670
3402
  let scroll = result as! CraftNativeScrollView
2671
3403
  let flexDirection = style["flexDirection"] as? String
@@ -2772,6 +3504,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2772
3504
  return UIActivityIndicatorView(style: .medium)
2773
3505
  case "Image":
2774
3506
  return UIImageView()
3507
+ case "Video":
3508
+ return CraftNativeVideoView(host: self)
2775
3509
  case "Icon":
2776
3510
  let icon = CraftNativeIconView()
2777
3511
  icon.contentMode = .scaleAspectFit
@@ -2792,7 +3526,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2792
3526
  }
2793
3527
 
2794
3528
  private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
2795
- // The current stx native compiler emits key in props; accept the IR
3529
+ // The current @stacksjs/stx native compiler emits key in props; accept the IR
2796
3530
  // field too so keyed children keep working when the compiler adopts it.
2797
3531
  [node["id"], node["key"], props["key"], props["testID"]]
2798
3532
  .compactMap { $0 as? String }
@@ -3015,6 +3749,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3015
3749
  imageLoadHandlers.removeValue(forKey: id)
3016
3750
  imageLoadEndHandlers.removeValue(forKey: id)
3017
3751
  imageErrorHandlers.removeValue(forKey: id)
3752
+ if let video = node.view as? CraftNativeVideoView { video.stop() }
3018
3753
  for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
3019
3754
  }
3020
3755
 
@@ -3261,6 +3996,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3261
3996
  private func configureStack(_ stack: CraftNativeFlowView, style: [String: Any]) {
3262
3997
  let direction = style["flexDirection"] as? String
3263
3998
  stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
3999
+ stack.reverse = direction?.hasSuffix("-reverse") == true
3264
4000
  stack.wrap = style["flexWrap"] as? String == "wrap" || style["flexWrap"] as? Bool == true
3265
4001
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
3266
4002
  stack.alignContent = style["alignContent"] as? String ?? "flex-start"