@craft-native/android 0.0.181 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/android",
3
- "version": "0.0.181",
3
+ "version": "0.0.182",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/android#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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,