craft-native 0.0.180 → 0.0.182

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.
@@ -35,8 +35,10 @@
35
35
  <activity
36
36
  android:name=".MainActivity"
37
37
  android:exported="true"
38
- android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|uiMode|fontScale"
38
+ android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|fontScale"
39
39
  android:windowSoftInputMode="adjustResize"
40
+ android:supportsPictureInPicture="true"
41
+ android:resizeableActivity="true"
40
42
  android:launchMode="singleTask">
41
43
  <intent-filter>
42
44
  <action android:name="android.intent.action.MAIN" />
@@ -12,6 +12,7 @@ import android.os.Handler
12
12
  import android.os.Looper
13
13
  import android.provider.Settings
14
14
  import android.speech.tts.TextToSpeech
15
+ import android.speech.tts.Voice
15
16
  import android.speech.tts.UtteranceProgressListener
16
17
  import android.util.Log
17
18
  import android.view.WindowManager
@@ -64,6 +65,8 @@ internal class CraftNativeServices(
64
65
 
65
66
  /** What this host adds to the capability names the screen sees. */
66
67
  fun names(): List<String> = buildList {
68
+ // `craft.animation.next`: the screen's next change animates (MainActivity).
69
+ add("animation")
67
70
  add("dialog")
68
71
  add("browser")
69
72
  add("linking")
@@ -524,11 +527,22 @@ internal class CraftNativeServices(
524
527
  if (text.isBlank()) return done(failure("INVALID_ARGUMENT", "speech.speak needs some text to say"))
525
528
  val options = args.optJSONObject(1) ?: JSONObject()
526
529
  withSpeech { engine ->
527
- options.optString("language").takeIf { it.isNotBlank() }?.let { engine.language = Locale.forLanguageTag(it) }
528
- if (options.has("rate")) engine.setSpeechRate((options.optDouble("rate", 0.5) * 2).toFloat().coerceIn(0.1f, 4f))
530
+ val language = options.optString("language").takeIf { it.isNotBlank() }?.let(Locale::forLanguageTag) ?: Locale.getDefault()
531
+ val voice = bestVoice(engine, language)
532
+ if (voice != null) engine.setVoice(voice) else engine.setLanguage(language)
533
+ // The engine keeps its rate and pitch between calls: each
534
+ // cue sets its own, 1 being the voice's normal (as on iOS).
535
+ engine.setSpeechRate(speechNumber(options, "rate", 0.5, 2.0))
536
+ engine.setPitch(speechNumber(options, "pitch", 0.5, 2.0))
529
537
  val id = UUID.randomUUID().toString()
530
538
  speeches[id] = done
531
- val queue = if (options.optBoolean("interrupt", false)) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
539
+ // Interrupting stops everything said or waiting, as on iOS (the default there too).
540
+ var queue = if (options.optBoolean("interrupt", true)) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
541
+ val quietMs = (speechNumber(options, "preDelay", 0.0, 5.0) * 1000).toLong()
542
+ if (quietMs > 0) {
543
+ engine.playSilentUtterance(quietMs, queue, "$id-quiet")
544
+ queue = TextToSpeech.QUEUE_ADD
545
+ }
532
546
  if (engine.speak(text, queue, null, id) != TextToSpeech.SUCCESS) speeches.remove(id)?.invoke(success(false))
533
547
  }
534
548
  }
@@ -542,6 +556,30 @@ internal class CraftNativeServices(
542
556
  }
543
557
  }
544
558
 
559
+ /** A speech option clamped to its range, 1 when it is not given. */
560
+ private fun speechNumber(options: JSONObject, name: String, low: Double, high: Double): Float {
561
+ val fallback = if (name == "preDelay") 0.0 else 1.0
562
+ val value = options.optDouble(name, fallback).takeIf { it.isFinite() } ?: fallback
563
+ return value.coerceIn(low, high).toFloat()
564
+ }
565
+
566
+ private val voices = mutableMapOf<String, Voice?>()
567
+
568
+ /**
569
+ * The best installed voice for `language`: on the phone (never one that
570
+ * needs the network mid-workout), of the highest quality, then the
571
+ * quickest to answer. Remembered per language.
572
+ */
573
+ private fun bestVoice(engine: TextToSpeech, language: Locale): Voice? = voices.getOrPut(language.toLanguageTag()) {
574
+ val installed = runCatching { engine.voices }.getOrNull().orEmpty().filter { voice ->
575
+ !voice.isNetworkConnectionRequired &&
576
+ TextToSpeech.Engine.KEY_FEATURE_NOT_INSTALLED !in voice.features.orEmpty() &&
577
+ voice.locale.language == language.language
578
+ }
579
+ val sameCountry = installed.filter { language.country.isBlank() || it.locale.country == language.country }
580
+ (sameCountry.ifEmpty { installed }).maxWithOrNull(compareBy<Voice>({ it.quality }, { -it.latency }, { it.name }))
581
+ }
582
+
545
583
  private fun withSpeech(use: (TextToSpeech) -> Unit) {
546
584
  val engine = speech
547
585
  if (engine != null && speechReady) return use(engine)
@@ -10,7 +10,9 @@ import android.content.res.ColorStateList
10
10
  import android.content.res.Configuration
11
11
  import android.database.ContentObserver
12
12
  import android.content.Intent
13
+ import android.app.PictureInPictureParams
13
14
  import android.media.AudioAttributes
15
+ import android.media.AudioManager
14
16
  import android.media.AudioFormat
15
17
  import android.media.MediaPlayer
16
18
  import android.media.AudioTrack
@@ -46,6 +48,13 @@ import android.text.SpannableString
46
48
  import android.text.style.ReplacementSpan
47
49
  import android.util.Log
48
50
  import android.util.Base64
51
+ import android.transition.ChangeBounds
52
+ import android.transition.ChangeTransform
53
+ import android.transition.Fade
54
+ import android.transition.Transition
55
+ import android.transition.TransitionManager
56
+ import android.transition.TransitionSet
57
+ import android.util.Rational
49
58
  import android.util.TypedValue
50
59
  import android.view.Gravity
51
60
  import android.view.KeyEvent
@@ -58,6 +67,10 @@ import android.view.ViewGroup
58
67
  import android.view.ViewOutlineProvider
59
68
  import android.view.ViewParent
60
69
  import android.view.ViewTreeObserver
70
+ import android.view.animation.LinearInterpolator
71
+ import android.view.animation.PathInterpolator
72
+ import android.view.SurfaceHolder
73
+ import android.view.SurfaceView
61
74
  import android.widget.AdapterView
62
75
  import android.widget.ArrayAdapter
63
76
  import android.widget.Button
@@ -2390,6 +2403,17 @@ private class CraftNativeDatePickerView(context: Context) : FrameLayout(context)
2390
2403
  }
2391
2404
  }
2392
2405
 
2406
+ /**
2407
+ * A native screen's `<Video>`: the platform's VideoView.
2408
+ *
2409
+ * It plays without taking audio focus, so the person's music carries on
2410
+ * under it, as on iOS. `onLoad`, `onPlay`, `onPause`, `onEnd` and `onError`
2411
+ * reach the screen in the web's names, from the controls as well as from
2412
+ * props. With `pictureInPicture` the activity takes it into a floating
2413
+ * window when the person leaves the app while it plays (MainActivity
2414
+ * `updatePictureInPicture`), drawn there on a surface of the activity's own
2415
+ * while this view keeps its place.
2416
+ */
2393
2417
  private class CraftNativeVideoView(context: Context) : VideoView(context) {
2394
2418
  private var sourceUri = ""
2395
2419
  private var shouldLoop = false
@@ -2397,8 +2421,24 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2397
2421
  private var muted = false
2398
2422
  private var resizeMode = "contain"
2399
2423
  private var preparedPlayer: MediaPlayer? = null
2424
+ /** The screen's handlers by event name, and how an event reaches them. */
2425
+ var handlers: JSONObject = JSONObject()
2426
+ var emit: ((handler: String, nativeEvent: JSONObject) -> Unit)? = null
2427
+ /** May float in Picture in Picture while it plays. */
2428
+ var pictureInPicture = false
2429
+ private set
2430
+ /** Playing now, by the controls or by props. */
2431
+ var playing = false
2432
+ private set
2433
+ /** Playback started or stopped, or the choice changed: the activity's PiP params follow. */
2434
+ var onPictureInPictureChanged: (() -> Unit)? = null
2435
+
2436
+ init {
2437
+ // Mixed with the music rather than pausing it.
2438
+ setAudioFocusRequest(AudioManager.AUDIOFOCUS_NONE)
2439
+ }
2400
2440
 
2401
- fun configure(source: Any?, autoplay: Boolean, paused: Boolean, loop: Boolean, muted: Boolean, controls: Boolean, resizeMode: String?) {
2441
+ fun configure(source: Any?, autoplay: Boolean, paused: Boolean, loop: Boolean, muted: Boolean, controls: Boolean, resizeMode: String?, pictureInPicture: Boolean = false) {
2402
2442
  val uri = when (source) {
2403
2443
  is String -> source
2404
2444
  is JSONObject -> craftNativeOptionalString(source.opt("uri")).orEmpty()
@@ -2407,12 +2447,18 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2407
2447
  shouldLoop = loop
2408
2448
  shouldPlay = autoplay && !paused
2409
2449
  this.muted = muted
2450
+ val floating = pictureInPicture && !muted
2451
+ if (floating != this.pictureInPicture) {
2452
+ this.pictureInPicture = floating
2453
+ onPictureInPictureChanged?.invoke()
2454
+ }
2410
2455
  this.resizeMode = resizeMode?.lowercase()?.takeIf { it in setOf("cover", "contain", "stretch", "center") } ?: "contain"
2411
2456
  applyScalingMode()
2412
2457
  setMediaController(if (controls) MediaController(context).also { it.setAnchorView(this) } else null)
2413
2458
  if (uri != sourceUri) {
2414
2459
  sourceUri = uri
2415
2460
  stopPlayback()
2461
+ setPlaying(false)
2416
2462
  preparedPlayer = null
2417
2463
  if (uri.isBlank()) return
2418
2464
  // A copy kept on the phone (craft.media.keep) plays in place of
@@ -2425,9 +2471,19 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2425
2471
  mediaPlayer.isLooping = shouldLoop
2426
2472
  val volume = if (this.muted) 0f else 1f
2427
2473
  mediaPlayer.setVolume(volume, volume)
2474
+ fire("onLoad", JSONObject().put("duration", duration / 1000.0))
2428
2475
  if (shouldPlay) start()
2429
2476
  }
2430
- setOnCompletionListener { if (shouldLoop && !isPlaying) start() }
2477
+ setOnCompletionListener {
2478
+ if (shouldLoop && !isPlaying) start()
2479
+ else setPlaying(false)
2480
+ fire("onEnd")
2481
+ }
2482
+ setOnErrorListener { _, what, extra ->
2483
+ setPlaying(false)
2484
+ fire("onError", JSONObject().put("message", "The video could not be played ($what, $extra)"))
2485
+ true
2486
+ }
2431
2487
  }
2432
2488
  if (uri.isBlank()) return
2433
2489
  runCatching {
@@ -2436,10 +2492,55 @@ private class CraftNativeVideoView(context: Context) : VideoView(context) {
2436
2492
  }
2437
2493
  }
2438
2494
 
2495
+ override fun start() {
2496
+ super.start()
2497
+ setPlaying(true)
2498
+ }
2499
+
2500
+ override fun pause() {
2501
+ super.pause()
2502
+ setPlaying(false)
2503
+ }
2504
+
2505
+ private fun setPlaying(value: Boolean) {
2506
+ if (playing == value) return
2507
+ playing = value
2508
+ fire(if (value) "onPlay" else "onPause")
2509
+ onPictureInPictureChanged?.invoke()
2510
+ }
2511
+
2512
+ private fun fire(event: String, nativeEvent: JSONObject = JSONObject()) {
2513
+ val handler = craftNativeEventName(handlers, event)
2514
+ if (handler.isNotBlank()) emit?.invoke(handler, nativeEvent)
2515
+ }
2516
+
2517
+ /** Its width over height once known, inside what Picture in Picture allows. */
2518
+ fun aspectRatio(): Rational? {
2519
+ val player = preparedPlayer ?: return null
2520
+ val width = runCatching { player.videoWidth }.getOrDefault(0)
2521
+ val height = runCatching { player.videoHeight }.getOrDefault(0)
2522
+ if (width <= 0 || height <= 0) return null
2523
+ val ratio = (width.toDouble() / height).coerceIn(1 / 2.39, 2.39)
2524
+ return Rational((ratio * 1000).roundToInt(), 1000)
2525
+ }
2526
+
2527
+ /** Draws on `holder` (the floating window's surface), or back on its own with null. */
2528
+ fun showOn(holder: SurfaceHolder?): Boolean {
2529
+ val player = preparedPlayer ?: return false
2530
+ return runCatching { player.setDisplay(holder ?: this.holder) }.isSuccess
2531
+ }
2532
+
2439
2533
  fun stop() {
2534
+ // Released with its screen: nothing more is told to it.
2535
+ emit = null
2440
2536
  stopPlayback()
2537
+ setPlaying(false)
2441
2538
  sourceUri = ""
2442
2539
  preparedPlayer = null
2540
+ if (pictureInPicture) {
2541
+ pictureInPicture = false
2542
+ onPictureInPictureChanged?.invoke()
2543
+ }
2443
2544
  }
2444
2545
 
2445
2546
  private fun applyScalingMode() {
@@ -3845,6 +3946,7 @@ class MainActivity : AppCompatActivity() {
3845
3946
  if (visibleScreen === screen) applyChrome(screen)
3846
3947
  }
3847
3948
  }
3949
+ "ANIMATE_NEXT" -> if (screen !== appScreen && visibleScreen === screen) animateNextChange(screen, payload)
3848
3950
  "SYNC_WRITE" -> syncWrite(screen, payload)
3849
3951
  "TIMER" -> {
3850
3952
  val id = payload.optInt("id")
@@ -4541,14 +4643,20 @@ class MainActivity : AppCompatActivity() {
4541
4643
  reconcileChildren(screen, content, children, path, next, style, previousControls)
4542
4644
  }
4543
4645
  "Video" -> (previous as? CraftNativeVideoView ?: CraftNativeVideoView(this)).apply {
4646
+ handlers = events
4647
+ emit = { handler, nativeEvent ->
4648
+ send(screen, "EVENT", JSONObject().put("handlerName", handler).put("nativeEvent", nativeEvent))
4649
+ }
4650
+ onPictureInPictureChanged = { updatePictureInPicture() }
4544
4651
  configure(
4545
- source = props.opt("source"),
4652
+ source = props.opt("source") ?: props.opt("src"),
4546
4653
  autoplay = props.optBoolean("autoplay", props.optBoolean("autoPlay", false)),
4547
4654
  paused = props.optBoolean("paused", false),
4548
4655
  loop = props.optBoolean("loop", false),
4549
4656
  muted = props.optBoolean("muted", false),
4550
4657
  controls = props.optBoolean("controls", false),
4551
4658
  resizeMode = craftNativeResizeMode(style, props),
4659
+ pictureInPicture = props.optBoolean("pictureInPicture", false),
4552
4660
  )
4553
4661
  }
4554
4662
  "Map" -> (previous as? CraftNativeMapView ?: CraftNativeMapView(this)).apply {
@@ -4767,6 +4875,8 @@ class MainActivity : AppCompatActivity() {
4767
4875
  control.isLongClickable = false
4768
4876
  }
4769
4877
  applyAccessibility(control, props, type, explicitKey ?: protocolId ?: path)
4878
+ // One element across an animated change (craft.animation.next).
4879
+ control.transitionName = craftNativeOptionalString(props.opt("transitionID"))?.takeIf { it.isNotBlank() }
4770
4880
  val layoutHandler = craftNativeEventName(events, "onLayout")
4771
4881
  if (layoutHandler.isNotBlank()) {
4772
4882
  if (screen.layoutHandlers[control] != layoutHandler) screen.lastLayoutBounds.remove(control)
@@ -5914,6 +6024,114 @@ class MainActivity : AppCompatActivity() {
5914
6024
  .filterIsInstance<CraftNativeMapView>()
5915
6025
  .toSet()
5916
6026
 
6027
+ // MARK: Animated changes
6028
+
6029
+ /**
6030
+ * `craft.animation.next(options)`: the screen's next change animates,
6031
+ * views that stay moving to their new places, new ones fading in and
6032
+ * removed ones fading out, and two with the same `transitionID` moving as
6033
+ * one. Android's own transitions, which follow the person's animation
6034
+ * scale: with animations off it is a cut, as the system's own are.
6035
+ */
6036
+ private fun animateNextChange(screen: Screen, payload: JSONObject) {
6037
+ val seconds = payload.optDouble("duration", 0.35).takeIf { it.isFinite() } ?: 0.35
6038
+ val curve = payload.optString("curve", "spring")
6039
+ val transition = TransitionSet()
6040
+ transition.setOrdering(TransitionSet.ORDERING_TOGETHER)
6041
+ listOf(ChangeBounds(), ChangeTransform(), Fade()).forEach { part ->
6042
+ part.setMatchOrder(Transition.MATCH_NAME, Transition.MATCH_INSTANCE)
6043
+ transition.addTransition(part)
6044
+ }
6045
+ transition.setDuration((seconds.coerceIn(0.1, 1.0) * 1000).toLong())
6046
+ transition.setInterpolator(
6047
+ when (curve) {
6048
+ "linear" -> LinearInterpolator()
6049
+ "easeInOut" -> PathInterpolator(0.42f, 0f, 0.58f, 1f)
6050
+ // A spring with no bounce reads as a decelerating curve.
6051
+ else -> PathInterpolator(0.2f, 0f, 0f, 1f)
6052
+ },
6053
+ )
6054
+ TransitionManager.beginDelayedTransition(screen.view, transition)
6055
+ }
6056
+
6057
+ // MARK: Picture in Picture
6058
+
6059
+ /** The floating window's own surface, over everything while it shows. */
6060
+ private var pictureInPictureLayer: FrameLayout? = null
6061
+ private var pictureInPictureVideo: CraftNativeVideoView? = null
6062
+
6063
+ /** A video that asked for Picture in Picture and is playing on a screen in view. */
6064
+ private fun floatingVideo(): CraftNativeVideoView? = allScreens()
6065
+ .filter { !it.headless }
6066
+ .flatMap { retainedViews(it) }
6067
+ .filterIsInstance<CraftNativeVideoView>()
6068
+ .firstOrNull { it.pictureInPicture && it.playing && it.isShown }
6069
+
6070
+ private fun pictureInPictureAllowed(): Boolean =
6071
+ packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE)
6072
+
6073
+ /**
6074
+ * Picture in Picture follows the playing video: on Android 12 and later
6075
+ * the system floats it by itself as the person leaves the app (gesture
6076
+ * navigation included); before that `onUserLeaveHint` asks.
6077
+ */
6078
+ private fun updatePictureInPicture() {
6079
+ if (!pictureInPictureAllowed() || isInPictureInPictureMode) return
6080
+ val video = floatingVideo()
6081
+ val params = PictureInPictureParams.Builder()
6082
+ video?.aspectRatio()?.let { params.setAspectRatio(it) }
6083
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) params.setAutoEnterEnabled(video != null)
6084
+ runCatching { setPictureInPictureParams(params.build()) }
6085
+ }
6086
+
6087
+ override fun onUserLeaveHint() {
6088
+ super.onUserLeaveHint()
6089
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S || !pictureInPictureAllowed()) return
6090
+ val video = floatingVideo() ?: return
6091
+ val params = PictureInPictureParams.Builder()
6092
+ video.aspectRatio()?.let { params.setAspectRatio(it) }
6093
+ runCatching { enterPictureInPictureMode(params.build()) }
6094
+ }
6095
+
6096
+ override fun onPictureInPictureModeChanged(isInPictureInPictureMode: Boolean, newConfig: Configuration) {
6097
+ super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
6098
+ if (isInPictureInPictureMode) showVideoAlone() else putVideoBack()
6099
+ }
6100
+
6101
+ /**
6102
+ * The floating window shows the whole activity, so the video is drawn
6103
+ * alone on a surface over it, black around it. The video keeps playing
6104
+ * through the change: only where its frames go moves.
6105
+ */
6106
+ private fun showVideoAlone() {
6107
+ val video = floatingVideo() ?: return
6108
+ val host = window.decorView as? ViewGroup ?: return
6109
+ val surface = SurfaceView(this).apply {
6110
+ setZOrderMediaOverlay(true)
6111
+ holder.addCallback(object : SurfaceHolder.Callback {
6112
+ override fun surfaceCreated(holder: SurfaceHolder) { video.showOn(holder) }
6113
+ override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) = Unit
6114
+ override fun surfaceDestroyed(holder: SurfaceHolder) { video.showOn(null) }
6115
+ })
6116
+ }
6117
+ val layer = FrameLayout(this).apply {
6118
+ setBackgroundColor(Color.BLACK)
6119
+ addView(surface, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER))
6120
+ }
6121
+ host.addView(layer, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
6122
+ pictureInPictureLayer = layer
6123
+ pictureInPictureVideo = video
6124
+ }
6125
+
6126
+ /** Back in the app: the video draws in its place again. */
6127
+ private fun putVideoBack() {
6128
+ pictureInPictureVideo?.showOn(null)
6129
+ pictureInPictureVideo = null
6130
+ pictureInPictureLayer?.let { (it.parent as? ViewGroup)?.removeView(it) }
6131
+ pictureInPictureLayer = null
6132
+ updatePictureInPicture()
6133
+ }
6134
+
5917
6135
  private fun releaseFlatList(
5918
6136
  screen: Screen,
5919
6137
  list: CraftNativeFlatList,
@@ -737,6 +737,13 @@ export interface SpeechOptions {
737
737
  language?: string;
738
738
  /** Stop whatever is being said first. Defaults to true; false queues behind it. */
739
739
  interrupt?: boolean;
740
+ /** Voice pitch, 0.5 to 2. 1 is the voice's own; lower sounds calmer. */
741
+ pitch?: number;
742
+ /**
743
+ * Seconds of quiet before the words (up to 5), after whatever was said
744
+ * before them: a pause between two queued cues.
745
+ */
746
+ preDelay?: number;
740
747
  }
741
748
  export declare const brightness: {
742
749
  isAvailable(): boolean;
package/dist/cli.js CHANGED
@@ -3303,7 +3303,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
3303
3303
  `);
3304
3304
  }
3305
3305
  // package.json
3306
- var version = "0.0.180";
3306
+ var version = "0.0.182";
3307
3307
 
3308
3308
  // bin/cli.ts
3309
3309
  var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
@@ -417,6 +417,13 @@ export interface VideoProps extends BaseProps {
417
417
  muted?: boolean;
418
418
  paused?: boolean;
419
419
  resizeMode?: 'contain' | 'cover' | 'stretch';
420
+ /**
421
+ * Carries on in a floating window (Picture in Picture) when the app goes to
422
+ * the background or another app opens while it plays, and comes back into
423
+ * its place with the app. iOS needs `enableBackgroundAudio`. A video with
424
+ * sound plays mixed with the person's music either way.
425
+ */
426
+ pictureInPicture?: boolean;
420
427
  onLoad?: (event: {
421
428
  duration: number;
422
429
  }) => void;
@@ -430,6 +437,9 @@ export interface VideoProps extends BaseProps {
430
437
  onError?: (event: {
431
438
  message: string;
432
439
  }) => void;
440
+ /** It went into the floating window, and came back out (iOS). */
441
+ onPictureInPictureStart?: () => void;
442
+ onPictureInPictureStop?: () => void;
433
443
  }
434
444
  export interface MapRegion {
435
445
  center: {
package/dist/index.cjs CHANGED
@@ -310,7 +310,7 @@ function craftBinaryNotFoundMessage(triedPath) {
310
310
  `);
311
311
  }
312
312
  // package.json
313
- var version = "0.0.180";
313
+ var version = "0.0.182";
314
314
 
315
315
  // src/sdk-version.ts
316
316
  var SDK_VERSION = version;
package/dist/index.js CHANGED
@@ -58,7 +58,7 @@ function craftBinaryNotFoundMessage(triedPath) {
58
58
  `);
59
59
  }
60
60
  // package.json
61
- var version = "0.0.180";
61
+ var version = "0.0.182";
62
62
 
63
63
  // src/sdk-version.ts
64
64
  var SDK_VERSION = version;
@@ -197,7 +197,7 @@ function resolveCraftVersion(startDir = import.meta.dir) {
197
197
  while (true) {
198
198
  try {
199
199
  const packageJSON = JSON.parse(readFileSync2(join3(directory, "package.json"), "utf8"));
200
- if (packageJSON.name === "@craft-native/ios" && typeof packageJSON.version === "string" && packageJSON.version.length > 0)
200
+ if ((packageJSON.name === "@craft-native/ios" || packageJSON.name === "craft-native") && typeof packageJSON.version === "string" && packageJSON.version.length > 0)
201
201
  return packageJSON.version;
202
202
  } catch {}
203
203
  const parent = dirname2(directory);
@@ -6812,14 +6812,16 @@ struct CraftWebView: UIViewRepresentable {
6812
6812
  self.speechSynthesizer = fresh
6813
6813
  }
6814
6814
 
6815
- let utterance = AVSpeechUtterance(string: text)
6816
- utterance.rate = Self.utteranceRate(body["rate"] as? Double ?? 1)
6817
- // An unknown language finds no voice, and a nil voice is the
6818
- // device's own, which is the default anyway.
6819
- if let language = body["language"] as? String, !language.isEmpty,
6820
- let voice = AVSpeechSynthesisVoice(language: language) {
6821
- utterance.voice = voice
6822
- }
6815
+ // Set up as a native screen's is (CraftNativeServices.swift):
6816
+ // the rate, the pitch, the quiet before it, and the best voice
6817
+ // installed for the language.
6818
+ let utterance = CraftNativeSpeech.utterance(
6819
+ text,
6820
+ language: body["language"] as? String,
6821
+ rate: (body["rate"] as? NSNumber)?.doubleValue,
6822
+ pitch: (body["pitch"] as? NSNumber)?.doubleValue,
6823
+ preDelay: (body["preDelay"] as? NSNumber)?.doubleValue
6824
+ )
6823
6825
 
6824
6826
  self.pendingUtterances.append((utterance, callbackId))
6825
6827
  self.claimAudioSessionForSpeech {
@@ -6859,21 +6861,6 @@ struct CraftWebView: UIViewRepresentable {
6859
6861
  return wasSounding
6860
6862
  }
6861
6863
 
6862
- /// The page's rate, where 1 is normal, on AVSpeechUtterance's scale.
6863
- ///
6864
- /// Apple's scale is not linear in speed: the default sits at 0.5 and
6865
- /// the maximum is far faster than twice that. So a slower rate scales
6866
- /// the default down in proportion, and a faster one climbs toward the
6867
- /// maximum at half the slope, which keeps 2 brisk but intelligible.
6868
- private static func utteranceRate(_ rate: Double) -> Float {
6869
- let requested = Float(min(max(rate.isFinite ? rate : 1, 0.5), 2))
6870
- let normal = AVSpeechUtteranceDefaultSpeechRate
6871
- let mapped = requested <= 1
6872
- ? normal * requested
6873
- : normal + (AVSpeechUtteranceMaximumSpeechRate - normal) * (requested - 1) / 2
6874
- return min(max(mapped, AVSpeechUtteranceMinimumSpeechRate), AVSpeechUtteranceMaximumSpeechRate)
6875
- }
6876
-
6877
6864
  /// Duck the music rather than stop it, and play through the silent
6878
6865
  /// switch.
6879
6866
  ///
@@ -41,7 +41,7 @@ enum CraftNativeActions {
41
41
  static func capabilities(config: CraftConfig) -> [String] {
42
42
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
43
43
  // as it arrives) need nothing the app has to declare.
44
- var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge"]
44
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation"]
45
45
  if config.enableBiometric { values.append("biometric") }
46
46
  if config.enableSecureStorage { values.append("secureStorage") }
47
47
  if config.enableLocalDatabase { values.append("database") }
@@ -474,6 +474,8 @@ enum CraftNativeActions {
474
474
  text,
475
475
  language: options["language"] as? String,
476
476
  rate: (options["rate"] as? NSNumber)?.doubleValue,
477
+ pitch: (options["pitch"] as? NSNumber)?.doubleValue,
478
+ preDelay: (options["preDelay"] as? NSNumber)?.doubleValue,
477
479
  interrupt: options["interrupt"] as? Bool ?? true
478
480
  ) { spoken in complete(requestToken, .success(spoken), completion) }
479
481
  }
@@ -450,7 +450,17 @@ private func craftNativeAlignContentCode(_ value: String) -> UInt32 {
450
450
  }
451
451
  }
452
452
 
453
- private final class CraftNativeVideoView: UIView {
453
+ /// A native screen's `<Video>`: AVPlayerViewController in place.
454
+ ///
455
+ /// With sound, a video takes the audio session as a playing sound does
456
+ /// (`CraftNativeSounds`): playback, mixed with the person's music, heard
457
+ /// through the silent switch, and with speech ducking the music under a cue
458
+ /// as it does for any other sound. With `pictureInPicture` it carries on in
459
+ /// the system's floating player when the app goes to the background or
460
+ /// another app opens (the app needs the `audio` background mode, which
461
+ /// `enableBackgroundAudio` writes), and comes back into its place when the
462
+ /// app does.
463
+ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate {
454
464
  private let playerController = AVPlayerViewController()
455
465
  private var player: AVPlayer?
456
466
  private var sourceURI = ""
@@ -460,6 +470,8 @@ private final class CraftNativeVideoView: UIView {
460
470
  private var timeObserver: Any?
461
471
  private var loops = false
462
472
  private var wasPlaying: Bool?
473
+ /// This video holds a claim on the audio session (`CraftNativeSounds`).
474
+ private var claimedSession = false
463
475
  /// The screen's handlers by event name (`onEnd`, `onPlay`, `onPause`,
464
476
  /// `onProgress`, `onLoad`, `onError`), and how an event reaches them.
465
477
  var handlers: [String: String] = [:]
@@ -477,6 +489,8 @@ private final class CraftNativeVideoView: UIView {
477
489
  // to UI tests and assistive technology.
478
490
  playerController.view.isAccessibilityElement = false
479
491
  playerController.view.accessibilityElementsHidden = true
492
+ playerController.delegate = self
493
+ playerController.allowsPictureInPicturePlayback = false
480
494
  }
481
495
 
482
496
  required init?(coder: NSCoder) {
@@ -515,9 +529,24 @@ private final class CraftNativeVideoView: UIView {
515
529
  wasPlaying = nil
516
530
  player?.pause()
517
531
  player = nil
532
+ releaseSession()
518
533
  }
519
534
 
520
- func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, controls: Bool, resizeMode: String?) {
535
+ /// Playback, mixed with the music, before the player first makes a sound:
536
+ /// an AVPlayer started under the app's default session stops the music.
537
+ private func claimSession() {
538
+ guard !claimedSession else { return }
539
+ claimedSession = true
540
+ CraftNativeSounds.claimSession(respectSilentSwitch: false)
541
+ }
542
+
543
+ private func releaseSession() {
544
+ guard claimedSession else { return }
545
+ claimedSession = false
546
+ CraftNativeSounds.releaseSession()
547
+ }
548
+
549
+ func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, controls: Bool, resizeMode: String?, pictureInPicture: Bool = false) {
521
550
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String ?? ""
522
551
  if uri != sourceURI {
523
552
  detachPlayer()
@@ -545,14 +574,43 @@ private final class CraftNativeVideoView: UIView {
545
574
  }
546
575
  guard let player else { return }
547
576
  player.isMuted = muted
577
+ // A muted video makes no sound, so it leaves the session as it is.
578
+ if muted { releaseSession() } else { claimSession() }
548
579
  playerController.showsPlaybackControls = controls
549
580
  playerController.videoGravity = resizeMode == "cover" ? .resizeAspectFill
550
581
  : resizeMode == "stretch" ? .resize
551
582
  : .resizeAspect
583
+ // Picture in Picture needs the playback session, so a muted video has none.
584
+ let floating = pictureInPicture && !muted
585
+ playerController.allowsPictureInPicturePlayback = floating
586
+ if #available(iOS 14.2, *) {
587
+ playerController.canStartPictureInPictureAutomaticallyFromInline = floating
588
+ }
552
589
  loops = loop
553
590
  if paused || !autoplay { player.pause() } else { player.play() }
554
591
  }
555
592
 
593
+ // MARK: AVPlayerViewControllerDelegate
594
+
595
+ /// The video stays in its place on the screen while it floats.
596
+ func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
597
+ false
598
+ }
599
+
600
+ /// Back from the floating player (the app came back, or its button was
601
+ /// tapped): the video is still in its place, so there is nothing to put back.
602
+ func playerViewController(_ playerViewController: AVPlayerViewController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
603
+ completionHandler(player != nil)
604
+ }
605
+
606
+ func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
607
+ fire("onPictureInPictureStart")
608
+ }
609
+
610
+ func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
611
+ fire("onPictureInPictureStop")
612
+ }
613
+
556
614
  /// Load, play and pause, time, the end and failure, as the web's video
557
615
  /// element reports them, so `@ended` and `@timeupdate` work as on the page.
558
616
  private func observe(_ player: AVPlayer) {
@@ -1296,6 +1354,22 @@ private class CraftNativeFlowView: UIStackView {
1296
1354
  }
1297
1355
  }
1298
1356
 
1357
+ // MARK: Touches
1358
+
1359
+ /// A child drawn outside this view's bounds still takes its touches, as an
1360
+ /// overflowing element is clickable on the web, unless the view clips
1361
+ /// (`overflow: hidden`). UIKit only asks a view's children when the point
1362
+ /// is inside the view, so a floating button absolutely positioned out of a
1363
+ /// zero-height parent (a component's wrapper) drew but could not be tapped.
1364
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
1365
+ if let hit = super.hitTest(point, with: event) { return hit }
1366
+ guard !clipsToBounds, !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
1367
+ for child in subviews.reversed() where !child.isHidden && child.alpha > 0.01 && child.isUserInteractionEnabled {
1368
+ if let hit = child.hitTest(child.convert(point, from: self), with: event) { return hit }
1369
+ }
1370
+ return nil
1371
+ }
1372
+
1299
1373
  // MARK: Children
1300
1374
 
1301
1375
  override var arrangedSubviews: [UIView] { flowChildren }
@@ -3116,6 +3190,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3116
3190
  /// The compiler can emit an anchor as `TouchableOpacity`, so this map is
3117
3191
  /// intentionally shared by Link and the generic pressable path.
3118
3192
  private var linkTargets: [ObjectIdentifier: String] = [:]
3193
+ /// Views by their `transitionID`: one element across an animated change.
3194
+ private var transitionIDs: [ObjectIdentifier: String] = [:]
3195
+ /// `craft.animation.next`: the screen as it was, waiting for the change.
3196
+ private var animationTransaction: CraftNativeAnimationTransaction?
3197
+ /// The animation of the last change, still running.
3198
+ private var runningAnimation: CraftNativeRunningAnimation?
3119
3199
  private var renderedRoot: RenderedNode?
3120
3200
  private weak var lastFocusedInput: UIView?
3121
3201
 
@@ -4399,6 +4479,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4399
4479
  }
4400
4480
  case "NAVIGATION_SET_OPTIONS":
4401
4481
  applyNavigationOptions(payload)
4482
+ case "ANIMATE_NEXT":
4483
+ // The next change the screen makes animates (craft.animation.next).
4484
+ beginAnimatedChange(payload)
4402
4485
  case "NAVIGATE_OPEN":
4403
4486
  // A path rather than a screen: the hybrid shell opens it, natively
4404
4487
  // or in the web view.
@@ -4712,6 +4795,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4712
4795
  linkTargets.removeValue(forKey: ObjectIdentifier(result))
4713
4796
  }
4714
4797
  result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
4798
+ if let transitionID = props["transitionID"] as? String, !transitionID.isEmpty {
4799
+ transitionIDs[ObjectIdentifier(result)] = transitionID
4800
+ } else {
4801
+ transitionIDs.removeValue(forKey: ObjectIdentifier(result))
4802
+ }
4715
4803
  if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "SectionList" && type != "FlatList" && type != "Modal" {
4716
4804
  result.setContentHuggingPriority(.required, for: .vertical)
4717
4805
  }
@@ -5046,7 +5134,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5046
5134
  loop: (props["loop"] as? Bool) == true,
5047
5135
  muted: (props["muted"] as? Bool) == true,
5048
5136
  controls: (props["controls"] as? Bool) == true,
5049
- resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String
5137
+ resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String,
5138
+ pictureInPicture: (props["pictureInPicture"] as? Bool) == true
5050
5139
  )
5051
5140
  case "Map":
5052
5141
  let map = result as! CraftNativeMapView
@@ -5454,6 +5543,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5454
5543
  }
5455
5544
  handlers.removeValue(forKey: id)
5456
5545
  linkTargets.removeValue(forKey: id)
5546
+ transitionIDs.removeValue(forKey: id)
5457
5547
  pressInHandlers.removeValue(forKey: id)
5458
5548
  pressOutHandlers.removeValue(forKey: id)
5459
5549
  layoutHandlers.removeValue(forKey: id)
@@ -6771,6 +6861,317 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6771
6861
  }
6772
6862
  }
6773
6863
 
6864
+ // MARK: - Animated changes
6865
+
6866
+ /// What `craft.animation.next(options)` asked for. `duration` in seconds
6867
+ /// (0.1 to 1, 0.35 when left out); `curve` "spring" (the default, iOS's own
6868
+ /// smooth spring with no bounce unless `bounce` says), "easeInOut" or
6869
+ /// "linear".
6870
+ struct CraftNativeAnimationSpec {
6871
+ let duration: TimeInterval
6872
+ let curve: String
6873
+ let bounce: CGFloat
6874
+
6875
+ init(_ payload: [String: Any]) {
6876
+ let asked = (payload["duration"] as? NSNumber)?.doubleValue ?? 0.35
6877
+ duration = min(max(asked.isFinite ? asked : 0.35, 0.1), 1)
6878
+ let name = payload["curve"] as? String ?? "spring"
6879
+ curve = ["spring", "easeInOut", "linear"].contains(name) ? name : "spring"
6880
+ let springiness = (payload["bounce"] as? NSNumber)?.doubleValue ?? 0
6881
+ bounce = CGFloat(min(max(springiness.isFinite ? springiness : 0, 0), 0.5))
6882
+ }
6883
+
6884
+ var timing: UITimingCurveProvider {
6885
+ switch curve {
6886
+ case "linear": return UICubicTimingParameters(animationCurve: .linear)
6887
+ case "easeInOut": return UICubicTimingParameters(animationCurve: .easeInOut)
6888
+ default: return UISpringTimingParameters(dampingRatio: 1 - bounce)
6889
+ }
6890
+ }
6891
+ }
6892
+
6893
+ /// How long a change animates while one runs on any screen, so what the
6894
+ /// app changes beside it (the status bar's style) moves in step.
6895
+ enum CraftNativeAnimationClock {
6896
+ static var duration: TimeInterval?
6897
+ }
6898
+
6899
+ /// A view as it was when the change was asked for.
6900
+ final class CraftNativeAnimationRecord {
6901
+ let view: UIView
6902
+ weak var superview: UIView?
6903
+ /// In its parent's coordinates, and in the screen's.
6904
+ let frame: CGRect
6905
+ let frameOnScreen: CGRect
6906
+ let alpha: CGFloat
6907
+ let backgroundColor: UIColor?
6908
+ let fontSize: CGFloat?
6909
+ let textColor: UIColor?
6910
+ let transitionID: String?
6911
+
6912
+ init(view: UIView, screen: UIView, transitionID: String?) {
6913
+ self.view = view
6914
+ superview = view.superview
6915
+ frame = view.frame
6916
+ frameOnScreen = view.superview?.convert(view.frame, to: screen) ?? view.frame
6917
+ alpha = view.alpha
6918
+ backgroundColor = view.backgroundColor
6919
+ fontSize = (view as? UILabel)?.font.pointSize
6920
+ textColor = (view as? UILabel)?.textColor
6921
+ self.transitionID = transitionID
6922
+ }
6923
+ }
6924
+
6925
+ /// The screen between `craft.animation.next` and the change it animates.
6926
+ final class CraftNativeAnimationTransaction {
6927
+ let spec: CraftNativeAnimationSpec
6928
+ let reduceMotion: Bool
6929
+ var records: [ObjectIdentifier: CraftNativeAnimationRecord] = [:]
6930
+ var screenBackground: UIColor?
6931
+ /// Reduce Motion: the screen as it was, crossfaded into the new one.
6932
+ var picture: UIView?
6933
+
6934
+ init(spec: CraftNativeAnimationSpec, reduceMotion: Bool) {
6935
+ self.spec = spec
6936
+ self.reduceMotion = reduceMotion
6937
+ }
6938
+ }
6939
+
6940
+ /// An animation under way: finished at once if another change comes first.
6941
+ final class CraftNativeRunningAnimation {
6942
+ var animators: [UIViewPropertyAnimator] = []
6943
+ var cleanup: [() -> Void] = []
6944
+ private var done = false
6945
+
6946
+ func finish() {
6947
+ guard !done else { return }
6948
+ done = true
6949
+ for animator in animators where animator.state == .active {
6950
+ animator.stopAnimation(false)
6951
+ animator.finishAnimation(at: .end)
6952
+ }
6953
+ cleanup.forEach { $0() }
6954
+ cleanup.removeAll()
6955
+ }
6956
+ }
6957
+
6958
+ extension CraftNativeScreenController {
6959
+ /// `craft.animation.next(options)`: what the screen changes next (a
6960
+ /// render, a patch, its own background and bar) animates instead of
6961
+ /// cutting, as SwiftUI's `withAnimation` or React Native's
6962
+ /// `LayoutAnimation`. Views that stay move and resize to their new
6963
+ /// places; new views fade in and removed ones fade out where they were; a
6964
+ /// view whose font size changed scales into it. Two views with the same
6965
+ /// `transitionID`, one going and one coming, are one element: it moves and
6966
+ /// scales from the old one's place to the new one's, crossfading the two.
6967
+ /// With Reduce Motion on, the whole screen crossfades instead.
6968
+ func beginAnimatedChange(_ payload: [String: Any]) {
6969
+ guard !isAppScript, isViewLoaded else { return }
6970
+ // The last change's animation lands where it was going first.
6971
+ runningAnimation?.finish()
6972
+ runningAnimation = nil
6973
+ if animationTransaction != nil { return }
6974
+ let transaction = CraftNativeAnimationTransaction(spec: CraftNativeAnimationSpec(payload), reduceMotion: UIAccessibility.isReduceMotionEnabled)
6975
+ transaction.screenBackground = view.backgroundColor
6976
+ if transaction.reduceMotion {
6977
+ transaction.picture = view.window == nil ? nil : view.snapshotView(afterScreenUpdates: false)
6978
+ } else {
6979
+ view.layoutIfNeeded()
6980
+ recordAnimationState(renderedRoot, into: transaction)
6981
+ }
6982
+ animationTransaction = transaction
6983
+ CraftNativeAnimationClock.duration = transaction.spec.duration
6984
+ // The change comes from the same turn of the script (the state it
6985
+ // changed renders in a microtask); a slow one still animates.
6986
+ DispatchQueue.main.async { [weak self] in self?.commitAnimatedChange() }
6987
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in self?.commitAnimatedChange() }
6988
+ }
6989
+
6990
+ private func recordAnimationState(_ node: RenderedNode?, into transaction: CraftNativeAnimationTransaction) {
6991
+ guard let node else { return }
6992
+ let id = ObjectIdentifier(node.view)
6993
+ transaction.records[id] = CraftNativeAnimationRecord(view: node.view, screen: view, transitionID: transitionIDs[id])
6994
+ node.children.forEach { recordAnimationState($0, into: transaction) }
6995
+ }
6996
+
6997
+ private func renderedViews(_ node: RenderedNode?, into views: inout [UIView]) {
6998
+ guard let node else { return }
6999
+ views.append(node.view)
7000
+ node.children.forEach { renderedViews($0, into: &views) }
7001
+ }
7002
+
7003
+ private func commitAnimatedChange() {
7004
+ guard let transaction = animationTransaction else { return }
7005
+ animationTransaction = nil
7006
+ let spec = transaction.spec
7007
+ let running = CraftNativeRunningAnimation()
7008
+ runningAnimation = running
7009
+ running.cleanup.append { CraftNativeAnimationClock.duration = nil }
7010
+
7011
+ if transaction.reduceMotion {
7012
+ guard let picture = transaction.picture else { running.finish(); return }
7013
+ picture.frame = view.bounds
7014
+ picture.isUserInteractionEnabled = false
7015
+ view.addSubview(picture)
7016
+ let fade = UIViewPropertyAnimator(duration: 0.3, curve: .easeInOut) { picture.alpha = 0 }
7017
+ fade.addCompletion { _ in running.finish() }
7018
+ running.animators.append(fade)
7019
+ running.cleanup.append { picture.removeFromSuperview() }
7020
+ fade.startAnimation()
7021
+ return
7022
+ }
7023
+
7024
+ UIView.performWithoutAnimation { view.layoutIfNeeded() }
7025
+ var after: [UIView] = []
7026
+ renderedViews(renderedRoot, into: &after)
7027
+ let present = Set(after.map(ObjectIdentifier.init))
7028
+ let records = transaction.records
7029
+
7030
+ // Going: no longer drawn. Only the topmost of a removed subtree moves
7031
+ // to the overlay; what is under it goes with it.
7032
+ let removed = records.values.filter { !present.contains(ObjectIdentifier($0.view)) }
7033
+ let removedIds = Set(removed.map { ObjectIdentifier($0.view) })
7034
+ let removedRoots = removed.filter { record in
7035
+ guard let parent = record.superview else { return true }
7036
+ return !removedIds.contains(ObjectIdentifier(parent))
7037
+ }
7038
+ // Coming: not there before.
7039
+ let inserted = after.filter { records[ObjectIdentifier($0)] == nil }
7040
+ let insertedIds = Set(inserted.map(ObjectIdentifier.init))
7041
+
7042
+ // One element across the change: the old view going, the new one coming.
7043
+ var oldShared: [String: CraftNativeAnimationRecord] = [:]
7044
+ for record in removed { if let tag = record.transitionID { oldShared[tag] = record } }
7045
+ var pairs: [(old: CraftNativeAnimationRecord, new: UIView)] = []
7046
+ for candidate in inserted {
7047
+ if let tag = transitionIDs[ObjectIdentifier(candidate)], let old = oldShared.removeValue(forKey: tag) {
7048
+ pairs.append((old, candidate))
7049
+ }
7050
+ }
7051
+ let pairedNew = Set(pairs.map { ObjectIdentifier($0.new) })
7052
+
7053
+ let overlay = UIView(frame: view.bounds)
7054
+ overlay.isUserInteractionEnabled = false
7055
+ overlay.backgroundColor = .clear
7056
+ view.addSubview(overlay)
7057
+ running.cleanup.append { overlay.removeFromSuperview() }
7058
+
7059
+ let main = UIViewPropertyAnimator(duration: spec.duration, timingParameters: spec.timing)
7060
+ // Fades take the first part, so nothing is half there for long.
7061
+ let fadeOut = UIViewPropertyAnimator(duration: spec.duration * 0.5, curve: .easeOut)
7062
+ let fadeIn = UIViewPropertyAnimator(duration: spec.duration * 0.6, curve: .easeInOut)
7063
+ running.animators = [main, fadeOut, fadeIn]
7064
+
7065
+ // Removed views fade where they were, over what replaced them.
7066
+ for record in removedRoots {
7067
+ let going = record.view
7068
+ going.translatesAutoresizingMaskIntoConstraints = true
7069
+ going.transform = .identity
7070
+ going.frame = record.frameOnScreen
7071
+ overlay.addSubview(going)
7072
+ fadeOut.addAnimations { going.alpha = 0 }
7073
+ }
7074
+
7075
+ // The background, the screen's own included, goes from the old colour.
7076
+ if let before = transaction.screenBackground, let now = view.backgroundColor, !before.isEqual(now) {
7077
+ view.backgroundColor = before
7078
+ main.addAnimations { self.view.backgroundColor = now }
7079
+ }
7080
+
7081
+ // Shared elements: the new view starts where the old one was, at its
7082
+ // size, and the old one travels with it as it fades.
7083
+ for (old, new) in pairs {
7084
+ let target = new.superview?.convert(new.frame, to: view) ?? new.frame
7085
+ let move = morph(from: old.frameOnScreen, to: target, uniform: true)
7086
+ let finalAlpha = new.alpha
7087
+ let base = new.transform
7088
+ new.transform = base.concatenating(move)
7089
+ new.alpha = 0
7090
+ main.addAnimations { new.transform = base }
7091
+ fadeIn.addAnimations { new.alpha = finalAlpha }
7092
+ let going = old.view
7093
+ going.translatesAutoresizingMaskIntoConstraints = true
7094
+ going.transform = .identity
7095
+ going.frame = old.frameOnScreen
7096
+ going.alpha = old.alpha
7097
+ overlay.addSubview(going)
7098
+ main.addAnimations { going.transform = move.inverted() }
7099
+ fadeOut.addAnimations { going.alpha = 0 }
7100
+ running.cleanup.append { new.transform = base; new.alpha = finalAlpha }
7101
+ }
7102
+
7103
+ // New views fade in. A new container holding a shared element keeps it
7104
+ // in sight: only the rest of it fades, and its colour comes up.
7105
+ for added in inserted where !(added.superview.map { insertedIds.contains(ObjectIdentifier($0)) } ?? false) {
7106
+ fadeInInserted(added, pairedNew: pairedNew, animator: fadeIn, running: running)
7107
+ }
7108
+
7109
+ // Views that stay go to their new places and sizes.
7110
+ for record in records.values where present.contains(ObjectIdentifier(record.view)) {
7111
+ let stays = record.view
7112
+ if let label = stays as? UILabel, let before = record.fontSize, abs(before - label.font.pointSize) > 0.5 {
7113
+ let base = label.transform
7114
+ let target = label.superview?.convert(label.frame, to: view) ?? label.frame
7115
+ label.transform = base.concatenating(morph(from: record.frameOnScreen, to: target, uniform: true))
7116
+ main.addAnimations { label.transform = base }
7117
+ running.cleanup.append { label.transform = base }
7118
+ } else if record.superview === stays.superview, record.frame != stays.frame {
7119
+ let final = stays.frame
7120
+ stays.frame = record.frame
7121
+ main.addAnimations { stays.frame = final }
7122
+ }
7123
+ if let before = record.backgroundColor, let now = stays.backgroundColor, !before.isEqual(now) {
7124
+ stays.backgroundColor = before
7125
+ main.addAnimations { stays.backgroundColor = now }
7126
+ }
7127
+ if let label = stays as? UILabel, let before = record.textColor, !before.isEqual(label.textColor) {
7128
+ let now = label.textColor
7129
+ label.textColor = before
7130
+ UIView.transition(with: label, duration: spec.duration * 0.6, options: [.transitionCrossDissolve, .allowAnimatedContent]) { label.textColor = now }
7131
+ }
7132
+ }
7133
+
7134
+ main.addCompletion { _ in running.finish() }
7135
+ fadeOut.startAnimation()
7136
+ fadeIn.startAnimation()
7137
+ main.startAnimation()
7138
+ }
7139
+
7140
+ /// The transform that puts a view at `final` where `start` was: moved
7141
+ /// centre to centre and scaled, by height alone when `uniform`, so text
7142
+ /// keeps its shape.
7143
+ private func morph(from start: CGRect, to final: CGRect, uniform: Bool) -> CGAffineTransform {
7144
+ guard final.width > 0, final.height > 0, start.width > 0, start.height > 0 else { return .identity }
7145
+ let sy = start.height / final.height
7146
+ let sx = uniform ? sy : start.width / final.width
7147
+ let dx = start.midX - final.midX
7148
+ let dy = start.midY - final.midY
7149
+ return CGAffineTransform(translationX: dx, y: dy).scaledBy(x: sx, y: sy)
7150
+ }
7151
+
7152
+ private func fadeInInserted(_ view: UIView, pairedNew: Set<ObjectIdentifier>, animator: UIViewPropertyAnimator, running: CraftNativeRunningAnimation) {
7153
+ if pairedNew.contains(ObjectIdentifier(view)) { return }
7154
+ guard containsShared(view, pairedNew: pairedNew) else {
7155
+ let finalAlpha = view.alpha
7156
+ view.alpha = 0
7157
+ animator.addAnimations { view.alpha = finalAlpha }
7158
+ running.cleanup.append { view.alpha = finalAlpha }
7159
+ return
7160
+ }
7161
+ if let colour = view.backgroundColor, colour != .clear {
7162
+ view.backgroundColor = colour.withAlphaComponent(0)
7163
+ animator.addAnimations { view.backgroundColor = colour }
7164
+ running.cleanup.append { view.backgroundColor = colour }
7165
+ }
7166
+ for child in view.subviews { fadeInInserted(child, pairedNew: pairedNew, animator: animator, running: running) }
7167
+ }
7168
+
7169
+ private func containsShared(_ view: UIView, pairedNew: Set<ObjectIdentifier>) -> Bool {
7170
+ if pairedNew.contains(ObjectIdentifier(view)) { return true }
7171
+ return view.subviews.contains { containsShared($0, pairedNew: pairedNew) }
7172
+ }
7173
+ }
7174
+
6774
7175
  /// `console` for native screens: the unified log, subsystem `craft.native`,
6775
7176
  /// category the screen's name. Read it with
6776
7177
  /// `log stream --predicate 'subsystem == "craft.native"'`.
@@ -48,18 +48,16 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
48
48
 
49
49
  /// Says `text`; `done` hears true once it was spoken, false when it was
50
50
  /// stopped or interrupted. With `interrupt` false it waits its turn behind
51
- /// what is being said. Main thread.
52
- func speak(_ text: String, language: String?, rate: Double?, interrupt: Bool, done: @escaping (Bool) -> Void) {
51
+ /// what is being said. `pitch` is 0.5 to 2 (1 is the voice's own), and
52
+ /// `preDelay` is seconds of quiet before the words, after whatever was
53
+ /// said before them. Main thread.
54
+ func speak(_ text: String, language: String?, rate: Double?, pitch: Double? = nil, preDelay: Double? = nil, interrupt: Bool, done: @escaping (Bool) -> Void) {
53
55
  pendingRelease?.cancel()
54
56
  pendingRelease = nil
55
57
  if interrupt, cancelPending() { retireSynthesizer() }
56
- let utterance = AVSpeechUtterance(string: text)
57
- utterance.rate = Self.utteranceRate(rate ?? 1)
58
- // An unknown language finds no voice, and a nil voice is the device's own.
59
- if let language, !language.isEmpty, let voice = AVSpeechSynthesisVoice(language: language) {
60
- utterance.voice = voice
61
- }
58
+ let utterance = Self.utterance(text, language: language, rate: rate, pitch: pitch, preDelay: preDelay)
62
59
  pending.append((utterance, done))
60
+ holdsSession = true
63
61
  sessionQueue.async {
64
62
  self.claimSession()
65
63
  DispatchQueue.main.async {
@@ -70,6 +68,74 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
70
68
  }
71
69
  }
72
70
 
71
+ /// Whether speech has the audio session, or is about to: a sound or a
72
+ /// video starting meanwhile leaves the category to speech, which hands it
73
+ /// over when it lets go (`CraftNativeSounds.reclaimSessionIfPlaying`).
74
+ /// Main thread.
75
+ private(set) var holdsSession = false
76
+
77
+ /// One utterance, set up the same way for a native screen and the page
78
+ /// (CraftApp.swift): the rate on AVSpeechUtterance's scale, the pitch,
79
+ /// the quiet before it, and the best voice installed for the language.
80
+ static func utterance(_ text: String, language: String?, rate: Double?, pitch: Double?, preDelay: Double?) -> AVSpeechUtterance {
81
+ let utterance = AVSpeechUtterance(string: text)
82
+ utterance.rate = utteranceRate(rate ?? 1)
83
+ let wantedPitch = pitch.flatMap { $0.isFinite ? $0 : nil } ?? 1
84
+ utterance.pitchMultiplier = Float(min(max(wantedPitch, 0.5), 2))
85
+ let quiet = preDelay.flatMap { $0.isFinite ? $0 : nil } ?? 0
86
+ utterance.preUtteranceDelay = min(max(quiet, 0), 5)
87
+ // An unknown language finds no voice, and a nil voice is the device's own.
88
+ if let voice = bestVoice(for: language) { utterance.voice = voice }
89
+ return utterance
90
+ }
91
+
92
+ private static var voices: [String: AVSpeechSynthesisVoice] = [:]
93
+ private static var voicesObserver: NSObjectProtocol?
94
+
95
+ /// The best voice on this phone for `language` (the device's own when
96
+ /// nil): a premium voice when one is installed, else an enhanced one,
97
+ /// else the compact voice iOS would pick. Among voices of one quality the
98
+ /// one sharing the default voice's name wins ("Anna (Enhanced)" over
99
+ /// another enhanced voice when Anna is the default), so a download in
100
+ /// Settings changes how a voice sounds, not who it is. Novelty voices and
101
+ /// the person's own Personal Voice are never picked. Remembered per
102
+ /// language until the installed voices change. Main thread.
103
+ static func bestVoice(for language: String?) -> AVSpeechSynthesisVoice? {
104
+ let wanted = (language?.isEmpty == false ? language! : AVSpeechSynthesisVoice.currentLanguageCode())
105
+ if let known = voices[wanted] { return known }
106
+ if voicesObserver == nil {
107
+ if #available(iOS 17.0, *) {
108
+ voicesObserver = NotificationCenter.default.addObserver(forName: AVSpeechSynthesizer.availableVoicesDidChangeNotification, object: nil, queue: .main) { _ in
109
+ CraftNativeSpeech.voices.removeAll()
110
+ }
111
+ }
112
+ }
113
+ guard let standard = AVSpeechSynthesisVoice(language: wanted) else { return nil }
114
+ let name = Self.baseName(standard.name)
115
+ let candidates = AVSpeechSynthesisVoice.speechVoices().filter { voice in
116
+ guard voice.language == standard.language else { return false }
117
+ if #available(iOS 17.0, *) {
118
+ if voice.voiceTraits.contains(.isNoveltyVoice) || voice.voiceTraits.contains(.isPersonalVoice) { return false }
119
+ }
120
+ return true
121
+ }
122
+ let best = candidates.max { lhs, rhs in
123
+ let left = (lhs.quality.rawValue, Self.baseName(lhs.name) == name ? 1 : 0, lhs.identifier == standard.identifier ? 1 : 0)
124
+ let right = (rhs.quality.rawValue, Self.baseName(rhs.name) == name ? 1 : 0, rhs.identifier == standard.identifier ? 1 : 0)
125
+ if left != right { return left < right }
126
+ // Stable between launches: the same voice every time.
127
+ return lhs.identifier > rhs.identifier
128
+ } ?? standard
129
+ let chosen = best.quality.rawValue >= standard.quality.rawValue ? best : standard
130
+ voices[wanted] = chosen
131
+ return chosen
132
+ }
133
+
134
+ /// "Anna (Enhanced)" and "Anna (Premium)" are both Anna.
135
+ private static func baseName(_ name: String) -> String {
136
+ name.components(separatedBy: " (").first ?? name
137
+ }
138
+
73
139
  func stop() {
74
140
  // When something was sounding, its didCancel hands the session back
75
141
  // once the audio has stopped; deactivating before that fails as busy.
@@ -95,8 +161,13 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
95
161
  synthesizer = fresh
96
162
  }
97
163
 
98
- /// The page's rate, where 1 is normal, on AVSpeechUtterance's scale: the
99
- /// same mapping as the page's speech, so a cue sounds alike in both.
164
+ /// The page's rate, where 1 is normal, on AVSpeechUtterance's scale, for
165
+ /// a native screen and the page alike.
166
+ ///
167
+ /// Apple's scale is not linear in speed: the default sits at 0.5 and the
168
+ /// maximum is far faster than twice that. So a slower rate scales the
169
+ /// default down in proportion, and a faster one climbs toward the maximum
170
+ /// at half the slope, which keeps 2 brisk but intelligible.
100
171
  static func utteranceRate(_ rate: Double) -> Float {
101
172
  let requested = Float(min(max(rate.isFinite ? rate : 1, 0.5), 2))
102
173
  let normal = AVSpeechUtteranceDefaultSpeechRate
@@ -129,11 +200,25 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
129
200
  guard let self else { return }
130
201
  self.pendingRelease = nil
131
202
  guard self.pending.isEmpty, !self.synthesizer.isSpeaking else { return }
203
+ let soundsPlaying = CraftNativeSounds.isPlaying
132
204
  self.sessionQueue.async {
133
- guard let previous = self.sessionToRestore else { return }
205
+ guard let previous = self.sessionToRestore else {
206
+ // The claim failed: there is nothing to hand back.
207
+ DispatchQueue.main.async {
208
+ guard self.pending.isEmpty else { return }
209
+ self.holdsSession = false
210
+ CraftNativeSounds.reclaimSessionIfPlaying(handedBack: nil)
211
+ }
212
+ return
213
+ }
134
214
  let session = AVAudioSession.sharedInstance()
135
215
  do {
136
- try session.setActive(false, options: .notifyOthersOnDeactivation)
216
+ // Deactivating ends the ducking. A video or a sound still
217
+ // playing keeps it busy; the category put back below
218
+ // carries no ducking, so the music comes back up anyway.
219
+ if !soundsPlaying {
220
+ try session.setActive(false, options: .notifyOthersOnDeactivation)
221
+ }
137
222
  } catch {
138
223
  // Busy: the last of the audio has not drained, or the app
139
224
  // plays something of its own. Once more shortly covers the first.
@@ -144,7 +229,11 @@ final class CraftNativeSpeech: NSObject, AVSpeechSynthesizerDelegate {
144
229
  }
145
230
  self.sessionToRestore = nil
146
231
  try? session.setCategory(previous.0, mode: previous.1, options: previous.2)
147
- DispatchQueue.main.async { CraftNativeSounds.reclaimSessionIfPlaying() }
232
+ DispatchQueue.main.async {
233
+ // A cue that came in meanwhile claims the session again.
234
+ if self.pending.isEmpty { self.holdsSession = false }
235
+ CraftNativeSounds.reclaimSessionIfPlaying(handedBack: previous)
236
+ }
148
237
  }
149
238
  }
150
239
  pendingRelease = work
@@ -337,12 +426,26 @@ final class CraftNativeSounds {
337
426
  RunLoop.main.add(timer, forMode: .common)
338
427
  }
339
428
 
429
+ /// Whether a sound, a native screen's Web Audio or a video is playing.
430
+ static var isPlaying: Bool { playing > 0 }
431
+ /// A sound started while speech held the session: speech's category was
432
+ /// left alone (it plays too, ducking the music under the cue), and the
433
+ /// category to put back later is the one speech hands back.
434
+ private static var claimedUnderSpeech = false
435
+
340
436
  /// Takes the session for one more playing sound. Native screens' Web
341
- /// Audio (CraftWebAudio.swift) counts here too, so speech hands the
342
- /// session back to whichever of them still plays.
437
+ /// Audio (CraftWebAudio.swift) and videos (CraftNativeScreen.swift) count
438
+ /// here too, so speech hands the session back to whichever of them still
439
+ /// plays. Main thread.
343
440
  static func claimSession(respectSilentSwitch: Bool) {
344
441
  playing += 1
345
- category = respectSilentSwitch ? .ambient : .playback
442
+ // Playback outranks ambient: a video heard through the silent switch
443
+ // is not muted by a breathing exercise's rain starting after it.
444
+ category = respectSilentSwitch && (playing == 1 || category == .ambient) ? .ambient : .playback
445
+ if CraftNativeSpeech.shared.holdsSession {
446
+ if sessionToRestore == nil { claimedUnderSpeech = true }
447
+ return
448
+ }
346
449
  let session = AVAudioSession.sharedInstance()
347
450
  if sessionToRestore == nil { sessionToRestore = (session.category, session.mode, session.categoryOptions) }
348
451
  do {
@@ -355,14 +458,28 @@ final class CraftNativeSounds {
355
458
 
356
459
  static func releaseSession() {
357
460
  playing = max(0, playing - 1)
358
- guard playing == 0, let previous = sessionToRestore else { return }
461
+ guard playing == 0 else { return }
462
+ if claimedUnderSpeech {
463
+ // Speech still holds the session and puts its own back.
464
+ claimedUnderSpeech = false
465
+ return
466
+ }
467
+ guard let previous = sessionToRestore else { return }
359
468
  sessionToRestore = nil
469
+ if CraftNativeSpeech.shared.holdsSession { return }
360
470
  try? AVAudioSession.sharedInstance().setCategory(previous.0, mode: previous.1, options: previous.2)
361
471
  }
362
472
 
363
- /// Speech handed the session back with the category it found, which may
364
- /// predate a sound started while it spoke: take it again for the sound.
365
- static func reclaimSessionIfPlaying() {
473
+ /// Speech handed the session back with the category it found
474
+ /// (`handedBack`, nil when it never took it), which may predate a sound
475
+ /// started while it spoke: take it again for the sound. Main thread.
476
+ static func reclaimSessionIfPlaying(handedBack: (AVAudioSession.Category, AVAudioSession.Mode, AVAudioSession.CategoryOptions)?) {
477
+ if claimedUnderSpeech {
478
+ claimedUnderSpeech = false
479
+ let session = AVAudioSession.sharedInstance()
480
+ sessionToRestore = handedBack ?? (session.category, session.mode, session.categoryOptions)
481
+ if playing > 0 { try? session.setActive(true) }
482
+ }
366
483
  guard playing > 0 else { return }
367
484
  try? AVAudioSession.sharedInstance().setCategory(category, mode: .default, options: [.mixWithOthers])
368
485
  }
@@ -522,7 +522,8 @@ enum CraftStatusBar {
522
522
  }
523
523
  guard let root = CraftPresenter.keyWindow()?.rootViewController else { return false }
524
524
  teach(root)
525
- UIView.animate(withDuration: 0.2) { root.setNeedsStatusBarAppearanceUpdate() }
525
+ // In step with a screen's animated change (craft.animation.next) when one runs.
526
+ UIView.animate(withDuration: CraftNativeAnimationClock.duration ?? 0.2) { root.setNeedsStatusBarAppearanceUpdate() }
526
527
  return true
527
528
  }
528
529
 
package/dist/mobile.js CHANGED
@@ -688,6 +688,7 @@ var speech = {
688
688
  return new Promise((resolve) => {
689
689
  const utterance = new Utterance(text);
690
690
  utterance.rate = speechRate(options.rate);
691
+ utterance.pitch = speechRate(options.pitch);
691
692
  if (options.language)
692
693
  utterance.lang = options.language;
693
694
  const settle = (spoken) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "craft-native",
3
- "version": "0.0.180",
3
+ "version": "0.0.182",
4
4
  "type": "module",
5
5
  "description": "Build desktop apps with web languages - TypeScript SDK for Craft",
6
6
  "author": "Chris Breuer <chris@stacksjs.org>",