craft-native 0.0.355 → 0.0.359
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/src/index.js +1 -0
- package/dist/android/templates/CraftNativeCapabilities.kt.template +184 -14
- package/dist/android/templates/CraftNativeMapGoogle.kt.template +18 -5
- package/dist/android/templates/CraftNativeMapLibre.kt.template +28 -1
- package/dist/android/templates/CraftNativeMutation.kt.template +3 -0
- package/dist/android/templates/CraftNativeShell.kt.template +20 -5
- package/dist/android/templates/CraftNativeSounds.kt.template +260 -0
- package/dist/android/templates/MainActivityNative.kt.template +200 -15
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftNativeFiles.swift +5 -3
- package/dist/ios/templates/CraftNativeScreen.swift +169 -18
- package/dist/ios/templates/CraftNativeServices.swift +58 -10
- package/package.json +1 -1
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.media.AudioAttributes
|
|
5
|
+
import android.media.AudioFocusRequest
|
|
6
|
+
import android.media.AudioManager
|
|
7
|
+
import android.media.MediaPlayer
|
|
8
|
+
import android.net.Uri
|
|
9
|
+
import android.os.Handler
|
|
10
|
+
import android.os.Looper
|
|
11
|
+
import org.json.JSONObject
|
|
12
|
+
import java.io.File
|
|
13
|
+
import kotlin.math.max
|
|
14
|
+
import kotlin.math.min
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Screen-owned `craft.audio` playback for native Android routes.
|
|
18
|
+
*
|
|
19
|
+
* Each id owns one MediaPlayer, so effects can overlap and a replacement
|
|
20
|
+
* invalidates the previous load. The player is released on every terminal
|
|
21
|
+
* path, including cancellation, decode failure and screen teardown.
|
|
22
|
+
*/
|
|
23
|
+
internal class CraftNativeSounds(private val context: Context) {
|
|
24
|
+
private data class Sound(
|
|
25
|
+
val player: MediaPlayer,
|
|
26
|
+
var volume: Float,
|
|
27
|
+
var paused: Boolean = false,
|
|
28
|
+
var interrupted: Boolean = false,
|
|
29
|
+
var prepared: Boolean = false,
|
|
30
|
+
var fade: Runnable? = null,
|
|
31
|
+
var pending: ((CraftNativeCapabilityResult) -> Unit)? = null,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
private val main = Handler(Looper.getMainLooper())
|
|
35
|
+
private val sounds = mutableMapOf<String, Sound>()
|
|
36
|
+
private val audioManager by lazy { context.getSystemService(Context.AUDIO_SERVICE) as AudioManager }
|
|
37
|
+
private val focusRequest by lazy {
|
|
38
|
+
AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
|
|
39
|
+
.setAudioAttributes(AudioAttributes.Builder()
|
|
40
|
+
.setUsage(AudioAttributes.USAGE_GAME)
|
|
41
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
|
42
|
+
.build())
|
|
43
|
+
.setAcceptsDelayedFocusGain(false)
|
|
44
|
+
.setOnAudioFocusChangeListener({ change -> onAudioFocusChange(change) }, main)
|
|
45
|
+
.build()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** `{ id, event: "ended" | "error", message? }` for the screen bridge. */
|
|
49
|
+
var emit: ((JSONObject) -> Unit)? = null
|
|
50
|
+
|
|
51
|
+
fun play(id: String, address: String, options: JSONObject, done: (CraftNativeCapabilityResult) -> Unit) {
|
|
52
|
+
if (id.isBlank()) return done(failure("INVALID_ARGUMENT", "audio.play needs an id"))
|
|
53
|
+
val uri = source(address)
|
|
54
|
+
?: return done(failure("INVALID_ARGUMENT", "audio.play needs an absolute http, https, file or content URL"))
|
|
55
|
+
stop(id, 0.0)
|
|
56
|
+
|
|
57
|
+
val requestedVolume = options.optDouble("volume", 1.0)
|
|
58
|
+
val volume = requestedVolume.takeIf { it.isFinite() }?.coerceIn(0.0, 1.0)?.toFloat()
|
|
59
|
+
?: return done(failure("INVALID_ARGUMENT", "audio.play volume must be a finite number between 0 and 1"))
|
|
60
|
+
val fadeIn = options.optDouble("fadeIn", 0.0)
|
|
61
|
+
if (!fadeIn.isFinite() || fadeIn < 0) return done(failure("INVALID_ARGUMENT", "audio.play fadeIn must be a non-negative finite number"))
|
|
62
|
+
val player = MediaPlayer()
|
|
63
|
+
val sound = Sound(player, if (fadeIn > 0) 0f else volume, pending = done)
|
|
64
|
+
sounds[id] = sound
|
|
65
|
+
fun settle(result: CraftNativeCapabilityResult) {
|
|
66
|
+
val answer = sound.pending ?: return
|
|
67
|
+
sound.pending = null
|
|
68
|
+
answer(result)
|
|
69
|
+
}
|
|
70
|
+
fun failed(message: String) {
|
|
71
|
+
if (sounds[id] !== sound) return
|
|
72
|
+
settle(failure("AUDIO_ERROR", message))
|
|
73
|
+
remove(id)
|
|
74
|
+
emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", message))
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (audioManager.requestAudioFocus(focusRequest) != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
|
78
|
+
settle(failure("AUDIO_FOCUS_DENIED", "Audio focus was denied by the operating system"))
|
|
79
|
+
remove(id)
|
|
80
|
+
emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", "Audio focus was denied by the operating system"))
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
runCatching {
|
|
85
|
+
player.setAudioAttributes(AudioAttributes.Builder()
|
|
86
|
+
.setUsage(AudioAttributes.USAGE_GAME)
|
|
87
|
+
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
|
|
88
|
+
.build())
|
|
89
|
+
player.isLooping = options.optBoolean("loop", false)
|
|
90
|
+
player.setVolume(if (fadeIn > 0) 0f else volume, if (fadeIn > 0) 0f else volume)
|
|
91
|
+
player.setOnPreparedListener {
|
|
92
|
+
if (sounds[id] !== sound) return@setOnPreparedListener
|
|
93
|
+
sound.prepared = true
|
|
94
|
+
if (!sound.paused && !sound.interrupted) player.start()
|
|
95
|
+
settle(CraftNativeCapabilityResult(data = true))
|
|
96
|
+
if (fadeIn > 0) {
|
|
97
|
+
if (!sound.paused) ramp(sound, volume, fadeIn / 1000.0)
|
|
98
|
+
else { sound.volume = volume; player.setVolume(volume, volume) }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
player.setOnCompletionListener {
|
|
102
|
+
if (sounds[id] !== sound) return@setOnCompletionListener
|
|
103
|
+
remove(id)
|
|
104
|
+
emit?.invoke(JSONObject().put("id", id).put("event", "ended"))
|
|
105
|
+
}
|
|
106
|
+
player.setOnErrorListener { _, what, extra ->
|
|
107
|
+
failed("The sound could not be played ($what, $extra)")
|
|
108
|
+
true
|
|
109
|
+
}
|
|
110
|
+
player.setDataSource(context, uri)
|
|
111
|
+
player.prepareAsync()
|
|
112
|
+
}.onFailure { error ->
|
|
113
|
+
if (sounds[id] === sound) {
|
|
114
|
+
settle(failure("AUDIO_ERROR", error.message ?: "The sound could not be loaded"))
|
|
115
|
+
remove(id)
|
|
116
|
+
emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", error.message ?: "The sound could not be loaded"))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Cancellation belongs to this playback, not a later reuse of its id. */
|
|
122
|
+
fun cancellation(id: String): () -> Unit {
|
|
123
|
+
val sound = sounds[id]
|
|
124
|
+
return { if (sound != null && sounds[id] === sound) remove(id) }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
fun pause(id: String): Boolean {
|
|
128
|
+
val sound = sounds[id] ?: return false
|
|
129
|
+
sound.fade?.let(main::removeCallbacks)
|
|
130
|
+
sound.fade = null
|
|
131
|
+
runCatching { if (sound.player.isPlaying) sound.player.pause() }
|
|
132
|
+
sound.paused = true
|
|
133
|
+
sound.interrupted = false
|
|
134
|
+
return true
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
fun resume(id: String): Boolean {
|
|
138
|
+
val sound = sounds[id] ?: return false
|
|
139
|
+
if (sound.prepared) runCatching { sound.player.start() }.onFailure { return false }
|
|
140
|
+
sound.paused = false
|
|
141
|
+
sound.interrupted = false
|
|
142
|
+
return true
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
fun setVolume(id: String, volume: Double, fade: Double): Boolean {
|
|
146
|
+
val sound = sounds[id] ?: return false
|
|
147
|
+
if (!volume.isFinite() || !fade.isFinite() || fade < 0) return false
|
|
148
|
+
val target = volume.coerceIn(0.0, 1.0).toFloat()
|
|
149
|
+
sound.fade?.let(main::removeCallbacks)
|
|
150
|
+
sound.fade = null
|
|
151
|
+
if (fade > 0 && !sound.paused) ramp(sound, target, fade / 1000.0)
|
|
152
|
+
else {
|
|
153
|
+
sound.volume = target
|
|
154
|
+
sound.player.setVolume(target, target)
|
|
155
|
+
}
|
|
156
|
+
return true
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
fun stop(id: String, fadeOut: Double): Boolean {
|
|
160
|
+
val sound = sounds[id] ?: return false
|
|
161
|
+
if (!fadeOut.isFinite() || fadeOut < 0) return false
|
|
162
|
+
if (fadeOut > 0 && !sound.paused) {
|
|
163
|
+
ramp(sound, 0f, fadeOut / 1000.0) { if (sounds[id] === sound) remove(id) }
|
|
164
|
+
} else remove(id)
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fun stopAll(fadeOut: Double): Boolean {
|
|
169
|
+
if (!fadeOut.isFinite() || fadeOut < 0) return false
|
|
170
|
+
sounds.keys.toList().forEach { stop(it, fadeOut) }
|
|
171
|
+
return true
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fun close() {
|
|
175
|
+
sounds.keys.toList().forEach(::remove)
|
|
176
|
+
emit = null
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private fun source(address: String): Uri? {
|
|
180
|
+
val text = address.trim()
|
|
181
|
+
if (text.isBlank()) return null
|
|
182
|
+
CraftNativeMedia.local(context, text)?.let { file: File -> return Uri.fromFile(file) }
|
|
183
|
+
val uri = runCatching { Uri.parse(text) }.getOrNull() ?: return null
|
|
184
|
+
return uri.takeIf { it.scheme?.lowercase() in setOf("http", "https", "file", "content") && !it.toString().contains(" ") }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private fun remove(id: String) {
|
|
188
|
+
val sound = sounds.remove(id) ?: return
|
|
189
|
+
sound.fade?.let(main::removeCallbacks)
|
|
190
|
+
sound.fade = null
|
|
191
|
+
runCatching {
|
|
192
|
+
sound.player.setOnPreparedListener(null)
|
|
193
|
+
sound.player.setOnCompletionListener(null)
|
|
194
|
+
sound.player.setOnErrorListener(null)
|
|
195
|
+
sound.player.stop()
|
|
196
|
+
}
|
|
197
|
+
sound.player.release()
|
|
198
|
+
if (sounds.isEmpty()) audioManager.abandonAudioFocusRequest(focusRequest)
|
|
199
|
+
val answer = sound.pending
|
|
200
|
+
sound.pending = null
|
|
201
|
+
answer?.invoke(failure("CANCELLED", "Audio playback was stopped or replaced"))
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private fun onAudioFocusChange(change: Int) {
|
|
205
|
+
when (change) {
|
|
206
|
+
AudioManager.AUDIOFOCUS_LOSS -> {
|
|
207
|
+
sounds.keys.toList().forEach { id ->
|
|
208
|
+
if (sounds[id] != null) {
|
|
209
|
+
remove(id)
|
|
210
|
+
emit?.invoke(JSONObject().put("id", id).put("event", "error").put("message", "Audio focus was lost"))
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> sounds.values.toList().forEach { sound ->
|
|
215
|
+
if (!sound.paused) {
|
|
216
|
+
sound.interrupted = true
|
|
217
|
+
if (sound.prepared) runCatching { sound.player.pause() }
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> sounds.values.toList().forEach { sound ->
|
|
221
|
+
runCatching { sound.player.setVolume(sound.volume * 0.2f, sound.volume * 0.2f) }
|
|
222
|
+
}
|
|
223
|
+
AudioManager.AUDIOFOCUS_GAIN -> sounds.values.toList().forEach { sound ->
|
|
224
|
+
if (sound.interrupted && !sound.paused) {
|
|
225
|
+
sound.interrupted = false
|
|
226
|
+
if (sound.prepared) runCatching { sound.player.start() }
|
|
227
|
+
} else {
|
|
228
|
+
runCatching { sound.player.setVolume(sound.volume, sound.volume) }
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private fun ramp(sound: Sound, target: Float, seconds: Double, done: (() -> Unit)? = null) {
|
|
235
|
+
sound.fade?.let(main::removeCallbacks)
|
|
236
|
+
val start = sound.volume
|
|
237
|
+
val began = android.os.SystemClock.uptimeMillis()
|
|
238
|
+
val duration = max(1L, (seconds * 1000).toLong())
|
|
239
|
+
val tick = object : Runnable {
|
|
240
|
+
override fun run() {
|
|
241
|
+
if (!sounds.containsValue(sound)) return
|
|
242
|
+
val progress = min(1.0, (android.os.SystemClock.uptimeMillis() - began).toDouble() / duration).toFloat()
|
|
243
|
+
val value = start + (target - start) * progress
|
|
244
|
+
sound.player.setVolume(value, value)
|
|
245
|
+
sound.volume = value
|
|
246
|
+
if (progress >= 1f) {
|
|
247
|
+
sound.fade = null
|
|
248
|
+
done?.invoke()
|
|
249
|
+
} else {
|
|
250
|
+
sound.fade = this
|
|
251
|
+
main.postDelayed(this, 33L)
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
sound.fade = tick
|
|
256
|
+
main.post(tick)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private fun failure(code: String, message: String) = CraftNativeCapabilityResult(code = code, message = message)
|
|
260
|
+
}
|
|
@@ -740,6 +740,115 @@ private class CraftNativeSvgView(context: Context) : View(context) {
|
|
|
740
740
|
private data class Quadruple<T1, T2, T3, T4>(val first: T1, val second: T2, val third: T3, val fourth: T4)
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
+
/** One photo contract and loader shared by the Google and MapLibre hosts. */
|
|
744
|
+
internal data class CraftMarkerPhoto(val address: String, val size: Float, val circle: Boolean) {
|
|
745
|
+
companion object {
|
|
746
|
+
fun from(props: JSONObject): CraftMarkerPhoto? {
|
|
747
|
+
val address = props.opt("image") as? String ?: return null
|
|
748
|
+
val uri = Uri.parse(address)
|
|
749
|
+
if (!uri.scheme.equals("https", true) || uri.host.isNullOrBlank() || uri.userInfo != null) return null
|
|
750
|
+
// Current STX releases preserve unknown HTML/binding spellings.
|
|
751
|
+
val rawSize = props.opt("imageSize") ?: props.opt("imagesize") ?: props.opt("image-size")
|
|
752
|
+
val shape = props.opt("imageShape") ?: props.opt("imageshape") ?: props.opt("image-shape")
|
|
753
|
+
val size = (rawSize as? Number)?.toDouble()?.takeIf { it.isFinite() } ?: 30.0
|
|
754
|
+
return CraftMarkerPhoto(address, size.coerceIn(16.0, 96.0).toFloat(), shape != "square")
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
internal class CraftMarkerPhotos(private val context: Context, private val changed: () -> Unit) {
|
|
760
|
+
private class Entry(val spec: CraftMarkerPhoto) {
|
|
761
|
+
var job: Future<*>? = null
|
|
762
|
+
var bitmap: Bitmap? = null
|
|
763
|
+
}
|
|
764
|
+
private val entries = mutableMapOf<String, Entry>()
|
|
765
|
+
private val executor = Executors.newFixedThreadPool(2)
|
|
766
|
+
private val main = Handler(Looper.getMainLooper())
|
|
767
|
+
|
|
768
|
+
fun bitmap(id: String, spec: CraftMarkerPhoto? = null): Bitmap? = entries[id]?.takeIf { spec == null || it.spec == spec }?.bitmap
|
|
769
|
+
|
|
770
|
+
fun reconcile(wanted: Map<String, CraftMarkerPhoto>) {
|
|
771
|
+
entries.keys.filter { entries[it]?.spec != wanted[it] }.forEach { entries.remove(it)?.job?.cancel(true) }
|
|
772
|
+
wanted.forEach { (id, spec) ->
|
|
773
|
+
if (entries.containsKey(id)) return@forEach
|
|
774
|
+
val entry = Entry(spec)
|
|
775
|
+
entries[id] = entry
|
|
776
|
+
entry.job = executor.submit {
|
|
777
|
+
val bitmap = runCatching { load(spec) }.getOrNull()
|
|
778
|
+
main.post {
|
|
779
|
+
if (entries[id] !== entry) return@post
|
|
780
|
+
entry.bitmap = bitmap
|
|
781
|
+
entry.job = null
|
|
782
|
+
if (bitmap != null) changed()
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
private fun load(spec: CraftMarkerPhoto): Bitmap? {
|
|
789
|
+
val kept = CraftNativeMedia.local(context, spec.address)
|
|
790
|
+
val bytes = if (kept != null) kept.inputStream().use(::readBounded) else download(spec.address)
|
|
791
|
+
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
|
792
|
+
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
|
793
|
+
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
|
794
|
+
val size = (spec.size * context.resources.displayMetrics.density).roundToInt().coerceAtLeast(1)
|
|
795
|
+
var sample = 1
|
|
796
|
+
while (bounds.outWidth / (sample * 2) >= size && bounds.outHeight / (sample * 2) >= size) sample *= 2
|
|
797
|
+
val source = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, BitmapFactory.Options().apply { inSampleSize = sample }) ?: return null
|
|
798
|
+
try {
|
|
799
|
+
val result = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
|
800
|
+
val scale = maxOf(size.toFloat() / source.width, size.toFloat() / source.height)
|
|
801
|
+
val matrix = Matrix().apply {
|
|
802
|
+
setScale(scale, scale)
|
|
803
|
+
postTranslate((size - source.width * scale) / 2, (size - source.height * scale) / 2)
|
|
804
|
+
}
|
|
805
|
+
val shader = android.graphics.BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply { setLocalMatrix(matrix) }
|
|
806
|
+
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { this.shader = shader }
|
|
807
|
+
val canvas = Canvas(result)
|
|
808
|
+
if (spec.circle) canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
|
809
|
+
else canvas.drawRect(0f, 0f, size.toFloat(), size.toFloat(), paint)
|
|
810
|
+
return result
|
|
811
|
+
} finally { source.recycle() }
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
private fun readBounded(input: java.io.InputStream): ByteArray {
|
|
815
|
+
val output = java.io.ByteArrayOutputStream()
|
|
816
|
+
val buffer = ByteArray(8192)
|
|
817
|
+
while (true) {
|
|
818
|
+
if (Thread.currentThread().isInterrupted) throw java.io.InterruptedIOException()
|
|
819
|
+
val count = input.read(buffer)
|
|
820
|
+
if (count < 0) return output.toByteArray()
|
|
821
|
+
require(output.size() + count <= 8 * 1024 * 1024) { "Marker image exceeds 8 MiB" }
|
|
822
|
+
output.write(buffer, 0, count)
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
private fun download(address: String): ByteArray {
|
|
827
|
+
var url = URL(address)
|
|
828
|
+
repeat(6) {
|
|
829
|
+
require(url.protocol.equals("https", true) && url.userInfo == null)
|
|
830
|
+
val connection = (url.openConnection() as HttpURLConnection).apply {
|
|
831
|
+
connectTimeout = 10_000; readTimeout = 10_000
|
|
832
|
+
instanceFollowRedirects = false; useCaches = false
|
|
833
|
+
}
|
|
834
|
+
try {
|
|
835
|
+
when (connection.responseCode) {
|
|
836
|
+
200 -> return connection.inputStream.use(::readBounded)
|
|
837
|
+
301, 302, 303, 307, 308 -> url = URL(url, requireNotNull(connection.getHeaderField("Location")))
|
|
838
|
+
else -> error("Marker image request failed")
|
|
839
|
+
}
|
|
840
|
+
} finally { connection.disconnect() }
|
|
841
|
+
}
|
|
842
|
+
error("Too many marker image redirects")
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
fun destroy() {
|
|
846
|
+
entries.values.forEach { it.job?.cancel(true) }
|
|
847
|
+
entries.clear()
|
|
848
|
+
executor.shutdownNow()
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
743
852
|
{{NATIVE_MAP_HOST}}
|
|
744
853
|
|
|
745
854
|
/** A finite celebration owned by a View, never by the JavaScript frame loop. */
|
|
@@ -899,6 +1008,7 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
899
1008
|
private var wrapReverse = false
|
|
900
1009
|
private var unboundedWidth = false
|
|
901
1010
|
private var authoredPadding = intArrayOf(0, 0, 0, 0)
|
|
1011
|
+
private var windowSafeAreaInsets = intArrayOf(0, 0, 0, 0)
|
|
902
1012
|
private var safeAreaPadding = intArrayOf(0, 0, 0, 0)
|
|
903
1013
|
private var applyingPadding = false
|
|
904
1014
|
private var safeAreaEdges = setOf("top", "right", "bottom", "left")
|
|
@@ -962,18 +1072,20 @@ internal class CraftNativeFlexLayout(context: Context) : LinearLayout(context) {
|
|
|
962
1072
|
}
|
|
963
1073
|
|
|
964
1074
|
fun setWindowSafeInsets(left: Int, top: Int, right: Int, bottom: Int) {
|
|
965
|
-
|
|
966
|
-
safeAreaPadding = intArrayOf(
|
|
967
|
-
if ("left" in safeAreaEdges) insets[0] else 0,
|
|
968
|
-
if ("top" in safeAreaEdges) insets[1] else 0,
|
|
969
|
-
if ("right" in safeAreaEdges) insets[2] else 0,
|
|
970
|
-
if ("bottom" in safeAreaEdges) insets[3] else 0,
|
|
971
|
-
)
|
|
1075
|
+
windowSafeAreaInsets = intArrayOf(left, top, right, bottom)
|
|
972
1076
|
applySafeAreaPadding()
|
|
973
1077
|
invalidateMeasurements()
|
|
974
1078
|
}
|
|
975
1079
|
|
|
976
1080
|
private fun applySafeAreaPadding() {
|
|
1081
|
+
// Keep the unfiltered insets so changing edges takes effect immediately,
|
|
1082
|
+
// including restoring an edge without another platform inset dispatch.
|
|
1083
|
+
safeAreaPadding = intArrayOf(
|
|
1084
|
+
if ("left" in safeAreaEdges) windowSafeAreaInsets[0] else 0,
|
|
1085
|
+
if ("top" in safeAreaEdges) windowSafeAreaInsets[1] else 0,
|
|
1086
|
+
if ("right" in safeAreaEdges) windowSafeAreaInsets[2] else 0,
|
|
1087
|
+
if ("bottom" in safeAreaEdges) windowSafeAreaInsets[3] else 0,
|
|
1088
|
+
)
|
|
977
1089
|
setPadding(authoredPadding[0], authoredPadding[1], authoredPadding[2], authoredPadding[3])
|
|
978
1090
|
}
|
|
979
1091
|
|
|
@@ -4138,6 +4250,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
4138
4250
|
val pendingCapabilities = mutableSetOf<String>()
|
|
4139
4251
|
val capabilityTimeouts = mutableMapOf<String, Runnable>()
|
|
4140
4252
|
val audio = CraftNativeAudio()
|
|
4253
|
+
val sounds = CraftNativeSounds(view.context)
|
|
4254
|
+
val soundRequestCancellers = mutableMapOf<String, () -> Unit>()
|
|
4255
|
+
/** Snapshot writes awaiting the writer thread; close rejects them once. */
|
|
4256
|
+
val pendingSyncResults = mutableSetOf<String>()
|
|
4141
4257
|
var active = true
|
|
4142
4258
|
var closing = false
|
|
4143
4259
|
private set
|
|
@@ -4153,6 +4269,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
4153
4269
|
active = false
|
|
4154
4270
|
closing = true
|
|
4155
4271
|
audio.close()
|
|
4272
|
+
soundRequestCancellers.values.toList().forEach { it() }
|
|
4273
|
+
soundRequestCancellers.clear()
|
|
4274
|
+
sounds.close()
|
|
4156
4275
|
// Evaluation completion does not mean its outbound messages have
|
|
4157
4276
|
// reached the host. Close only after the ordered channel barrier,
|
|
4158
4277
|
// so onDestroy's final writes run before navigation continues.
|
|
@@ -4684,6 +4803,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
4684
4803
|
activeScreen.view.onLayoutChanged = {
|
|
4685
4804
|
if (activeScreen.active) emitLayoutEvents(activeScreen)
|
|
4686
4805
|
}
|
|
4806
|
+
activeScreen.sounds.emit = { payload ->
|
|
4807
|
+
if (activeScreen.active) send(activeScreen, "SOUND_EVENT", payload)
|
|
4808
|
+
}
|
|
4687
4809
|
screen = activeScreen
|
|
4688
4810
|
CraftNativeHub.register(activeScreen.capabilityScope) { type, payload ->
|
|
4689
4811
|
if (activeScreen.active) send(activeScreen, type, payload)
|
|
@@ -5061,6 +5183,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5061
5183
|
/** The bridge's capability names: Craft's core set and this host's services. */
|
|
5062
5184
|
private fun capabilityNames(): JSONArray = capabilities.names().apply {
|
|
5063
5185
|
services.names().forEach { name -> put(name) }
|
|
5186
|
+
put("audio")
|
|
5064
5187
|
put("viewFocus")
|
|
5065
5188
|
put("viewMeasure")
|
|
5066
5189
|
put("viewScroll")
|
|
@@ -5079,13 +5202,20 @@ class MainActivity : AppCompatActivity() {
|
|
|
5079
5202
|
/** A synchronous store written in an isolate (CraftNativeShell.kt's prelude). */
|
|
5080
5203
|
private fun syncWrite(screen: Screen, payload: JSONObject) {
|
|
5081
5204
|
val id = payload.optString("id").takeIf { it.isNotBlank() }
|
|
5205
|
+
val store = payload.optString("store")
|
|
5206
|
+
if (id != null) screen.pendingSyncResults.add(id)
|
|
5082
5207
|
val reply: (Boolean) -> Unit = { ok ->
|
|
5083
|
-
if (id != null && screen.
|
|
5208
|
+
if (id != null && screen.pendingSyncResults.remove(id) && (screen.active || screen.closing)) {
|
|
5209
|
+
send(screen, "SYNC_RESULT", JSONObject().put("id", id).put("ok", ok))
|
|
5210
|
+
}
|
|
5084
5211
|
}
|
|
5085
5212
|
val key = payload.optString("key")
|
|
5086
5213
|
val value = if (payload.isNull("value")) null else payload.optString("value")
|
|
5087
|
-
when (
|
|
5088
|
-
"storage" -> if (key.isNotBlank())
|
|
5214
|
+
when (store) {
|
|
5215
|
+
"storage" -> if (key.isNotBlank()) {
|
|
5216
|
+
stores.setStorage(key, value, from = screen.capabilityScope)
|
|
5217
|
+
if (id != null) reply(true)
|
|
5218
|
+
} else if (id != null) reply(false)
|
|
5089
5219
|
"snapshots" -> if (payload.optBoolean("clear")) {
|
|
5090
5220
|
val keep = payload.optJSONArray("keep") ?: JSONArray()
|
|
5091
5221
|
stores.clearSnapshots((0 until keep.length()).map { keep.optString(it) }.toSet(), reply)
|
|
@@ -5147,7 +5277,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5147
5277
|
|
|
5148
5278
|
private fun refreshScreenTheme(screen: Screen) {
|
|
5149
5279
|
applyScreenBackground(screen)
|
|
5150
|
-
screen.mutations.
|
|
5280
|
+
screen.mutations.document()?.let { renderCommitted(screen, it) }
|
|
5151
5281
|
screen.controls.values.mapNotNull {
|
|
5152
5282
|
when (it) {
|
|
5153
5283
|
is CraftNativeRefreshableFlatList -> it.list
|
|
@@ -5165,7 +5295,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5165
5295
|
private fun updateSafeAreaViews() {
|
|
5166
5296
|
if (!::root.isInitialized) return
|
|
5167
5297
|
val active = visibleScreen ?: screens.lastOrNull { it.active }
|
|
5168
|
-
val rootNode = active?.mutations?.
|
|
5298
|
+
val rootNode = active?.mutations?.document()
|
|
5169
5299
|
val safeRoot = rootNode?.let { canonicalType(it.optString("type")) == "SafeAreaView" } == true
|
|
5170
5300
|
root.setPadding(
|
|
5171
5301
|
if (safeRoot) 0 else windowSafeInsets[0],
|
|
@@ -5184,7 +5314,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5184
5314
|
* against zero insets.
|
|
5185
5315
|
*/
|
|
5186
5316
|
private fun updateSafeAreaInsets(screen: Screen) {
|
|
5187
|
-
val rootNode = screen.mutations.
|
|
5317
|
+
val rootNode = screen.mutations.document()
|
|
5188
5318
|
val safeRoot = rootNode?.let { canonicalType(it.optString("type")) == "SafeAreaView" } == true
|
|
5189
5319
|
val tree = screen.view.getChildAt(0) as? CraftNativeFlexLayout
|
|
5190
5320
|
if (safeRoot) tree?.setWindowSafeInsets(windowSafeInsets[0], windowSafeInsets[1], windowSafeInsets[2], windowSafeInsets[3])
|
|
@@ -5326,6 +5456,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5326
5456
|
val token = "${screen.capabilityScope}/$id"
|
|
5327
5457
|
val pending = screen.pendingCapabilities.remove(token)
|
|
5328
5458
|
screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
|
|
5459
|
+
screen.soundRequestCancellers.remove(token)?.invoke()
|
|
5329
5460
|
if (pending) capabilities.cancel(token)
|
|
5330
5461
|
}
|
|
5331
5462
|
}
|
|
@@ -5511,6 +5642,17 @@ class MainActivity : AppCompatActivity() {
|
|
|
5511
5642
|
}
|
|
5512
5643
|
}
|
|
5513
5644
|
|
|
5645
|
+
private fun updateStatusBarProps(screen: Screen, props: JSONObject) {
|
|
5646
|
+
val style = craftNativeOptionalString(props.opt("barStyle"))
|
|
5647
|
+
val hidden = if (props.has("hidden") && !props.isNull("hidden")) craftNativeBoolean(props.opt("hidden")) else null
|
|
5648
|
+
val changed = screen.statusBarStyle != style || screen.statusBarHidden != hidden
|
|
5649
|
+
screen.statusBarStyle = style
|
|
5650
|
+
screen.statusBarHidden = hidden
|
|
5651
|
+
// MUTATE can reconcile this node without committing the whole screen.
|
|
5652
|
+
// Retained screens keep their props until their next visible commit.
|
|
5653
|
+
if (changed && visibleScreen === screen) applyStatusBar(screen)
|
|
5654
|
+
}
|
|
5655
|
+
|
|
5514
5656
|
private fun applyStatusBar(screen: Screen) {
|
|
5515
5657
|
val controller = WindowCompat.getInsetsController(window, window.decorView)
|
|
5516
5658
|
val night = (resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES
|
|
@@ -5582,8 +5724,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5582
5724
|
isLongClickable = selectable
|
|
5583
5725
|
}
|
|
5584
5726
|
"StatusBar" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
|
|
5585
|
-
screen
|
|
5586
|
-
screen.statusBarHidden = if (props.has("hidden") && !props.isNull("hidden")) craftNativeBoolean(props.opt("hidden")) else null
|
|
5727
|
+
updateStatusBarProps(screen, props)
|
|
5587
5728
|
visibility = View.GONE
|
|
5588
5729
|
}
|
|
5589
5730
|
"RefreshControl" -> (previous as? CraftNativeFlexLayout ?: CraftNativeFlexLayout(this)).apply {
|
|
@@ -7241,6 +7382,12 @@ class MainActivity : AppCompatActivity() {
|
|
|
7241
7382
|
}
|
|
7242
7383
|
imageJobs[view] = imageExecutor.submit {
|
|
7243
7384
|
val bitmap = runCatching {
|
|
7385
|
+
// Prefer the authored URL's private/offline copy. Never put
|
|
7386
|
+
// these pixels in the public HTTP cache: media.clear owns them.
|
|
7387
|
+
CraftNativeMedia.local(this, uri)?.let { kept ->
|
|
7388
|
+
val decoded = kept.inputStream().use(BitmapFactory::decodeStream)
|
|
7389
|
+
return@runCatching decoded.takeIf { CraftNativeMedia.local(this, uri) == kept }
|
|
7390
|
+
}
|
|
7244
7391
|
val publicRequest = headers.isEmpty() && Uri.parse(requestUri).userInfo.isNullOrEmpty()
|
|
7245
7392
|
if (publicRequest) {
|
|
7246
7393
|
val bytes = imageCache.read(requestUri)
|
|
@@ -7495,6 +7642,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
7495
7642
|
val timeout = Runnable {
|
|
7496
7643
|
if (!screen.active || !screen.pendingCapabilities.remove(token)) return@Runnable
|
|
7497
7644
|
screen.capabilityTimeouts.remove(token)
|
|
7645
|
+
screen.soundRequestCancellers.remove(token)?.invoke()
|
|
7498
7646
|
capabilities.cancel(token)
|
|
7499
7647
|
send(screen, "API_ERROR", JSONObject()
|
|
7500
7648
|
.put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
|
|
@@ -7512,6 +7660,14 @@ class MainActivity : AppCompatActivity() {
|
|
|
7512
7660
|
val method = payload.optString("method")
|
|
7513
7661
|
val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (module == "View") {
|
|
7514
7662
|
{ completion -> performView(screen, method, args, completion) }
|
|
7663
|
+
} else if (module == "Audio") {
|
|
7664
|
+
{ completion ->
|
|
7665
|
+
val id = args.optString(0)
|
|
7666
|
+
performAudio(screen, method, args, completion)
|
|
7667
|
+
if (method == "play" && id.isNotBlank() && screen.pendingCapabilities.contains(token)) {
|
|
7668
|
+
screen.soundRequestCancellers[token] = screen.sounds.cancellation(id)
|
|
7669
|
+
}
|
|
7670
|
+
}
|
|
7515
7671
|
} else if (services.handles(module)) {
|
|
7516
7672
|
{ completion -> services.perform(module, method, args, completion) }
|
|
7517
7673
|
} else {
|
|
@@ -7529,6 +7685,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
7529
7685
|
perform { result ->
|
|
7530
7686
|
if ((!screen.active && !screen.closing) || !screen.pendingCapabilities.remove(token)) return@perform
|
|
7531
7687
|
screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
|
|
7688
|
+
screen.soundRequestCancellers.remove(token)
|
|
7532
7689
|
val response = JSONObject()
|
|
7533
7690
|
.put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
|
|
7534
7691
|
.put("requestId", id)
|
|
@@ -7538,6 +7695,27 @@ class MainActivity : AppCompatActivity() {
|
|
|
7538
7695
|
}
|
|
7539
7696
|
}
|
|
7540
7697
|
|
|
7698
|
+
private fun performAudio(
|
|
7699
|
+
screen: Screen,
|
|
7700
|
+
method: String,
|
|
7701
|
+
args: JSONArray,
|
|
7702
|
+
completion: (CraftNativeCapabilityResult) -> Unit,
|
|
7703
|
+
) {
|
|
7704
|
+
val id = args.optString(0)
|
|
7705
|
+
when (method) {
|
|
7706
|
+
"play" -> {
|
|
7707
|
+
val address = args.optString(1)
|
|
7708
|
+
screen.sounds.play(id, address, args.optJSONObject(2) ?: JSONObject(), completion)
|
|
7709
|
+
}
|
|
7710
|
+
"pause" -> completion(CraftNativeCapabilityResult(data = screen.sounds.pause(id)))
|
|
7711
|
+
"resume" -> completion(CraftNativeCapabilityResult(data = screen.sounds.resume(id)))
|
|
7712
|
+
"setVolume" -> completion(CraftNativeCapabilityResult(data = screen.sounds.setVolume(id, args.optDouble(1, Double.NaN), args.optJSONObject(2)?.optDouble("fade", 0.0) ?: 0.0)))
|
|
7713
|
+
"stop" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stop(id, args.optJSONObject(1)?.optDouble("fadeOut", 0.0) ?: 0.0)))
|
|
7714
|
+
"stopAll" -> completion(CraftNativeCapabilityResult(data = screen.sounds.stopAll(args.optJSONObject(0)?.optDouble("fadeOut", 0.0) ?: 0.0)))
|
|
7715
|
+
else -> completion(CraftNativeCapabilityResult(code = "UNKNOWN_ACTION", message = "Unsupported Audio method"))
|
|
7716
|
+
}
|
|
7717
|
+
}
|
|
7718
|
+
|
|
7541
7719
|
/** View operations are screen-owned: node ids only exist while a route is mounted. */
|
|
7542
7720
|
private fun performView(
|
|
7543
7721
|
screen: Screen,
|
|
@@ -8152,6 +8330,13 @@ class MainActivity : AppCompatActivity() {
|
|
|
8152
8330
|
|
|
8153
8331
|
private fun closeScreen(screen: Screen, done: () -> Unit = {}) {
|
|
8154
8332
|
if (!screen.active) { screen.close(done); return }
|
|
8333
|
+
// A snapshot writer may still be replacing a file after the isolate
|
|
8334
|
+
// starts teardown. Reject its promise before the port is closed; the
|
|
8335
|
+
// worker's later completion sees the id was already consumed.
|
|
8336
|
+
screen.pendingSyncResults.toList().forEach { id ->
|
|
8337
|
+
send(screen, "SYNC_RESULT", JSONObject().put("id", id).put("ok", false))
|
|
8338
|
+
}
|
|
8339
|
+
screen.pendingSyncResults.clear()
|
|
8155
8340
|
CraftNativeHub.unregister(screen.capabilityScope)
|
|
8156
8341
|
screen.timers.values.forEach(capabilityHandler::removeCallbacks)
|
|
8157
8342
|
screen.timers.clear()
|
package/dist/cli.js
CHANGED
|
@@ -3047,7 +3047,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3047
3047
|
`);
|
|
3048
3048
|
}
|
|
3049
3049
|
// package.json
|
|
3050
|
-
var version = "0.0.
|
|
3050
|
+
var version = "0.0.359";
|
|
3051
3051
|
|
|
3052
3052
|
// bin/cli.ts
|
|
3053
3053
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED