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.
- package/dist/android/templates/AndroidManifest.xml.template +3 -1
- package/dist/android/templates/CraftNativeServices.kt.template +41 -3
- package/dist/android/templates/MainActivityNative.kt.template +221 -3
- package/dist/api/mobile.d.ts +7 -0
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +10 -0
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +10 -23
- package/dist/ios/templates/CraftNativeActions.swift +3 -1
- package/dist/ios/templates/CraftNativeScreen.swift +404 -3
- package/dist/ios/templates/CraftNativeServices.swift +137 -20
- package/dist/ios/templates/CraftNativeUI.swift +2 -1
- package/dist/mobile.js +1 -0
- package/package.json +1 -1
|
@@ -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
|
|
528
|
-
|
|
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
|
-
|
|
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 {
|
|
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,
|
package/dist/api/mobile.d.ts
CHANGED
|
@@ -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.
|
|
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
package/dist/index.js
CHANGED
package/dist/ios/src/index.js
CHANGED
|
@@ -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
|
-
|
|
6816
|
-
|
|
6817
|
-
//
|
|
6818
|
-
|
|
6819
|
-
|
|
6820
|
-
|
|
6821
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
52
|
-
|
|
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 =
|
|
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
|
|
99
|
-
///
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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)
|
|
342
|
-
/// session back to whichever of them still
|
|
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
|
-
|
|
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
|
|
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
|
|
364
|
-
///
|
|
365
|
-
|
|
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
|
-
|
|
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) => {
|