@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
|
@@ -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,
|