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.
- package/dist/android/templates/CraftNative.kt.template +137 -0
- package/dist/android/templates/MainActivityNative.kt.template +854 -87
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.js +1 -0
- package/dist/ios/templates/CraftApp.swift +14 -482
- package/dist/ios/templates/CraftHealth.swift +551 -0
- package/dist/ios/templates/CraftNativeActions.swift +11 -0
- package/dist/ios/templates/CraftNativeScreen.swift +798 -62
- package/package.json +1 -1
|
@@ -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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
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
|
|
651
|
-
|
|
652
|
-
case "
|
|
653
|
-
case "
|
|
654
|
-
case "space-
|
|
655
|
-
case "
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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 +
|
|
694
|
-
: CGRect(x: padding.left +
|
|
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
|
-
|
|
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
|
|
1201
|
-
guard
|
|
1202
|
-
|
|
1203
|
-
context.
|
|
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
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
if let dashes = own.dashes, !dashes.isEmpty {
|
|
1233
|
-
|
|
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
|
-
|
|
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
|
|
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"
|