capacitor-lottie-splash 0.1.2 → 0.1.4

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/README.md CHANGED
@@ -101,9 +101,9 @@ The documented HeadCount defaults are light `#f0f0f0 → #f5f5f5` and dark `#1e1
101
101
 
102
102
  ## Recovery and accessibility
103
103
 
104
- When playback completes before readiness, its final frame holds. After one second, a subtle indeterminate progress rail appears beneath the mark. After 15 foreground seconds the rail stops and a native/browser recovery surface presents “Still starting…” and “Try again.” The native retry reloads the WebView, so it does not depend on JavaScript having already loaded. Reduced-motion and asset failures retain the theme-matched native cover instead of exposing a blank screen.
104
+ Each presentation fades in over 500 milliseconds before Lottie playback begins, then fades out over 500 milliseconds when the application is ready. When playback completes before readiness, its final frame holds. After one second, a subtle indeterminate progress rail appears beneath the mark and remains visible until the application is ready. Reduced-motion and asset failures retain the theme-matched native cover instead of exposing a blank screen.
105
105
 
106
- For a staged native/over-the-air rollout, the consuming app must keep its web bundle and native host compatible. The native cover stays visible until the app-ready latch is set; a broken boot transitions to the visible recovery surface instead of exposing a blank page.
106
+ For a staged native/over-the-air rollout, the consuming app must keep its web bundle and native host compatible. The native cover stays visible until the app-ready latch is set.
107
107
 
108
108
  ## Compatibility
109
109
 
@@ -12,10 +12,7 @@ import android.view.View
12
12
  import android.view.ViewGroup
13
13
  import android.view.animation.AccelerateDecelerateInterpolator
14
14
  import android.view.animation.LinearInterpolator
15
- import android.widget.Button
16
15
  import android.widget.FrameLayout
17
- import android.widget.LinearLayout
18
- import android.widget.TextView
19
16
  import com.airbnb.lottie.LottieAnimationView
20
17
  import com.airbnb.lottie.LottieCompositionFactory
21
18
  import androidx.activity.ComponentActivity
@@ -23,7 +20,7 @@ import java.util.UUID
23
20
  import kotlin.math.max
24
21
  import kotlin.math.roundToInt
25
22
 
26
- enum class AndroidSplashPhase { PREPARING, PLAYING, HOLDING, HIDING, HIDDEN, RECOVERY }
23
+ enum class AndroidSplashPhase { PREPARING, PLAYING, HOLDING, HIDING, HIDDEN }
27
24
  enum class AndroidSplashPlayback { PLAY, HOLD_FINAL_FRAME }
28
25
 
29
26
  /** A small indeterminate rail that reads as loading without covering the finished logo. */
@@ -82,7 +79,7 @@ object LottieSplashCoordinator {
82
79
  // incorrectly dismissing a healthy native cover during that startup work.
83
80
  private const val WEB_ADOPTION_GRACE_MS = 30_000L
84
81
  private const val PROGRESS_DELAY_MS = 1_000L
85
- private const val RECOVERY_DELAY_MS = 15_000L
82
+ private const val COVER_FADE_IN_MS = 500L
86
83
  private const val COVER_FADE_OUT_MS = 500L
87
84
  private val main = Handler(Looper.getMainLooper())
88
85
 
@@ -92,10 +89,10 @@ object LottieSplashCoordinator {
92
89
  private var hostForeground = false
93
90
  private var systemHandoffComplete = true
94
91
  private var compositionReady = false
92
+ private var coverFadeInStarted = false
93
+ private var coverFadeInComplete = false
95
94
  private var requestedPlayback = AndroidSplashPlayback.PLAY
96
95
  private var loadingRail: LottieSplashLoadingRail? = null
97
- private var recoveryMessage: TextView? = null
98
- private var recoveryRetry: Button? = null
99
96
  private var presentationId: String? = null
100
97
  private var phase = AndroidSplashPhase.HIDDEN
101
98
  private var playbackSettled = false
@@ -106,16 +103,13 @@ object LottieSplashCoordinator {
106
103
  private var completion: ((String, String?) -> Unit)? = null
107
104
  private var pendingHide: ((Throwable?) -> Unit)? = null
108
105
  private var progressTask: Runnable? = null
109
- private var recoveryTask: Runnable? = null
110
106
  private var adoptionTask: Runnable? = null
111
107
  private var adoptedByWeb = false
112
108
  private var progressRemainingMs = PROGRESS_DELAY_MS
113
- private var recoveryRemainingMs = RECOVERY_DELAY_MS
114
109
  private var waitingClockStartedAtMs = 0L
115
110
  private var waitingClockRunning = false
116
111
 
117
112
  var onStateChange: (() -> Unit)? = null
118
- var onRecoveryRequested: (() -> Unit)? = null
119
113
 
120
114
  /**
121
115
  * Called by AndroidX while deciding whether its system-owned launch surface may leave.
@@ -144,6 +138,8 @@ object LottieSplashCoordinator {
144
138
  hostForeground = activity.hasWindowFocus()
145
139
  systemHandoffComplete = !dismissWhenUnadopted
146
140
  compositionReady = false
141
+ coverFadeInStarted = false
142
+ coverFadeInComplete = false
147
143
  requestedPlayback = playback
148
144
  presentationId = UUID.randomUUID().toString()
149
145
  phase = AndroidSplashPhase.PREPARING
@@ -153,8 +149,6 @@ object LottieSplashCoordinator {
153
149
  playbackOutcome = "static-fallback"
154
150
  playbackReason = null
155
151
  loadingRail = null
156
- recoveryMessage = null
157
- recoveryRetry = null
158
152
 
159
153
  val backgroundStart = if (darkTheme) Color.rgb(30, 30, 30) else Color.rgb(240, 240, 240)
160
154
  root.setBackgroundColor(backgroundStart)
@@ -165,7 +159,7 @@ object LottieSplashCoordinator {
165
159
  isClickable = true
166
160
  isFocusable = true
167
161
  contentDescription = "Loading application"
168
- alpha = 1f
162
+ alpha = 0f
169
163
  background = GradientDrawable(
170
164
  GradientDrawable.Orientation.LEFT_RIGHT,
171
165
  if (darkTheme) intArrayOf(backgroundStart, Color.rgb(37, 37, 37))
@@ -182,6 +176,7 @@ object LottieSplashCoordinator {
182
176
  overlay.post { updateAnimationLayout(overlay, lottie) }
183
177
  cover = overlay
184
178
  animation = lottie
179
+ if (systemHandoffComplete) beginCoverFadeIn(presentationId!!, overlay, lottie)
185
180
  if (dismissWhenUnadopted) scheduleUnadoptedFallback(presentationId!!)
186
181
  emit()
187
182
 
@@ -228,6 +223,7 @@ object LottieSplashCoordinator {
228
223
  val id = presentationId ?: return
229
224
  val existingCover = cover ?: return
230
225
  val existingAnimation = animation ?: return
226
+ beginCoverFadeIn(id, existingCover, existingAnimation)
231
227
  startPlaybackWhenVisible(id, existingCover, existingAnimation)
232
228
  }
233
229
 
@@ -294,7 +290,7 @@ object LottieSplashCoordinator {
294
290
  }
295
291
 
296
292
  private fun startPlaybackWhenVisible(id: String, overlay: FrameLayout, lottie: LottieAnimationView) {
297
- if (id != presentationId || cover !== overlay || animation !== lottie || !compositionReady || !hostForeground || !systemHandoffComplete || phase != AndroidSplashPhase.PREPARING) return
293
+ if (id != presentationId || cover !== overlay || animation !== lottie || !compositionReady || !coverFadeInComplete || !hostForeground || !systemHandoffComplete || phase != AndroidSplashPhase.PREPARING) return
298
294
  val composition = lottie.composition ?: return
299
295
  phase = AndroidSplashPhase.PLAYING
300
296
  emit()
@@ -323,6 +319,22 @@ object LottieSplashCoordinator {
323
319
  }
324
320
  }
325
321
 
322
+ private fun beginCoverFadeIn(id: String, overlay: FrameLayout, lottie: LottieAnimationView) {
323
+ if (id != presentationId || cover !== overlay || animation !== lottie || coverFadeInStarted || !systemHandoffComplete) return
324
+ coverFadeInStarted = true
325
+ overlay.animate()
326
+ .alpha(1f)
327
+ .setDuration(if (ValueAnimator.areAnimatorsEnabled()) COVER_FADE_IN_MS else 0)
328
+ .setInterpolator(AccelerateDecelerateInterpolator())
329
+ .withEndAction {
330
+ if (id == presentationId && cover === overlay && animation === lottie) {
331
+ coverFadeInComplete = true
332
+ startPlaybackWhenVisible(id, overlay, lottie)
333
+ }
334
+ }
335
+ .start()
336
+ }
337
+
326
338
  private fun updateAnimationLayout(overlay: FrameLayout, lottie: LottieAnimationView) {
327
339
  if (cover !== overlay || animation !== lottie || overlay.width <= 0 || overlay.height <= 0) return
328
340
  val availableWidth = minOf(overlay.width.toFloat(), overlay.height * 0.75f)
@@ -354,7 +366,6 @@ object LottieSplashCoordinator {
354
366
  private fun resetWaitingUi(id: String) {
355
367
  cancelWaitingTasks()
356
368
  progressRemainingMs = PROGRESS_DELAY_MS
357
- recoveryRemainingMs = RECOVERY_DELAY_MS
358
369
  scheduleWaitingUi(id)
359
370
  }
360
371
 
@@ -371,24 +382,19 @@ object LottieSplashCoordinator {
371
382
  waitingClockRunning = true
372
383
  waitingClockStartedAtMs = SystemClock.elapsedRealtime()
373
384
  progressTask = Runnable { showLoadingRail(id) }
374
- recoveryTask = Runnable { showRecovery(id) }
375
385
  if (loadingRail == null) main.postDelayed(progressTask!!, progressRemainingMs)
376
- main.postDelayed(recoveryTask!!, recoveryRemainingMs)
377
386
  }
378
387
 
379
388
  private fun pauseWaitingClock() {
380
389
  if (!waitingClockRunning) return
381
390
  val elapsed = SystemClock.elapsedRealtime() - waitingClockStartedAtMs
382
391
  progressRemainingMs = max(0, progressRemainingMs - elapsed)
383
- recoveryRemainingMs = max(0, recoveryRemainingMs - elapsed)
384
392
  cancelWaitingTasks()
385
393
  }
386
394
 
387
395
  private fun cancelWaitingTasks() {
388
396
  progressTask?.let(main::removeCallbacks)
389
- recoveryTask?.let(main::removeCallbacks)
390
397
  progressTask = null
391
- recoveryTask = null
392
398
  waitingClockRunning = false
393
399
  }
394
400
 
@@ -404,36 +410,10 @@ object LottieSplashCoordinator {
404
410
  }
405
411
  }
406
412
 
407
- private fun showRecovery(id: String) {
408
- recoveryRemainingMs = 0
409
- if (presentationId != id || appReady || phase != AndroidSplashPhase.HOLDING) return
410
- cancelWaitingTasks()
411
- phase = AndroidSplashPhase.RECOVERY
412
- loadingRail?.visibility = View.GONE
413
- val context = cover?.context ?: return
414
- val textColor = if (darkTheme) Color.WHITE else Color.rgb(30, 30, 30)
415
- val message = TextView(context).apply { text = "Still starting…"; textSize = 16f; setTextColor(textColor); gravity = Gravity.CENTER }
416
- val retry = Button(context).apply { text = "Try again"; setOnClickListener { onRecoveryRequested?.invoke() } }
417
- val recoveryGroup = LinearLayout(context).apply {
418
- orientation = LinearLayout.VERTICAL
419
- gravity = Gravity.CENTER_HORIZONTAL
420
- addView(message)
421
- addView(retry)
422
- }
423
- recoveryMessage = message
424
- recoveryRetry = retry
425
- cover?.addView(recoveryGroup, FrameLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT, Gravity.CENTER_HORIZONTAL or Gravity.BOTTOM).apply { bottomMargin = dp(recoveryGroup, 48) })
426
- recoveryGroup.announceForAccessibility("Still starting. Try again is available.")
427
- emit()
428
- }
429
-
430
413
  private fun clearWaitingUi() {
431
414
  cancelWaitingTasks()
432
415
  loadingRail?.let { cover?.removeView(it) }
433
- recoveryMessage?.parent?.let { parent -> (parent as? ViewGroup)?.let { cover?.removeView(it) } }
434
416
  loadingRail = null
435
- recoveryMessage = null
436
- recoveryRetry = null
437
417
  }
438
418
 
439
419
  private fun dismiss(callback: (Throwable?) -> Unit) {
@@ -464,11 +444,11 @@ object LottieSplashCoordinator {
464
444
  animation = null
465
445
  hostActivity = null
466
446
  loadingRail = null
467
- recoveryMessage = null
468
- recoveryRetry = null
469
447
  presentationId = null
470
448
  phase = AndroidSplashPhase.HIDDEN
471
449
  compositionReady = false
450
+ coverFadeInStarted = false
451
+ coverFadeInComplete = false
472
452
  emit()
473
453
  callback(null)
474
454
  }.start()
@@ -489,6 +469,8 @@ object LottieSplashCoordinator {
489
469
  presentationId = null
490
470
  phase = AndroidSplashPhase.HIDDEN
491
471
  compositionReady = false
472
+ coverFadeInStarted = false
473
+ coverFadeInComplete = false
492
474
  pendingHide = null
493
475
  completion = null
494
476
  emit()
@@ -20,7 +20,6 @@ class LottieSplashPlugin : Plugin() {
20
20
  }
21
21
  override fun load() {
22
22
  LottieSplashCoordinator.onStateChange = { notifyListeners("stateChange", stateObject()) }
23
- LottieSplashCoordinator.onRecoveryRequested = { bridge?.webView?.reload() }
24
23
  }
25
24
 
26
25
  @PluginMethod
@@ -89,7 +88,7 @@ class LottieSplashPlugin : Plugin() {
89
88
  webView.postVisualStateCallback(SystemClock.uptimeMillis(), object : WebView.VisualStateCallback() {
90
89
  override fun onComplete(requestId: Long) = finishHandoff()
91
90
  })
92
- // A stalled GPU/compositor must not leave an app-ready screen in recovery.
91
+ // A stalled GPU/compositor must not leave an app-ready screen covered.
93
92
  main.postDelayed(fallback, VISUAL_STATE_TIMEOUT_MS)
94
93
  } catch (_: Throwable) {
95
94
  finishHandoff()
@@ -1,6 +1,6 @@
1
1
  import type { PluginListenerHandle } from '@capacitor/core';
2
2
  export type SplashRenderer = 'ios' | 'android' | 'web';
3
- export type SplashPhase = 'preparing' | 'playing' | 'holding' | 'hiding' | 'hidden' | 'recovery';
3
+ export type SplashPhase = 'preparing' | 'playing' | 'holding' | 'hiding' | 'hidden';
4
4
  export type PlaybackOutcome = 'completed' | 'reduced-motion' | 'static-fallback';
5
5
  export type SplashTheme = 'light' | 'dark';
6
6
  export interface SplashAssets {
@@ -10,7 +10,6 @@ export declare class SplashStateMachine {
10
10
  playing(presentationId: string): StateMachineSnapshot;
11
11
  settlePlayback(presentationId: string, outcome: PlaybackOutcome, reason?: string): StateMachineSnapshot;
12
12
  requestHide(presentationId: string): StateMachineSnapshot;
13
- recovery(presentationId: string): StateMachineSnapshot;
14
13
  hidden(presentationId: string): StateMachineSnapshot;
15
14
  getPlaybackResult(presentationId: string): PlaybackResult | undefined;
16
15
  current(): StateMachineSnapshot;
@@ -45,12 +45,6 @@ export class SplashStateMachine {
45
45
  this.snapshot.phase = 'hiding';
46
46
  return this.current();
47
47
  }
48
- recovery(presentationId) {
49
- this.requireCurrent(presentationId);
50
- if (!this.snapshot.appReady)
51
- this.snapshot.phase = 'recovery';
52
- return this.current();
53
- }
54
48
  hidden(presentationId) {
55
49
  this.requireCurrent(presentationId);
56
50
  this.snapshot = {
package/dist/esm/web.d.ts CHANGED
@@ -6,7 +6,7 @@ export declare class LottieSplashWeb extends WebPlugin implements LottieSplashPl
6
6
  private resolvePlayback?;
7
7
  private overlay?;
8
8
  private progressTimer?;
9
- private recoveryTimer?;
9
+ private playbackTimer?;
10
10
  show(options: SplashShowOptions): Promise<Presentation>;
11
11
  setLaunchTheme(_options: SplashLaunchThemeOptions): Promise<void>;
12
12
  waitForCompletion(options: {
@@ -21,7 +21,6 @@ export declare class LottieSplashWeb extends WebPlugin implements LottieSplashPl
21
21
  private playComposition;
22
22
  private settle;
23
23
  private showLoadingRail;
24
- private showRecovery;
25
24
  private removeOverlay;
26
25
  private ensureCurrent;
27
26
  private emitState;
package/dist/esm/web.js CHANGED
@@ -1,14 +1,14 @@
1
1
  import { WebPlugin } from '@capacitor/core';
2
2
  import { SplashStateMachine } from './state-machine';
3
- const RECOVERY_TIMEOUT_MS = 15_000;
4
3
  const PROGRESS_DELAY_MS = 1_000;
4
+ const COVER_FADE_MS = 500;
5
5
  export class LottieSplashWeb extends WebPlugin {
6
6
  stateMachine = new SplashStateMachine();
7
7
  playbackPromise;
8
8
  resolvePlayback;
9
9
  overlay;
10
10
  progressTimer;
11
- recoveryTimer;
11
+ playbackTimer;
12
12
  async show(options) {
13
13
  const state = this.stateMachine.current();
14
14
  if (state.visible && state.presentationId)
@@ -32,7 +32,7 @@ export class LottieSplashWeb extends WebPlugin {
32
32
  this.emitState();
33
33
  if (!state.playbackSettled)
34
34
  await this.waitForCompletion(options);
35
- this.removeOverlay(options.presentationId);
35
+ await this.removeOverlay(options.presentationId);
36
36
  }
37
37
  async getState() {
38
38
  const { generation: _generation, ...state } = this.stateMachine.current();
@@ -49,10 +49,14 @@ export class LottieSplashWeb extends WebPlugin {
49
49
  overlay.dataset.presentationId = presentationId;
50
50
  Object.assign(overlay.style, {
51
51
  position: 'fixed', inset: '0', zIndex: '2147483647', display: 'grid', placeItems: 'center',
52
- background: this.backgroundFor(this.stateMachine.current().theme), overflow: 'hidden'
52
+ background: this.backgroundFor(this.stateMachine.current().theme), overflow: 'hidden',
53
+ opacity: '0', transition: `opacity ${COVER_FADE_MS}ms ease-in-out`
53
54
  });
54
55
  document.body.append(overlay);
55
56
  this.overlay = overlay;
57
+ // Force the initial transparent style to paint before beginning the fade.
58
+ void overlay.offsetWidth;
59
+ overlay.style.opacity = '1';
56
60
  // A generic package cannot ship HeadCount assets. The host supplies an approved local
57
61
  // composition; absent or failed assets leave the opaque branded background in place.
58
62
  if (!options.assets?.animationData && !options.assets?.animationPath) {
@@ -63,9 +67,13 @@ export class LottieSplashWeb extends WebPlugin {
63
67
  this.settle(presentationId, 'reduced-motion', 'Reduced motion is enabled');
64
68
  return;
65
69
  }
66
- this.stateMachine.playing(presentationId);
67
- this.emitState();
68
- void this.playComposition(options, presentationId);
70
+ this.playbackTimer = window.setTimeout(() => {
71
+ if (this.stateMachine.current().presentationId !== presentationId || this.overlay !== overlay)
72
+ return;
73
+ this.stateMachine.playing(presentationId);
74
+ this.emitState();
75
+ void this.playComposition(options, presentationId);
76
+ }, COVER_FADE_MS);
69
77
  }
70
78
  async playComposition(options, presentationId) {
71
79
  try {
@@ -104,7 +112,6 @@ export class LottieSplashWeb extends WebPlugin {
104
112
  this.emitState();
105
113
  if (!state.appReady) {
106
114
  this.progressTimer = window.setTimeout(() => this.showLoadingRail(presentationId), PROGRESS_DELAY_MS);
107
- this.recoveryTimer = window.setTimeout(() => this.showRecovery(presentationId), RECOVERY_TIMEOUT_MS);
108
115
  }
109
116
  }
110
117
  catch {
@@ -127,32 +134,15 @@ export class LottieSplashWeb extends WebPlugin {
127
134
  rail.append(segment);
128
135
  this.overlay?.append(style, rail);
129
136
  }
130
- showRecovery(presentationId) {
131
- let state;
132
- try {
133
- state = this.stateMachine.recovery(presentationId);
134
- }
135
- catch {
136
- return;
137
- }
138
- if (state.presentationId !== presentationId || state.appReady)
139
- return;
140
- this.overlay?.querySelector('[data-splash-loading-rail]')?.remove();
141
- const recovery = document.createElement('div');
142
- recovery.style.cssText = 'position:absolute;bottom:12%;display:grid;gap:12px;place-items:center;color:inherit;font:500 16px system-ui;';
143
- recovery.textContent = 'Still starting…';
144
- const retry = document.createElement('button');
145
- retry.type = 'button';
146
- retry.textContent = 'Try again';
147
- retry.onclick = () => window.location.reload();
148
- recovery.append(retry);
149
- this.overlay?.append(recovery);
150
- this.emitState();
151
- }
152
- removeOverlay(presentationId) {
137
+ async removeOverlay(presentationId) {
153
138
  window.clearTimeout(this.progressTimer);
154
- window.clearTimeout(this.recoveryTimer);
155
- this.overlay?.remove();
139
+ window.clearTimeout(this.playbackTimer);
140
+ const overlay = this.overlay;
141
+ if (overlay) {
142
+ overlay.style.opacity = '0';
143
+ await new Promise(resolve => window.setTimeout(resolve, COVER_FADE_MS));
144
+ overlay.remove();
145
+ }
156
146
  this.overlay = undefined;
157
147
  this.stateMachine.hidden(presentationId);
158
148
  this.emitState();
@@ -53,12 +53,6 @@ class SplashStateMachine {
53
53
  this.snapshot.phase = 'hiding';
54
54
  return this.current();
55
55
  }
56
- recovery(presentationId) {
57
- this.requireCurrent(presentationId);
58
- if (!this.snapshot.appReady)
59
- this.snapshot.phase = 'recovery';
60
- return this.current();
61
- }
62
56
  hidden(presentationId) {
63
57
  this.requireCurrent(presentationId);
64
58
  this.snapshot = {
@@ -85,15 +79,15 @@ class SplashStateMachine {
85
79
  }
86
80
  }
87
81
 
88
- const RECOVERY_TIMEOUT_MS = 15_000;
89
82
  const PROGRESS_DELAY_MS = 1_000;
83
+ const COVER_FADE_MS = 500;
90
84
  class LottieSplashWeb extends core.WebPlugin {
91
85
  stateMachine = new SplashStateMachine();
92
86
  playbackPromise;
93
87
  resolvePlayback;
94
88
  overlay;
95
89
  progressTimer;
96
- recoveryTimer;
90
+ playbackTimer;
97
91
  async show(options) {
98
92
  const state = this.stateMachine.current();
99
93
  if (state.visible && state.presentationId)
@@ -117,7 +111,7 @@ class LottieSplashWeb extends core.WebPlugin {
117
111
  this.emitState();
118
112
  if (!state.playbackSettled)
119
113
  await this.waitForCompletion(options);
120
- this.removeOverlay(options.presentationId);
114
+ await this.removeOverlay(options.presentationId);
121
115
  }
122
116
  async getState() {
123
117
  const { generation: _generation, ...state } = this.stateMachine.current();
@@ -134,10 +128,14 @@ class LottieSplashWeb extends core.WebPlugin {
134
128
  overlay.dataset.presentationId = presentationId;
135
129
  Object.assign(overlay.style, {
136
130
  position: 'fixed', inset: '0', zIndex: '2147483647', display: 'grid', placeItems: 'center',
137
- background: this.backgroundFor(this.stateMachine.current().theme), overflow: 'hidden'
131
+ background: this.backgroundFor(this.stateMachine.current().theme), overflow: 'hidden',
132
+ opacity: '0', transition: `opacity ${COVER_FADE_MS}ms ease-in-out`
138
133
  });
139
134
  document.body.append(overlay);
140
135
  this.overlay = overlay;
136
+ // Force the initial transparent style to paint before beginning the fade.
137
+ void overlay.offsetWidth;
138
+ overlay.style.opacity = '1';
141
139
  // A generic package cannot ship HeadCount assets. The host supplies an approved local
142
140
  // composition; absent or failed assets leave the opaque branded background in place.
143
141
  if (!options.assets?.animationData && !options.assets?.animationPath) {
@@ -148,9 +146,13 @@ class LottieSplashWeb extends core.WebPlugin {
148
146
  this.settle(presentationId, 'reduced-motion', 'Reduced motion is enabled');
149
147
  return;
150
148
  }
151
- this.stateMachine.playing(presentationId);
152
- this.emitState();
153
- void this.playComposition(options, presentationId);
149
+ this.playbackTimer = window.setTimeout(() => {
150
+ if (this.stateMachine.current().presentationId !== presentationId || this.overlay !== overlay)
151
+ return;
152
+ this.stateMachine.playing(presentationId);
153
+ this.emitState();
154
+ void this.playComposition(options, presentationId);
155
+ }, COVER_FADE_MS);
154
156
  }
155
157
  async playComposition(options, presentationId) {
156
158
  try {
@@ -189,7 +191,6 @@ class LottieSplashWeb extends core.WebPlugin {
189
191
  this.emitState();
190
192
  if (!state.appReady) {
191
193
  this.progressTimer = window.setTimeout(() => this.showLoadingRail(presentationId), PROGRESS_DELAY_MS);
192
- this.recoveryTimer = window.setTimeout(() => this.showRecovery(presentationId), RECOVERY_TIMEOUT_MS);
193
194
  }
194
195
  }
195
196
  catch {
@@ -212,32 +213,15 @@ class LottieSplashWeb extends core.WebPlugin {
212
213
  rail.append(segment);
213
214
  this.overlay?.append(style, rail);
214
215
  }
215
- showRecovery(presentationId) {
216
- let state;
217
- try {
218
- state = this.stateMachine.recovery(presentationId);
219
- }
220
- catch {
221
- return;
222
- }
223
- if (state.presentationId !== presentationId || state.appReady)
224
- return;
225
- this.overlay?.querySelector('[data-splash-loading-rail]')?.remove();
226
- const recovery = document.createElement('div');
227
- recovery.style.cssText = 'position:absolute;bottom:12%;display:grid;gap:12px;place-items:center;color:inherit;font:500 16px system-ui;';
228
- recovery.textContent = 'Still starting…';
229
- const retry = document.createElement('button');
230
- retry.type = 'button';
231
- retry.textContent = 'Try again';
232
- retry.onclick = () => window.location.reload();
233
- recovery.append(retry);
234
- this.overlay?.append(recovery);
235
- this.emitState();
236
- }
237
- removeOverlay(presentationId) {
216
+ async removeOverlay(presentationId) {
238
217
  window.clearTimeout(this.progressTimer);
239
- window.clearTimeout(this.recoveryTimer);
240
- this.overlay?.remove();
218
+ window.clearTimeout(this.playbackTimer);
219
+ const overlay = this.overlay;
220
+ if (overlay) {
221
+ overlay.style.opacity = '0';
222
+ await new Promise(resolve => window.setTimeout(resolve, COVER_FADE_MS));
223
+ overlay.remove();
224
+ }
241
225
  this.overlay = undefined;
242
226
  this.stateMachine.hidden(presentationId);
243
227
  this.emitState();
package/dist/plugin.js CHANGED
@@ -67,12 +67,6 @@ var capacitorLottieSplash = (function (exports, core) {
67
67
  this.snapshot.phase = 'hiding';
68
68
  return this.current();
69
69
  }
70
- recovery(presentationId) {
71
- this.requireCurrent(presentationId);
72
- if (!this.snapshot.appReady)
73
- this.snapshot.phase = 'recovery';
74
- return this.current();
75
- }
76
70
  hidden(presentationId) {
77
71
  this.requireCurrent(presentationId);
78
72
  this.snapshot = {
@@ -99,15 +93,15 @@ var capacitorLottieSplash = (function (exports, core) {
99
93
  }
100
94
  }
101
95
 
102
- const RECOVERY_TIMEOUT_MS = 15_000;
103
96
  const PROGRESS_DELAY_MS = 1_000;
97
+ const COVER_FADE_MS = 500;
104
98
  class LottieSplashWeb extends core.WebPlugin {
105
99
  stateMachine = new SplashStateMachine();
106
100
  playbackPromise;
107
101
  resolvePlayback;
108
102
  overlay;
109
103
  progressTimer;
110
- recoveryTimer;
104
+ playbackTimer;
111
105
  async show(options) {
112
106
  const state = this.stateMachine.current();
113
107
  if (state.visible && state.presentationId)
@@ -131,7 +125,7 @@ var capacitorLottieSplash = (function (exports, core) {
131
125
  this.emitState();
132
126
  if (!state.playbackSettled)
133
127
  await this.waitForCompletion(options);
134
- this.removeOverlay(options.presentationId);
128
+ await this.removeOverlay(options.presentationId);
135
129
  }
136
130
  async getState() {
137
131
  const { generation: _generation, ...state } = this.stateMachine.current();
@@ -148,10 +142,14 @@ var capacitorLottieSplash = (function (exports, core) {
148
142
  overlay.dataset.presentationId = presentationId;
149
143
  Object.assign(overlay.style, {
150
144
  position: 'fixed', inset: '0', zIndex: '2147483647', display: 'grid', placeItems: 'center',
151
- background: this.backgroundFor(this.stateMachine.current().theme), overflow: 'hidden'
145
+ background: this.backgroundFor(this.stateMachine.current().theme), overflow: 'hidden',
146
+ opacity: '0', transition: `opacity ${COVER_FADE_MS}ms ease-in-out`
152
147
  });
153
148
  document.body.append(overlay);
154
149
  this.overlay = overlay;
150
+ // Force the initial transparent style to paint before beginning the fade.
151
+ void overlay.offsetWidth;
152
+ overlay.style.opacity = '1';
155
153
  // A generic package cannot ship HeadCount assets. The host supplies an approved local
156
154
  // composition; absent or failed assets leave the opaque branded background in place.
157
155
  if (!options.assets?.animationData && !options.assets?.animationPath) {
@@ -162,9 +160,13 @@ var capacitorLottieSplash = (function (exports, core) {
162
160
  this.settle(presentationId, 'reduced-motion', 'Reduced motion is enabled');
163
161
  return;
164
162
  }
165
- this.stateMachine.playing(presentationId);
166
- this.emitState();
167
- void this.playComposition(options, presentationId);
163
+ this.playbackTimer = window.setTimeout(() => {
164
+ if (this.stateMachine.current().presentationId !== presentationId || this.overlay !== overlay)
165
+ return;
166
+ this.stateMachine.playing(presentationId);
167
+ this.emitState();
168
+ void this.playComposition(options, presentationId);
169
+ }, COVER_FADE_MS);
168
170
  }
169
171
  async playComposition(options, presentationId) {
170
172
  try {
@@ -203,7 +205,6 @@ var capacitorLottieSplash = (function (exports, core) {
203
205
  this.emitState();
204
206
  if (!state.appReady) {
205
207
  this.progressTimer = window.setTimeout(() => this.showLoadingRail(presentationId), PROGRESS_DELAY_MS);
206
- this.recoveryTimer = window.setTimeout(() => this.showRecovery(presentationId), RECOVERY_TIMEOUT_MS);
207
208
  }
208
209
  }
209
210
  catch {
@@ -226,32 +227,15 @@ var capacitorLottieSplash = (function (exports, core) {
226
227
  rail.append(segment);
227
228
  this.overlay?.append(style, rail);
228
229
  }
229
- showRecovery(presentationId) {
230
- let state;
231
- try {
232
- state = this.stateMachine.recovery(presentationId);
233
- }
234
- catch {
235
- return;
236
- }
237
- if (state.presentationId !== presentationId || state.appReady)
238
- return;
239
- this.overlay?.querySelector('[data-splash-loading-rail]')?.remove();
240
- const recovery = document.createElement('div');
241
- recovery.style.cssText = 'position:absolute;bottom:12%;display:grid;gap:12px;place-items:center;color:inherit;font:500 16px system-ui;';
242
- recovery.textContent = 'Still starting…';
243
- const retry = document.createElement('button');
244
- retry.type = 'button';
245
- retry.textContent = 'Try again';
246
- retry.onclick = () => window.location.reload();
247
- recovery.append(retry);
248
- this.overlay?.append(recovery);
249
- this.emitState();
250
- }
251
- removeOverlay(presentationId) {
230
+ async removeOverlay(presentationId) {
252
231
  window.clearTimeout(this.progressTimer);
253
- window.clearTimeout(this.recoveryTimer);
254
- this.overlay?.remove();
232
+ window.clearTimeout(this.playbackTimer);
233
+ const overlay = this.overlay;
234
+ if (overlay) {
235
+ overlay.style.opacity = '0';
236
+ await new Promise(resolve => window.setTimeout(resolve, COVER_FADE_MS));
237
+ overlay.remove();
238
+ }
255
239
  this.overlay = undefined;
256
240
  this.stateMachine.hidden(presentationId);
257
241
  this.emitState();
@@ -149,7 +149,7 @@ public struct LottieSplashConfiguration {
149
149
  }
150
150
 
151
151
  public enum LottieSplashPhase: String {
152
- case preparing, playing, holding, hiding, hidden, recovery
152
+ case preparing, playing, holding, hiding, hidden
153
153
  }
154
154
 
155
155
  public final class LottieSplashCoordinator {
@@ -159,7 +159,7 @@ public final class LottieSplashCoordinator {
159
159
  /// permanently obscure that bundle during an over-the-air/native rollout mismatch.
160
160
  private let webAdoptionGracePeriod: TimeInterval = 10
161
161
  private let progressDelay: TimeInterval = 1
162
- private let recoveryDelay: TimeInterval = 15
162
+ private let coverFadeDuration: TimeInterval = 0.5
163
163
 
164
164
  public private(set) var presentationId: String?
165
165
  public private(set) var phase: LottieSplashPhase = .hidden
@@ -167,20 +167,18 @@ public final class LottieSplashCoordinator {
167
167
  public private(set) var appReady = false
168
168
  public private(set) var theme: LottieSplashTheme = .dark
169
169
  public var onStateChange: ((LottieSplashCoordinator) -> Void)?
170
- public var onRecoveryRequested: (() -> Void)?
171
170
 
172
171
  private weak var hostController: UIViewController?
173
172
  private var cover: LottieSplashCoverView?
174
173
  private var animationWebView: WKWebView?
175
174
  private var animationMessageBridge: LottieSplashMessageBridge?
176
175
  private var loadingRail: LottieSplashLoadingRail?
177
- private var recoveryWorkItem: DispatchWorkItem?
178
176
  private var progressWorkItem: DispatchWorkItem?
179
177
  private var adoptionWorkItem: DispatchWorkItem?
180
178
  private var adoptedByWeb = false
181
179
  private var webRendererLoaded = false
180
+ private var coverFadeInComplete = false
182
181
  private var progressRemaining = TimeInterval(1)
183
- private var recoveryRemaining = TimeInterval(15)
184
182
  private var waitingClockStartedAt: Date?
185
183
  private var playbackCompletion: ((String, String?) -> Void)?
186
184
  private let queue = DispatchQueue.main
@@ -243,7 +241,7 @@ public final class LottieSplashCoordinator {
243
241
  }
244
242
 
245
243
  public func startPlaybackIfVisible(configuration: LottieSplashConfiguration = .init()) {
246
- guard phase == .preparing, animationWebView != nil else { return }
244
+ guard phase == .preparing, animationWebView != nil, coverFadeInComplete else { return }
247
245
  phase = .playing
248
246
  emitState()
249
247
  guard !UIAccessibility.isReduceMotionEnabled else {
@@ -296,6 +294,7 @@ public final class LottieSplashCoordinator {
296
294
 
297
295
  private func buildCover(in controller: UIViewController, configuration: LottieSplashConfiguration) {
298
296
  let cover = LottieSplashCoverView(theme: configuration.resolvedTheme)
297
+ cover.alpha = 0
299
298
  cover.translatesAutoresizingMaskIntoConstraints = false
300
299
  cover.isAccessibilityElement = true
301
300
  cover.accessibilityLabel = "Loading application"
@@ -334,6 +333,15 @@ public final class LottieSplashCoordinator {
334
333
  self.animationWebView = animation
335
334
  self.animationMessageBridge = messageBridge
336
335
  self.webRendererLoaded = false
336
+ self.coverFadeInComplete = false
337
+ controller.view.layoutIfNeeded()
338
+ UIView.animate(withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : coverFadeDuration, delay: 0, options: [.curveEaseInOut]) {
339
+ cover.alpha = 1
340
+ } completion: { [weak self, weak cover] _ in
341
+ guard let self, let cover, self.cover === cover else { return }
342
+ self.coverFadeInComplete = true
343
+ self.startPlaybackIfVisible()
344
+ }
337
345
  }
338
346
 
339
347
  private func apply(theme: LottieSplashTheme) {
@@ -401,20 +409,13 @@ public final class LottieSplashCoordinator {
401
409
  ])
402
410
  self.loadingRail = rail
403
411
  }
404
- let recovery = DispatchWorkItem { [weak self] in
405
- self?.recoveryRemaining = 0
406
- self?.showRecovery(for: id)
407
- }
408
412
  progressWorkItem = progress
409
- recoveryWorkItem = recovery
410
413
  if self.loadingRail == nil { queue.asyncAfter(deadline: .now() + progressRemaining, execute: progress) }
411
- queue.asyncAfter(deadline: .now() + recoveryRemaining, execute: recovery)
412
414
  }
413
415
 
414
416
  private func resetWaitingIndicators(for id: String) {
415
417
  cancelWaitingTasks()
416
418
  progressRemaining = progressDelay
417
- recoveryRemaining = recoveryDelay
418
419
  scheduleWaitingIndicators(for: id)
419
420
  }
420
421
 
@@ -422,15 +423,12 @@ public final class LottieSplashCoordinator {
422
423
  guard let waitingClockStartedAt else { return }
423
424
  let elapsed = Date().timeIntervalSince(waitingClockStartedAt)
424
425
  progressRemaining = max(0, progressRemaining - elapsed)
425
- recoveryRemaining = max(0, recoveryRemaining - elapsed)
426
426
  cancelWaitingTasks()
427
427
  }
428
428
 
429
429
  private func cancelWaitingTasks() {
430
430
  progressWorkItem?.cancel()
431
- recoveryWorkItem?.cancel()
432
431
  progressWorkItem = nil
433
- recoveryWorkItem = nil
434
432
  waitingClockStartedAt = nil
435
433
  }
436
434
 
@@ -445,33 +443,6 @@ public final class LottieSplashCoordinator {
445
443
  queue.asyncAfter(deadline: .now() + webAdoptionGracePeriod, execute: fallback)
446
444
  }
447
445
 
448
- private func showRecovery(for id: String) {
449
- guard presentationId == id, !appReady else { return }
450
- cancelWaitingTasks()
451
- phase = .recovery
452
- loadingRail?.removeFromSuperview()
453
- loadingRail = nil
454
- let stack = UIStackView()
455
- stack.axis = .vertical
456
- stack.spacing = 12
457
- stack.alignment = .center
458
- stack.translatesAutoresizingMaskIntoConstraints = false
459
- let label = UILabel()
460
- label.text = "Still starting…"
461
- label.accessibilityTraits = .updatesFrequently
462
- let retry = UIButton(type: .system)
463
- retry.setTitle("Try again", for: .normal)
464
- retry.addAction(UIAction { [weak self] _ in self?.onRecoveryRequested?() }, for: .primaryActionTriggered)
465
- stack.addArrangedSubview(label)
466
- stack.addArrangedSubview(retry)
467
- cover?.addSubview(stack)
468
- if let cover {
469
- NSLayoutConstraint.activate([stack.centerXAnchor.constraint(equalTo: cover.centerXAnchor), stack.bottomAnchor.constraint(equalTo: cover.safeAreaLayoutGuide.bottomAnchor, constant: -48)])
470
- }
471
- UIAccessibility.post(notification: .announcement, argument: "Still starting")
472
- emitState()
473
- }
474
-
475
446
  private func dismiss(completion: @escaping (Error?) -> Void) {
476
447
  guard phase != .hidden, let cover, let id = presentationId else { completion(nil); return }
477
448
  phase = .hiding
@@ -480,7 +451,7 @@ public final class LottieSplashCoordinator {
480
451
  adoptionWorkItem = nil
481
452
  loadingRail?.removeFromSuperview()
482
453
  loadingRail = nil
483
- UIView.animate(withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.5, animations: { cover.alpha = 0 }) { [weak self] _ in
454
+ UIView.animate(withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : coverFadeDuration, animations: { cover.alpha = 0 }) { [weak self] _ in
484
455
  cover.removeFromSuperview()
485
456
  self?.cover = nil
486
457
  self?.phase = .hidden
@@ -488,6 +459,7 @@ public final class LottieSplashCoordinator {
488
459
  self?.animationWebView?.configuration.userContentController.removeScriptMessageHandler(forName: "lottieSplash")
489
460
  self?.animationWebView = nil
490
461
  self?.animationMessageBridge = nil
462
+ self?.coverFadeInComplete = false
491
463
  self?.emitState()
492
464
  _ = id
493
465
  completion(nil)
@@ -17,7 +17,6 @@ public class LottieSplashPlugin: CAPPlugin, CAPBridgedPlugin {
17
17
 
18
18
  override public func load() {
19
19
  coordinator.onStateChange = { [weak self] coordinator in self?.notifyListeners("stateChange", data: self?.stateData(coordinator) ?? [:]) }
20
- coordinator.onRecoveryRequested = { [weak self] in self?.bridge?.webView?.reload() }
21
20
  }
22
21
 
23
22
  @objc public func show(_ call: CAPPluginCall) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "capacitor-lottie-splash",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Native-first Lottie splash presentation for Capacitor 8",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",