craft-native 0.0.358 → 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/CraftNativeShell.kt.template +20 -5
- package/dist/android/templates/CraftNativeSounds.kt.template +260 -0
- package/dist/android/templates/MainActivityNative.kt.template +175 -3
- 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. */
|
|
@@ -4141,6 +4250,10 @@ class MainActivity : AppCompatActivity() {
|
|
|
4141
4250
|
val pendingCapabilities = mutableSetOf<String>()
|
|
4142
4251
|
val capabilityTimeouts = mutableMapOf<String, Runnable>()
|
|
4143
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>()
|
|
4144
4257
|
var active = true
|
|
4145
4258
|
var closing = false
|
|
4146
4259
|
private set
|
|
@@ -4156,6 +4269,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
4156
4269
|
active = false
|
|
4157
4270
|
closing = true
|
|
4158
4271
|
audio.close()
|
|
4272
|
+
soundRequestCancellers.values.toList().forEach { it() }
|
|
4273
|
+
soundRequestCancellers.clear()
|
|
4274
|
+
sounds.close()
|
|
4159
4275
|
// Evaluation completion does not mean its outbound messages have
|
|
4160
4276
|
// reached the host. Close only after the ordered channel barrier,
|
|
4161
4277
|
// so onDestroy's final writes run before navigation continues.
|
|
@@ -4687,6 +4803,9 @@ class MainActivity : AppCompatActivity() {
|
|
|
4687
4803
|
activeScreen.view.onLayoutChanged = {
|
|
4688
4804
|
if (activeScreen.active) emitLayoutEvents(activeScreen)
|
|
4689
4805
|
}
|
|
4806
|
+
activeScreen.sounds.emit = { payload ->
|
|
4807
|
+
if (activeScreen.active) send(activeScreen, "SOUND_EVENT", payload)
|
|
4808
|
+
}
|
|
4690
4809
|
screen = activeScreen
|
|
4691
4810
|
CraftNativeHub.register(activeScreen.capabilityScope) { type, payload ->
|
|
4692
4811
|
if (activeScreen.active) send(activeScreen, type, payload)
|
|
@@ -5064,6 +5183,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5064
5183
|
/** The bridge's capability names: Craft's core set and this host's services. */
|
|
5065
5184
|
private fun capabilityNames(): JSONArray = capabilities.names().apply {
|
|
5066
5185
|
services.names().forEach { name -> put(name) }
|
|
5186
|
+
put("audio")
|
|
5067
5187
|
put("viewFocus")
|
|
5068
5188
|
put("viewMeasure")
|
|
5069
5189
|
put("viewScroll")
|
|
@@ -5082,13 +5202,20 @@ class MainActivity : AppCompatActivity() {
|
|
|
5082
5202
|
/** A synchronous store written in an isolate (CraftNativeShell.kt's prelude). */
|
|
5083
5203
|
private fun syncWrite(screen: Screen, payload: JSONObject) {
|
|
5084
5204
|
val id = payload.optString("id").takeIf { it.isNotBlank() }
|
|
5205
|
+
val store = payload.optString("store")
|
|
5206
|
+
if (id != null) screen.pendingSyncResults.add(id)
|
|
5085
5207
|
val reply: (Boolean) -> Unit = { ok ->
|
|
5086
|
-
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
|
+
}
|
|
5087
5211
|
}
|
|
5088
5212
|
val key = payload.optString("key")
|
|
5089
5213
|
val value = if (payload.isNull("value")) null else payload.optString("value")
|
|
5090
|
-
when (
|
|
5091
|
-
"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)
|
|
5092
5219
|
"snapshots" -> if (payload.optBoolean("clear")) {
|
|
5093
5220
|
val keep = payload.optJSONArray("keep") ?: JSONArray()
|
|
5094
5221
|
stores.clearSnapshots((0 until keep.length()).map { keep.optString(it) }.toSet(), reply)
|
|
@@ -5329,6 +5456,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
5329
5456
|
val token = "${screen.capabilityScope}/$id"
|
|
5330
5457
|
val pending = screen.pendingCapabilities.remove(token)
|
|
5331
5458
|
screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
|
|
5459
|
+
screen.soundRequestCancellers.remove(token)?.invoke()
|
|
5332
5460
|
if (pending) capabilities.cancel(token)
|
|
5333
5461
|
}
|
|
5334
5462
|
}
|
|
@@ -7254,6 +7382,12 @@ class MainActivity : AppCompatActivity() {
|
|
|
7254
7382
|
}
|
|
7255
7383
|
imageJobs[view] = imageExecutor.submit {
|
|
7256
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
|
+
}
|
|
7257
7391
|
val publicRequest = headers.isEmpty() && Uri.parse(requestUri).userInfo.isNullOrEmpty()
|
|
7258
7392
|
if (publicRequest) {
|
|
7259
7393
|
val bytes = imageCache.read(requestUri)
|
|
@@ -7508,6 +7642,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
7508
7642
|
val timeout = Runnable {
|
|
7509
7643
|
if (!screen.active || !screen.pendingCapabilities.remove(token)) return@Runnable
|
|
7510
7644
|
screen.capabilityTimeouts.remove(token)
|
|
7645
|
+
screen.soundRequestCancellers.remove(token)?.invoke()
|
|
7511
7646
|
capabilities.cancel(token)
|
|
7512
7647
|
send(screen, "API_ERROR", JSONObject()
|
|
7513
7648
|
.put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
|
|
@@ -7525,6 +7660,14 @@ class MainActivity : AppCompatActivity() {
|
|
|
7525
7660
|
val method = payload.optString("method")
|
|
7526
7661
|
val perform: ((CraftNativeCapabilityResult) -> Unit) -> Unit = if (module == "View") {
|
|
7527
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
|
+
}
|
|
7528
7671
|
} else if (services.handles(module)) {
|
|
7529
7672
|
{ completion -> services.perform(module, method, args, completion) }
|
|
7530
7673
|
} else {
|
|
@@ -7542,6 +7685,7 @@ class MainActivity : AppCompatActivity() {
|
|
|
7542
7685
|
perform { result ->
|
|
7543
7686
|
if ((!screen.active && !screen.closing) || !screen.pendingCapabilities.remove(token)) return@perform
|
|
7544
7687
|
screen.capabilityTimeouts.remove(token)?.let(capabilityHandler::removeCallbacks)
|
|
7688
|
+
screen.soundRequestCancellers.remove(token)
|
|
7545
7689
|
val response = JSONObject()
|
|
7546
7690
|
.put("version", CRAFT_NATIVE_CAPABILITY_PROTOCOL_VERSION)
|
|
7547
7691
|
.put("requestId", id)
|
|
@@ -7551,6 +7695,27 @@ class MainActivity : AppCompatActivity() {
|
|
|
7551
7695
|
}
|
|
7552
7696
|
}
|
|
7553
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
|
+
|
|
7554
7719
|
/** View operations are screen-owned: node ids only exist while a route is mounted. */
|
|
7555
7720
|
private fun performView(
|
|
7556
7721
|
screen: Screen,
|
|
@@ -8165,6 +8330,13 @@ class MainActivity : AppCompatActivity() {
|
|
|
8165
8330
|
|
|
8166
8331
|
private fun closeScreen(screen: Screen, done: () -> Unit = {}) {
|
|
8167
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()
|
|
8168
8340
|
CraftNativeHub.unregister(screen.capabilityScope)
|
|
8169
8341
|
screen.timers.values.forEach(capabilityHandler::removeCallbacks)
|
|
8170
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
package/dist/index.js
CHANGED
|
@@ -192,10 +192,12 @@ enum CraftNativeFiles {
|
|
|
192
192
|
do {
|
|
193
193
|
let url = try newFile(named: name)
|
|
194
194
|
guard let destination = CGImageDestinationCreateWithURL(url as CFURL, UTType.jpeg.identifier as CFString, 1, nil) else {
|
|
195
|
+
try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
|
|
195
196
|
return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved"))
|
|
196
197
|
}
|
|
197
198
|
CGImageDestinationAddImage(destination, image, [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary)
|
|
198
199
|
guard CGImageDestinationFinalize(destination) else {
|
|
200
|
+
try? FileManager.default.removeItem(at: url.deletingLastPathComponent())
|
|
199
201
|
return .failure(CraftNativeActionError(code: "WRITE_ERROR", message: "The photo could not be saved"))
|
|
200
202
|
}
|
|
201
203
|
return .success(describe(url, type: "image/jpeg", extra: ["width": image.width, "height": image.height]))
|
|
@@ -731,11 +733,11 @@ enum CraftNativeMedia {
|
|
|
731
733
|
lock.lock()
|
|
732
734
|
defer { lock.unlock() }
|
|
733
735
|
if let flight, inFlight[address]?.id != flight {
|
|
734
|
-
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The
|
|
736
|
+
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
|
|
735
737
|
}
|
|
736
738
|
// A background transfer from before the cache was cleared (sign-out).
|
|
737
739
|
if let stillWanted, !stillWanted() {
|
|
738
|
-
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The
|
|
740
|
+
return .failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))
|
|
739
741
|
}
|
|
740
742
|
let staging = root.appendingPathComponent(UUID().uuidString + ".part")
|
|
741
743
|
defer { try? fm.removeItem(at: staging) }
|
|
@@ -784,7 +786,7 @@ enum CraftNativeMedia {
|
|
|
784
786
|
catch { failure = error }
|
|
785
787
|
}
|
|
786
788
|
lock.unlock()
|
|
787
|
-
for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The
|
|
789
|
+
for done in waiting { done(.failure(CraftNativeActionError(code: "CANCELLED", message: "The media cache was cleared"))) }
|
|
788
790
|
if let failure { throw failure }
|
|
789
791
|
return removed
|
|
790
792
|
}
|