craft-native 0.0.139 → 0.0.141
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/android/templates/CraftNative.kt.template +137 -0
- package/dist/android/templates/MainActivityNative.kt.template +822 -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 +763 -54
- 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()
|
|
@@ -1310,8 +1905,11 @@ private final class CraftNativeGradientLayer: CAGradientLayer {
|
|
|
1310
1905
|
layer.follow(view)
|
|
1311
1906
|
guard existing == nil else { return }
|
|
1312
1907
|
view.layer.insertSublayer(layer, at: 0)
|
|
1908
|
+
// The layer's bounds, not the view's: UIKit does not promise KVO for
|
|
1909
|
+
// UIView.bounds, and a screen laid out by frame never reported it, so
|
|
1910
|
+
// every gradient stayed zero-sized. CALayer's own bounds always do.
|
|
1313
1911
|
layer.observations = [
|
|
1314
|
-
view.observe(\.bounds) { [weak layer]
|
|
1912
|
+
view.layer.observe(\.bounds) { [weak layer, weak view] _, _ in if let view { layer?.follow(view) } },
|
|
1315
1913
|
view.layer.observe(\.cornerRadius) { [weak layer, weak view] _, _ in if let view { layer?.follow(view) } },
|
|
1316
1914
|
]
|
|
1317
1915
|
}
|
|
@@ -1529,6 +2127,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1529
2127
|
/// A drawing's shapes are not views: a change to one redraws its `Svg`.
|
|
1530
2128
|
private var svgOwners: [String: String] = [:]
|
|
1531
2129
|
private let mutationDocument = CraftNativeMutationDocument()
|
|
2130
|
+
private let audio = CraftNativeAudio()
|
|
1532
2131
|
private let capabilityScope = UUID().uuidString
|
|
1533
2132
|
private var pendingCapabilityRequests = Set<String>()
|
|
1534
2133
|
private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
|
|
@@ -1616,6 +2215,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1616
2215
|
guard !runtimeDisposed else { return }
|
|
1617
2216
|
runtimeDisposed = true
|
|
1618
2217
|
jsContext.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: [])
|
|
2218
|
+
audio.close()
|
|
1619
2219
|
}
|
|
1620
2220
|
|
|
1621
2221
|
private func refreshTraitDefaults() {
|
|
@@ -1899,6 +2499,76 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
1899
2499
|
keep('navigation', {
|
|
1900
2500
|
setOptions: function(options) { craftNativeSetOptions(JSON.stringify(options || {})); }
|
|
1901
2501
|
});
|
|
2502
|
+
if (typeof globalThis.AudioContext !== 'function') {
|
|
2503
|
+
var nextAudioId = 0;
|
|
2504
|
+
function audioMessage(payload) {
|
|
2505
|
+
globalThis.__stxNativeBridge.postMessage({ type: 'AUDIO', payload: payload });
|
|
2506
|
+
}
|
|
2507
|
+
function audioParam(contextId, nodeId, action, initial) {
|
|
2508
|
+
var current = Number(initial) || 0;
|
|
2509
|
+
var param = {};
|
|
2510
|
+
Object.defineProperty(param, 'value', {
|
|
2511
|
+
configurable: true,
|
|
2512
|
+
enumerable: true,
|
|
2513
|
+
get: function() { return current; },
|
|
2514
|
+
set: function(value) {
|
|
2515
|
+
current = Number(value) || 0;
|
|
2516
|
+
audioMessage({ action: action, contextId: contextId, nodeId: nodeId, value: current });
|
|
2517
|
+
}
|
|
2518
|
+
});
|
|
2519
|
+
function schedule(value, when, ramp) {
|
|
2520
|
+
current = Number(value) || 0;
|
|
2521
|
+
var time = Number(when);
|
|
2522
|
+
audioMessage({ action: action + (ramp ? 'RampToTime' : 'AtTime'), contextId: contextId, nodeId: nodeId, value: current, time: isFinite(time) ? Math.max(0, time) : 0 });
|
|
2523
|
+
}
|
|
2524
|
+
param.setValueAtTime = function(value, when) { schedule(value, when, false); return param; };
|
|
2525
|
+
param.linearRampToValueAtTime = function(value, when) { schedule(value, when, true); return param; };
|
|
2526
|
+
return param;
|
|
2527
|
+
}
|
|
2528
|
+
function AudioContext() {
|
|
2529
|
+
this._id = 'audio-' + (++nextAudioId);
|
|
2530
|
+
this._started = Date.now() / 1000;
|
|
2531
|
+
this.state = 'running';
|
|
2532
|
+
this.destination = { _contextId: this._id, _nodeId: 'destination' };
|
|
2533
|
+
audioMessage({ action: 'createContext', contextId: this._id });
|
|
2534
|
+
}
|
|
2535
|
+
Object.defineProperty(AudioContext.prototype, 'currentTime', {
|
|
2536
|
+
get: function() { return Date.now() / 1000 - this._started; }
|
|
2537
|
+
});
|
|
2538
|
+
AudioContext.prototype.createOscillator = function() {
|
|
2539
|
+
var context = this;
|
|
2540
|
+
var node = { _contextId: context._id, _nodeId: context._id + '-osc-' + (++nextAudioId) };
|
|
2541
|
+
node.frequency = audioParam(context._id, node._nodeId, 'frequency', 440);
|
|
2542
|
+
node.connect = function(destination) {
|
|
2543
|
+
audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
|
|
2544
|
+
return destination;
|
|
2545
|
+
};
|
|
2546
|
+
node.start = function(when) {
|
|
2547
|
+
audioMessage({ action: 'start', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
|
|
2548
|
+
};
|
|
2549
|
+
node.stop = function(when) {
|
|
2550
|
+
audioMessage({ action: 'stop', contextId: context._id, nodeId: node._nodeId, delay: Math.max(0, (Number(when) || 0) - context.currentTime) });
|
|
2551
|
+
};
|
|
2552
|
+
audioMessage({ action: 'createOscillator', contextId: context._id, nodeId: node._nodeId, frequency: node.frequency.value });
|
|
2553
|
+
return node;
|
|
2554
|
+
};
|
|
2555
|
+
AudioContext.prototype.createGain = function() {
|
|
2556
|
+
var context = this;
|
|
2557
|
+
var node = { _contextId: context._id, _nodeId: context._id + '-gain-' + (++nextAudioId) };
|
|
2558
|
+
node.gain = audioParam(context._id, node._nodeId, 'gain', 1);
|
|
2559
|
+
node.connect = function(destination) {
|
|
2560
|
+
audioMessage({ action: 'connect', contextId: context._id, sourceId: node._nodeId, destinationId: destination && destination._nodeId || 'destination' });
|
|
2561
|
+
return destination;
|
|
2562
|
+
};
|
|
2563
|
+
audioMessage({ action: 'createGain', contextId: context._id, nodeId: node._nodeId, gain: node.gain.value });
|
|
2564
|
+
return node;
|
|
2565
|
+
};
|
|
2566
|
+
AudioContext.prototype.resume = function() { this.state = 'running'; return Promise.resolve(); };
|
|
2567
|
+
AudioContext.prototype.suspend = function() { this.state = 'suspended'; return Promise.resolve(); };
|
|
2568
|
+
AudioContext.prototype.close = function() { this.state = 'closed'; audioMessage({ action: 'close', contextId: this._id }); return Promise.resolve(); };
|
|
2569
|
+
globalThis.AudioContext = AudioContext;
|
|
2570
|
+
globalThis.webkitAudioContext = AudioContext;
|
|
2571
|
+
}
|
|
1902
2572
|
})();
|
|
1903
2573
|
""")
|
|
1904
2574
|
}
|
|
@@ -2328,6 +2998,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2328
2998
|
pendingCapabilityRequests.remove(requestToken)
|
|
2329
2999
|
pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
|
|
2330
3000
|
CraftNativeActions.cancel(requestToken: requestToken)
|
|
3001
|
+
case "AUDIO":
|
|
3002
|
+
audio.handle(payload)
|
|
2331
3003
|
default:
|
|
2332
3004
|
break
|
|
2333
3005
|
}
|
|
@@ -2399,7 +3071,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2399
3071
|
child.removeFromSuperview()
|
|
2400
3072
|
}
|
|
2401
3073
|
if next.view.superview !== rootStack { rootStack.addArrangedSubview(next.view) }
|
|
2402
|
-
|
|
3074
|
+
// A native screen root fills the safe-area viewport unless the bundle
|
|
3075
|
+
// explicitly sizes it. Without this default, an unstyled SafeAreaView
|
|
3076
|
+
// measures to its scroll content and leaves no viewport to scroll.
|
|
3077
|
+
var rootStyle = next.style
|
|
3078
|
+
if rootStyle["flex"] == nil, rootStyle["height"] == nil { rootStyle["flex"] = 1 }
|
|
3079
|
+
rootStack.setLayoutStyle(rootStyle, for: next.view)
|
|
2403
3080
|
rootStack.invalidateMeasurements()
|
|
2404
3081
|
renderedRoot = next
|
|
2405
3082
|
trackContentScrollView()
|
|
@@ -2562,7 +3239,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2562
3239
|
case "Modal":
|
|
2563
3240
|
let modal = result as! CraftNativeModalView
|
|
2564
3241
|
modal.transparent = props["transparent"] as? Bool == true
|
|
2565
|
-
let visible
|
|
3242
|
+
let visible: Bool
|
|
3243
|
+
if let propVisible = props["visible"] as? Bool {
|
|
3244
|
+
// An explicit visible prop owns modal visibility. The
|
|
3245
|
+
// binding runtime may leave a stale display:none style
|
|
3246
|
+
// from the previous hidden state when it patches props.
|
|
3247
|
+
visible = propVisible
|
|
3248
|
+
} else {
|
|
3249
|
+
let display = style["display"] as? String
|
|
3250
|
+
visible = display != nil && display != "none"
|
|
3251
|
+
}
|
|
2566
3252
|
let modalID = ObjectIdentifier(modal)
|
|
2567
3253
|
let wasVisible = modalVisibility[modalID]
|
|
2568
3254
|
modalVisibility[modalID] = visible
|
|
@@ -2570,9 +3256,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2570
3256
|
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
2571
3257
|
{ [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
2572
3258
|
}
|
|
3259
|
+
if visible { modal.clearDismissSuppression() }
|
|
3260
|
+
let suppressDismiss = !visible && modal.consumeDismissSuppression()
|
|
2573
3261
|
if wasVisible != visible, visible || wasVisible == true {
|
|
2574
3262
|
let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
|
|
2575
|
-
if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
|
|
3263
|
+
if let lifecycleHandler = nonEmptyHandler(lifecycleHandler), !suppressDismiss {
|
|
2576
3264
|
send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
|
|
2577
3265
|
}
|
|
2578
3266
|
}
|
|
@@ -2663,6 +3351,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2663
3351
|
updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
|
|
2664
3352
|
updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
|
|
2665
3353
|
updateImage(image, source: props["source"])
|
|
3354
|
+
case "Video":
|
|
3355
|
+
let video = result as! CraftNativeVideoView
|
|
3356
|
+
let autoplay = (props["autoplay"] as? Bool) == true || (props["autoPlay"] as? Bool) == true
|
|
3357
|
+
video.configure(
|
|
3358
|
+
source: props["source"],
|
|
3359
|
+
autoplay: autoplay,
|
|
3360
|
+
paused: (props["paused"] as? Bool) == true,
|
|
3361
|
+
loop: (props["loop"] as? Bool) == true,
|
|
3362
|
+
muted: (props["muted"] as? Bool) == true,
|
|
3363
|
+
controls: (props["controls"] as? Bool) == true,
|
|
3364
|
+
resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String
|
|
3365
|
+
)
|
|
2666
3366
|
case "ScrollView":
|
|
2667
3367
|
let scroll = result as! CraftNativeScrollView
|
|
2668
3368
|
let flexDirection = style["flexDirection"] as? String
|
|
@@ -2744,7 +3444,6 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2744
3444
|
return textView
|
|
2745
3445
|
}
|
|
2746
3446
|
let field = UITextField()
|
|
2747
|
-
field.borderStyle = .roundedRect
|
|
2748
3447
|
field.addTarget(self, action: #selector(textChanged(_:)), for: .editingChanged)
|
|
2749
3448
|
field.addTarget(self, action: #selector(textFocused(_:)), for: .editingDidBegin)
|
|
2750
3449
|
field.addTarget(self, action: #selector(textBlurred(_:)), for: .editingDidEnd)
|
|
@@ -2770,6 +3469,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2770
3469
|
return UIActivityIndicatorView(style: .medium)
|
|
2771
3470
|
case "Image":
|
|
2772
3471
|
return UIImageView()
|
|
3472
|
+
case "Video":
|
|
3473
|
+
return CraftNativeVideoView(host: self)
|
|
2773
3474
|
case "Icon":
|
|
2774
3475
|
let icon = CraftNativeIconView()
|
|
2775
3476
|
icon.contentMode = .scaleAspectFit
|
|
@@ -2790,7 +3491,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
2790
3491
|
}
|
|
2791
3492
|
|
|
2792
3493
|
private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
|
|
2793
|
-
// The current stx native compiler emits key in props; accept the IR
|
|
3494
|
+
// The current @stacksjs/stx native compiler emits key in props; accept the IR
|
|
2794
3495
|
// field too so keyed children keep working when the compiler adopts it.
|
|
2795
3496
|
[node["id"], node["key"], props["key"], props["testID"]]
|
|
2796
3497
|
.compactMap { $0 as? String }
|
|
@@ -3013,6 +3714,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3013
3714
|
imageLoadHandlers.removeValue(forKey: id)
|
|
3014
3715
|
imageLoadEndHandlers.removeValue(forKey: id)
|
|
3015
3716
|
imageErrorHandlers.removeValue(forKey: id)
|
|
3717
|
+
if let video = node.view as? CraftNativeVideoView { video.stop() }
|
|
3016
3718
|
for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
|
|
3017
3719
|
}
|
|
3018
3720
|
|
|
@@ -3259,6 +3961,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3259
3961
|
private func configureStack(_ stack: CraftNativeFlowView, style: [String: Any]) {
|
|
3260
3962
|
let direction = style["flexDirection"] as? String
|
|
3261
3963
|
stack.axis = direction == "row" || direction == "row-reverse" ? .horizontal : .vertical
|
|
3964
|
+
stack.reverse = direction?.hasSuffix("-reverse") == true
|
|
3262
3965
|
stack.wrap = style["flexWrap"] as? String == "wrap" || style["flexWrap"] as? Bool == true
|
|
3263
3966
|
stack.alignItems = style["alignItems"] as? String ?? "stretch"
|
|
3264
3967
|
stack.alignContent = style["alignContent"] as? String ?? "flex-start"
|
|
@@ -3282,6 +3985,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3282
3985
|
updateDimension(width, constraint: &node.widthConstraint, anchor: view.widthAnchor)
|
|
3283
3986
|
updateDimension(height, constraint: &node.heightConstraint, anchor: view.heightAnchor)
|
|
3284
3987
|
view.backgroundColor = color(style["backgroundColor"]) ?? .clear
|
|
3988
|
+
// A field the style dresses itself (`border-0`, a border or a
|
|
3989
|
+
// background, as a search field inside its own pill is) draws no
|
|
3990
|
+
// system box of its own; a bare one keeps the rounded rectangle.
|
|
3991
|
+
if let field = view as? UITextField {
|
|
3992
|
+
field.borderStyle = style["borderWidth"] != nil || style["backgroundColor"] != nil ? .none : .roundedRect
|
|
3993
|
+
}
|
|
3285
3994
|
CraftNativeGradientLayer.apply(
|
|
3286
3995
|
to: view,
|
|
3287
3996
|
direction: style["gradientDirection"] as? String,
|