capacitor-lottie-splash 0.1.0

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.
@@ -0,0 +1,501 @@
1
+ import UIKit
2
+ import WebKit
3
+
4
+ public enum LottieSplashTheme: String {
5
+ case light
6
+ case dark
7
+
8
+ public var gradient: [CGColor] {
9
+ switch self {
10
+ case .light: return [UIColor(red: 240/255, green: 240/255, blue: 240/255, alpha: 1).cgColor, UIColor(red: 245/255, green: 245/255, blue: 245/255, alpha: 1).cgColor]
11
+ case .dark: return [UIColor(red: 30/255, green: 30/255, blue: 30/255, alpha: 1).cgColor, UIColor(red: 37/255, green: 37/255, blue: 37/255, alpha: 1).cgColor]
12
+ }
13
+ }
14
+
15
+ public var backgroundColor: UIColor {
16
+ switch self {
17
+ case .light: return UIColor(red: 240/255, green: 240/255, blue: 240/255, alpha: 1)
18
+ case .dark: return UIColor(red: 30/255, green: 30/255, blue: 30/255, alpha: 1)
19
+ }
20
+ }
21
+ }
22
+
23
+ /** A host-defined Capacitor Preferences value available before the WebView starts. */
24
+ public struct LottieSplashThemePreference {
25
+ public let key: String
26
+ public let darkValue: String
27
+ public let lightValue: String
28
+ public let storagePrefix: String
29
+
30
+ public init(key: String, darkValue: String, lightValue: String, storagePrefix: String = "CapacitorStorage.") {
31
+ self.key = key
32
+ self.darkValue = darkValue
33
+ self.lightValue = lightValue
34
+ self.storagePrefix = storagePrefix
35
+ }
36
+
37
+ public func resolvedTheme() -> LottieSplashTheme? {
38
+ switch UserDefaults.standard.string(forKey: storagePrefix + key) {
39
+ case darkValue: return .dark
40
+ case lightValue: return .light
41
+ default: return nil
42
+ }
43
+ }
44
+ }
45
+
46
+ private final class LottieSplashCoverView: UIView {
47
+ private let gradient = CAGradientLayer()
48
+
49
+ init(theme: LottieSplashTheme) {
50
+ super.init(frame: .zero)
51
+ backgroundColor = theme.backgroundColor
52
+ gradient.colors = theme.gradient
53
+ gradient.startPoint = CGPoint(x: 0, y: 0.5)
54
+ gradient.endPoint = CGPoint(x: 1, y: 0.5)
55
+ layer.insertSublayer(gradient, at: 0)
56
+ }
57
+
58
+ required init?(coder: NSCoder) { nil }
59
+
60
+ override func layoutSubviews() {
61
+ super.layoutSubviews()
62
+ gradient.frame = bounds
63
+ }
64
+
65
+ func apply(theme: LottieSplashTheme) {
66
+ backgroundColor = theme.backgroundColor
67
+ gradient.colors = theme.gradient
68
+ }
69
+ }
70
+
71
+ /** A quiet, indeterminate loading rail that does not obscure the completed mark. */
72
+ private final class LottieSplashLoadingRail: UIView {
73
+ private let track = UIView()
74
+ private let segment = UIView()
75
+ private var animationStarted = false
76
+
77
+ init(theme: LottieSplashTheme) {
78
+ super.init(frame: .zero)
79
+ isAccessibilityElement = true
80
+ accessibilityLabel = "Application is still loading"
81
+ track.layer.cornerRadius = 1.5
82
+ track.clipsToBounds = true
83
+ segment.layer.cornerRadius = 1.5
84
+ addSubview(track)
85
+ track.addSubview(segment)
86
+ apply(theme: theme)
87
+ }
88
+
89
+ required init?(coder: NSCoder) { nil }
90
+
91
+ override func layoutSubviews() {
92
+ super.layoutSubviews()
93
+ track.frame = bounds
94
+ let width = max(28, bounds.width * 0.36)
95
+ segment.frame = CGRect(x: -width, y: 0, width: width, height: bounds.height)
96
+ startAnimatingIfNeeded()
97
+ }
98
+
99
+ func apply(theme: LottieSplashTheme) {
100
+ track.backgroundColor = theme == .dark
101
+ ? UIColor.white.withAlphaComponent(0.18)
102
+ : UIColor.black.withAlphaComponent(0.14)
103
+ segment.backgroundColor = UIColor(red: 1, green: 0.30, blue: 0.30, alpha: 0.9)
104
+ }
105
+
106
+ private func startAnimatingIfNeeded() {
107
+ guard !animationStarted, bounds.width > 0 else { return }
108
+ animationStarted = true
109
+ let sweep = CABasicAnimation(keyPath: "transform.translation.x")
110
+ sweep.fromValue = -segment.bounds.width
111
+ sweep.toValue = bounds.width + segment.bounds.width
112
+ sweep.duration = 1.15
113
+ sweep.repeatCount = .infinity
114
+ sweep.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
115
+ segment.layer.add(sweep, forKey: "lottieSplashLoadingRail")
116
+ }
117
+ }
118
+
119
+ private final class LottieSplashMessageBridge: NSObject, WKScriptMessageHandler, WKNavigationDelegate {
120
+ weak var coordinator: LottieSplashCoordinator?
121
+
122
+ func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
123
+ guard message.name == "lottieSplash", let payload = message.body as? [String: Any] else { return }
124
+ coordinator?.receiveWebAnimationEvent(
125
+ outcome: payload["outcome"] as? String,
126
+ reason: payload["reason"] as? String
127
+ )
128
+ }
129
+
130
+ func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
131
+ coordinator?.webRendererDidLoad()
132
+ }
133
+ }
134
+
135
+ public struct LottieSplashConfiguration {
136
+ public let animationName: String
137
+ public let theme: LottieSplashTheme
138
+ public let themePreference: LottieSplashThemePreference?
139
+
140
+ public var resolvedTheme: LottieSplashTheme {
141
+ themePreference?.resolvedTheme() ?? theme
142
+ }
143
+
144
+ public init(animationName: String = "animatedLogo", theme: LottieSplashTheme = .dark, themePreference: LottieSplashThemePreference? = nil) {
145
+ self.animationName = animationName
146
+ self.theme = theme
147
+ self.themePreference = themePreference
148
+ }
149
+ }
150
+
151
+ public enum LottieSplashPhase: String {
152
+ case preparing, playing, holding, hiding, hidden, recovery
153
+ }
154
+
155
+ public final class LottieSplashCoordinator {
156
+ public static let shared = LottieSplashCoordinator()
157
+
158
+ /// A web bundle from before this plugin existed cannot adopt the native cover. Do not
159
+ /// permanently obscure that bundle during an over-the-air/native rollout mismatch.
160
+ private let webAdoptionGracePeriod: TimeInterval = 10
161
+ private let progressDelay: TimeInterval = 1
162
+ private let recoveryDelay: TimeInterval = 15
163
+
164
+ public private(set) var presentationId: String?
165
+ public private(set) var phase: LottieSplashPhase = .hidden
166
+ public private(set) var playbackSettled = false
167
+ public private(set) var appReady = false
168
+ public private(set) var theme: LottieSplashTheme = .dark
169
+ public var onStateChange: ((LottieSplashCoordinator) -> Void)?
170
+ public var onRecoveryRequested: (() -> Void)?
171
+
172
+ private weak var hostController: UIViewController?
173
+ private var cover: LottieSplashCoverView?
174
+ private var animationWebView: WKWebView?
175
+ private var animationMessageBridge: LottieSplashMessageBridge?
176
+ private var loadingRail: LottieSplashLoadingRail?
177
+ private var recoveryWorkItem: DispatchWorkItem?
178
+ private var progressWorkItem: DispatchWorkItem?
179
+ private var adoptionWorkItem: DispatchWorkItem?
180
+ private var adoptedByWeb = false
181
+ private var webRendererLoaded = false
182
+ private var progressRemaining = TimeInterval(1)
183
+ private var recoveryRemaining = TimeInterval(15)
184
+ private var waitingClockStartedAt: Date?
185
+ private var playbackCompletion: ((String, String?) -> Void)?
186
+ private let queue = DispatchQueue.main
187
+
188
+ private init() {}
189
+
190
+ /// Call from a host CAPBridgeViewController subclass in capacitorDidLoad().
191
+ /// The view exists at this point but the WebView has not loaded yet.
192
+ public func installInitialCover(on controller: UIViewController, configuration: LottieSplashConfiguration = .init()) {
193
+ let install = { [weak self, weak controller] in
194
+ guard let self, let controller else { return }
195
+ guard self.cover == nil else { return }
196
+ self.hostController = controller
197
+ self.presentationId = UUID().uuidString
198
+ self.phase = .preparing
199
+ self.playbackSettled = false
200
+ self.appReady = false
201
+ self.theme = configuration.resolvedTheme
202
+ self.adoptedByWeb = false
203
+ self.buildCover(in: controller, configuration: configuration)
204
+ self.prepareWebRenderer(configuration: configuration)
205
+ self.scheduleUnadoptedFallback(for: self.presentationId)
206
+ self.emitState()
207
+ }
208
+ if Thread.isMainThread { install() } else { queue.async(execute: install) }
209
+ }
210
+
211
+ public func show(configuration: LottieSplashConfiguration = .init()) -> (id: String, adopted: Bool) {
212
+ if let presentationId, cover != nil {
213
+ adoptedByWeb = true
214
+ adoptionWorkItem?.cancel()
215
+ adoptionWorkItem = nil
216
+ return (presentationId, true)
217
+ }
218
+ guard let controller = hostController else {
219
+ let id = UUID().uuidString
220
+ presentationId = id
221
+ phase = .hidden
222
+ return (id, false)
223
+ }
224
+ installInitialCover(on: controller, configuration: configuration)
225
+ // Cold launch playback starts from the host controller's viewDidAppear. A later
226
+ // JavaScript show() has no second view-appearance callback, so it must start its
227
+ // own presentation here. If the renderer is still loading, webRendererDidLoad()
228
+ // completes this hand-off once it is ready.
229
+ startPlaybackIfVisible()
230
+ return (presentationId ?? UUID().uuidString, false)
231
+ }
232
+
233
+ public func waitForCompletion(id: String, completion: @escaping (String, String?) -> Void) {
234
+ guard id == presentationId else { completion("static-fallback", "Stale splash presentation"); return }
235
+ if playbackSettled { completion("completed", nil); return }
236
+ playbackCompletion = completion
237
+ }
238
+
239
+ public func hide(id: String, completion: @escaping (Error?) -> Void) {
240
+ guard id == presentationId else { completion(LottieSplashError.stalePresentation); return }
241
+ appReady = true
242
+ if playbackSettled { dismiss(completion: completion) } else { emitState() }
243
+ }
244
+
245
+ public func startPlaybackIfVisible(configuration: LottieSplashConfiguration = .init()) {
246
+ guard phase == .preparing, animationWebView != nil else { return }
247
+ phase = .playing
248
+ emitState()
249
+ guard !UIAccessibility.isReduceMotionEnabled else {
250
+ settle(outcome: "static-fallback", reason: "Reduced motion")
251
+ return
252
+ }
253
+ beginWebAnimationIfReady()
254
+ }
255
+
256
+ private func prepareWebRenderer(configuration: LottieSplashConfiguration) {
257
+ guard let animationWebView else { return }
258
+ guard let publicDirectory = Bundle.main.resourceURL?.appendingPathComponent("public", isDirectory: true),
259
+ let animationURL = Bundle.main.url(forResource: configuration.animationName, withExtension: "json"),
260
+ let animationData = try? String(contentsOf: animationURL, encoding: .utf8),
261
+ let lottieRuntimeURL = Bundle(for: LottieSplashCoordinator.self).url(forResource: "lottie.min", withExtension: "js"),
262
+ let lottieRuntime = try? String(contentsOf: lottieRuntimeURL, encoding: .utf8) else {
263
+ settle(outcome: "static-fallback", reason: "Bundled animation unavailable")
264
+ return
265
+ }
266
+ let document = Self.webAnimationDocument
267
+ .replacingOccurrences(of: "__LOTTIE_RUNTIME__", with: lottieRuntime)
268
+ .replacingOccurrences(of: "__ANIMATION_DATA__", with: animationData)
269
+ animationWebView.loadHTMLString(document, baseURL: publicDirectory)
270
+ }
271
+
272
+ func webRendererDidLoad() {
273
+ webRendererLoaded = true
274
+ beginWebAnimationIfReady()
275
+ }
276
+
277
+ private func beginWebAnimationIfReady() {
278
+ guard phase == .playing, webRendererLoaded else { return }
279
+ // The approved composition starts its scanner sequence at frame zero. Do not retain
280
+ // the former wordmark's fixed offset here, or the visible playback will skip the
281
+ // scanner and reveal stages even when the renderer was initialized at frame zero.
282
+ animationWebView?.evaluateJavaScript("window.__headcountSplashAnimation?.goToAndPlay(0, true)")
283
+ }
284
+
285
+ public func applicationDidEnterBackground() {
286
+ animationWebView?.evaluateJavaScript("window.__headcountSplashAnimation?.pause()")
287
+ if phase == .holding { pauseWaitingClock() }
288
+ }
289
+
290
+ public func applicationDidBecomeActive() {
291
+ if phase == .playing {
292
+ animationWebView?.evaluateJavaScript("window.__headcountSplashAnimation?.play()")
293
+ }
294
+ else if phase == .holding, let presentationId { scheduleWaitingIndicators(for: presentationId) }
295
+ }
296
+
297
+ private func buildCover(in controller: UIViewController, configuration: LottieSplashConfiguration) {
298
+ let cover = LottieSplashCoverView(theme: configuration.resolvedTheme)
299
+ cover.translatesAutoresizingMaskIntoConstraints = false
300
+ cover.isAccessibilityElement = true
301
+ cover.accessibilityLabel = "Loading application"
302
+ cover.accessibilityViewIsModal = true
303
+ let contentController = WKUserContentController()
304
+ let messageBridge = LottieSplashMessageBridge()
305
+ messageBridge.coordinator = self
306
+ contentController.add(messageBridge, name: "lottieSplash")
307
+ let webConfiguration = WKWebViewConfiguration()
308
+ webConfiguration.userContentController = contentController
309
+ let animation = WKWebView(frame: .zero, configuration: webConfiguration)
310
+ animation.navigationDelegate = messageBridge
311
+ animation.isOpaque = false
312
+ animation.backgroundColor = .clear
313
+ animation.scrollView.backgroundColor = .clear
314
+ animation.scrollView.isScrollEnabled = false
315
+ animation.translatesAutoresizingMaskIntoConstraints = false
316
+ animation.isAccessibilityElement = false
317
+ cover.addSubview(animation)
318
+ controller.view.addSubview(cover)
319
+ let preferredAnimationWidth = animation.widthAnchor.constraint(equalTo: cover.widthAnchor)
320
+ preferredAnimationWidth.priority = .defaultHigh
321
+ NSLayoutConstraint.activate([
322
+ cover.leadingAnchor.constraint(equalTo: controller.view.leadingAnchor), cover.trailingAnchor.constraint(equalTo: controller.view.trailingAnchor),
323
+ cover.topAnchor.constraint(equalTo: controller.view.topAnchor), cover.bottomAnchor.constraint(equalTo: controller.view.bottomAnchor),
324
+ animation.centerXAnchor.constraint(equalTo: cover.centerXAnchor), animation.centerYAnchor.constraint(equalTo: cover.centerYAnchor),
325
+ // The current composition is square. Give it the full portrait width so artwork
326
+ // designed near its 512 px edges is not cropped by the legacy wordmark-sized host.
327
+ // On a short landscape surface, the lower-priority width constraint yields to the
328
+ // height safety limit while the animation remains centered and fully visible.
329
+ preferredAnimationWidth,
330
+ animation.heightAnchor.constraint(equalTo: animation.widthAnchor),
331
+ animation.heightAnchor.constraint(lessThanOrEqualTo: cover.heightAnchor, multiplier: 0.75)
332
+ ])
333
+ self.cover = cover
334
+ self.animationWebView = animation
335
+ self.animationMessageBridge = messageBridge
336
+ self.webRendererLoaded = false
337
+ }
338
+
339
+ private func apply(theme: LottieSplashTheme) {
340
+ self.theme = theme
341
+ cover?.apply(theme: theme)
342
+ }
343
+
344
+ func receiveWebAnimationEvent(outcome: String?, reason: String?) {
345
+ guard phase == .playing else { return }
346
+ settle(outcome: outcome == "completed" ? "completed" : "static-fallback", reason: reason)
347
+ }
348
+
349
+ private static let webAnimationDocument = """
350
+ <!doctype html>
351
+ <html><head><meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
352
+ <style>html,body{width:100%;height:100%;margin:0;background:transparent;overflow:hidden}body{display:flex;align-items:center;justify-content:center}#animation{width:35%;height:35%;overflow:hidden}canvas{width:100%!important;height:100%!important;display:block}</style>
353
+ </head><body><div id="animation"></div><script>__LOTTIE_RUNTIME__</script><script>
354
+ (() => {
355
+ const report = (outcome, reason) => window.webkit.messageHandlers.lottieSplash.postMessage({ outcome, reason });
356
+ window.addEventListener('error', event => report('static-fallback', event.message));
357
+ try {
358
+ const animationData = __ANIMATION_DATA__;
359
+ const animation = window.lottie.loadAnimation({
360
+ container: document.getElementById('animation'), animationData, renderer: 'canvas', loop: false, autoplay: false,
361
+ rendererSettings: { clearCanvas: true, progressiveLoad: false }
362
+ });
363
+ window.__headcountSplashAnimation = animation;
364
+ // The approved host composition begins its scanner sequence at frame zero. The
365
+ // former HeadCount wordmark was blank until frame 70, but retaining that legacy
366
+ // offset skips the new animation's opening motion.
367
+ animation.goToAndStop(0, true);
368
+ animation.addEventListener('complete', () => report('completed'));
369
+ animation.addEventListener('data_failed', () => report('static-fallback', 'Unable to parse the bundled animation'));
370
+ } catch (error) { report('static-fallback', error.message); }
371
+ })();
372
+ </script></body></html>
373
+ """
374
+
375
+ private func settle(outcome: String, reason: String?) {
376
+ guard let id = presentationId, !playbackSettled else { return }
377
+ playbackSettled = true
378
+ phase = appReady ? .hiding : .holding
379
+ playbackCompletion?(outcome, reason)
380
+ playbackCompletion = nil
381
+ if appReady { dismiss(completion: { _ in }) } else { resetWaitingIndicators(for: id) }
382
+ emitState()
383
+ }
384
+
385
+ private func scheduleWaitingIndicators(for id: String) {
386
+ guard waitingClockStartedAt == nil, presentationId == id, !appReady, phase == .holding else { return }
387
+ waitingClockStartedAt = Date()
388
+ let progress = DispatchWorkItem { [weak self] in
389
+ guard let self, self.presentationId == id, !self.appReady, self.phase == .holding else { return }
390
+ self.progressRemaining = 0
391
+ let rail = LottieSplashLoadingRail(theme: self.theme)
392
+ rail.translatesAutoresizingMaskIntoConstraints = false
393
+ self.cover?.addSubview(rail)
394
+ NSLayoutConstraint.activate([
395
+ rail.centerXAnchor.constraint(equalTo: self.cover!.centerXAnchor),
396
+ // The Lottie composition occupies the center. Keep the loading cue just
397
+ // below its finished tally rather than placing it on top of the artwork.
398
+ rail.centerYAnchor.constraint(equalTo: self.cover!.centerYAnchor, constant: 100),
399
+ rail.widthAnchor.constraint(equalToConstant: 104),
400
+ rail.heightAnchor.constraint(equalToConstant: 3)
401
+ ])
402
+ self.loadingRail = rail
403
+ }
404
+ let recovery = DispatchWorkItem { [weak self] in
405
+ self?.recoveryRemaining = 0
406
+ self?.showRecovery(for: id)
407
+ }
408
+ progressWorkItem = progress
409
+ recoveryWorkItem = recovery
410
+ if self.loadingRail == nil { queue.asyncAfter(deadline: .now() + progressRemaining, execute: progress) }
411
+ queue.asyncAfter(deadline: .now() + recoveryRemaining, execute: recovery)
412
+ }
413
+
414
+ private func resetWaitingIndicators(for id: String) {
415
+ cancelWaitingTasks()
416
+ progressRemaining = progressDelay
417
+ recoveryRemaining = recoveryDelay
418
+ scheduleWaitingIndicators(for: id)
419
+ }
420
+
421
+ private func pauseWaitingClock() {
422
+ guard let waitingClockStartedAt else { return }
423
+ let elapsed = Date().timeIntervalSince(waitingClockStartedAt)
424
+ progressRemaining = max(0, progressRemaining - elapsed)
425
+ recoveryRemaining = max(0, recoveryRemaining - elapsed)
426
+ cancelWaitingTasks()
427
+ }
428
+
429
+ private func cancelWaitingTasks() {
430
+ progressWorkItem?.cancel()
431
+ recoveryWorkItem?.cancel()
432
+ progressWorkItem = nil
433
+ recoveryWorkItem = nil
434
+ waitingClockStartedAt = nil
435
+ }
436
+
437
+ private func scheduleUnadoptedFallback(for id: String?) {
438
+ adoptionWorkItem?.cancel()
439
+ guard let id else { return }
440
+ let fallback = DispatchWorkItem { [weak self] in
441
+ guard let self, self.presentationId == id, !self.adoptedByWeb else { return }
442
+ self.dismiss(completion: { _ in })
443
+ }
444
+ adoptionWorkItem = fallback
445
+ queue.asyncAfter(deadline: .now() + webAdoptionGracePeriod, execute: fallback)
446
+ }
447
+
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
+ private func dismiss(completion: @escaping (Error?) -> Void) {
476
+ guard phase != .hidden, let cover, let id = presentationId else { completion(nil); return }
477
+ phase = .hiding
478
+ cancelWaitingTasks()
479
+ adoptionWorkItem?.cancel()
480
+ adoptionWorkItem = nil
481
+ loadingRail?.removeFromSuperview()
482
+ loadingRail = nil
483
+ UIView.animate(withDuration: UIAccessibility.isReduceMotionEnabled ? 0 : 0.5, animations: { cover.alpha = 0 }) { [weak self] _ in
484
+ cover.removeFromSuperview()
485
+ self?.cover = nil
486
+ self?.phase = .hidden
487
+ self?.presentationId = nil
488
+ self?.animationWebView?.configuration.userContentController.removeScriptMessageHandler(forName: "lottieSplash")
489
+ self?.animationWebView = nil
490
+ self?.animationMessageBridge = nil
491
+ self?.emitState()
492
+ _ = id
493
+ completion(nil)
494
+ }
495
+ emitState()
496
+ }
497
+
498
+ private func emitState() { onStateChange?(self) }
499
+ }
500
+
501
+ public enum LottieSplashError: Error { case stalePresentation }
@@ -0,0 +1,71 @@
1
+ import Capacitor
2
+
3
+ @objc(LottieSplashPlugin)
4
+ public class LottieSplashPlugin: CAPPlugin, CAPBridgedPlugin {
5
+ public let identifier = "LottieSplashPlugin"
6
+ public let jsName = "LottieSplash"
7
+ public let pluginMethods: [CAPPluginMethod] = [
8
+ CAPPluginMethod(name: "show", returnType: CAPPluginReturnPromise),
9
+ CAPPluginMethod(name: "setLaunchTheme", returnType: CAPPluginReturnPromise),
10
+ CAPPluginMethod(name: "waitForCompletion", returnType: CAPPluginReturnPromise),
11
+ CAPPluginMethod(name: "hide", returnType: CAPPluginReturnPromise),
12
+ CAPPluginMethod(name: "getState", returnType: CAPPluginReturnPromise),
13
+ CAPPluginMethod(name: "getCapabilities", returnType: CAPPluginReturnPromise)
14
+ ]
15
+
16
+ private let coordinator = LottieSplashCoordinator.shared
17
+
18
+ override public func load() {
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
+ }
22
+
23
+ @objc public func show(_ call: CAPPluginCall) {
24
+ DispatchQueue.main.async {
25
+ let result = self.coordinator.show(configuration: self.configuration(from: call))
26
+ call.resolve(["presentationId": result.id, "renderer": "ios", "adopted": result.adopted])
27
+ }
28
+ }
29
+
30
+ @objc public func setLaunchTheme(_ call: CAPPluginCall) {
31
+ // iOS launch storyboard appearance is selected by UIKit; there is no persistent
32
+ // per-application night-mode override equivalent to Android's UiModeManager.
33
+ call.resolve()
34
+ }
35
+
36
+ @objc public func waitForCompletion(_ call: CAPPluginCall) {
37
+ guard let id = call.getString("presentationId") else { call.reject("presentationId is required"); return }
38
+ coordinator.waitForCompletion(id: id) { outcome, reason in
39
+ var result: PluginCallResultData = ["presentationId": id, "outcome": outcome]
40
+ if let reason { result["reason"] = reason }
41
+ call.resolve(result)
42
+ }
43
+ }
44
+
45
+ @objc public func hide(_ call: CAPPluginCall) {
46
+ guard let id = call.getString("presentationId") else { call.reject("presentationId is required"); return }
47
+ DispatchQueue.main.async { self.coordinator.hide(id: id) { error in error.map { call.reject($0.localizedDescription) } ?? call.resolve() } }
48
+ }
49
+
50
+ @objc public func getState(_ call: CAPPluginCall) { call.resolve(stateData(coordinator)) }
51
+
52
+ @objc public func getCapabilities(_ call: CAPPluginCall) {
53
+ call.resolve(["apiVersion": 1, "pluginVersion": "0.1.0", "coldStartInstalled": coordinator.presentationId != nil])
54
+ }
55
+
56
+ private func configuration(from call: CAPPluginCall) -> LottieSplashConfiguration {
57
+ let theme: LottieSplashTheme
58
+ if let darkMode = call.getBool("darkMode") {
59
+ theme = darkMode ? .dark : .light
60
+ } else {
61
+ // Accept already-delivered web bundles while the host upgrades to the explicit
62
+ // darkMode contract.
63
+ theme = call.getString("theme").flatMap(LottieSplashTheme.init(rawValue:)) ?? .dark
64
+ }
65
+ return LottieSplashConfiguration(theme: theme)
66
+ }
67
+
68
+ private func stateData(_ coordinator: LottieSplashCoordinator) -> PluginCallResultData {
69
+ ["presentationId": coordinator.presentationId ?? NSNull(), "phase": coordinator.phase.rawValue, "visible": coordinator.phase != .hidden, "playbackSettled": coordinator.playbackSettled, "appReady": coordinator.appReady, "theme": coordinator.theme.rawValue]
70
+ }
71
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "capacitor-lottie-splash",
3
+ "version": "0.1.0",
4
+ "description": "Native-first Lottie splash presentation for Capacitor 8",
5
+ "main": "dist/plugin.cjs.js",
6
+ "module": "dist/esm/index.js",
7
+ "types": "dist/esm/index.d.ts",
8
+ "unpkg": "dist/plugin.js",
9
+ "files": [
10
+ "android/src/main/",
11
+ "android/build.gradle",
12
+ "android/settings.gradle",
13
+ "dist/*.js",
14
+ "dist/esm/*.js",
15
+ "dist/esm/*.d.ts",
16
+ "ios/Sources/",
17
+ "Package.swift",
18
+ "CapacitorLottieSplash.podspec"
19
+ ],
20
+ "author": "HeadCount CMMS",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/Head-Count-CMMS/CapacitorLottieSplash.git"
25
+ },
26
+ "bugs": {
27
+ "url": "https://github.com/Head-Count-CMMS/CapacitorLottieSplash/issues"
28
+ },
29
+ "keywords": [
30
+ "capacitor",
31
+ "plugin",
32
+ "lottie",
33
+ "splash-screen",
34
+ "native"
35
+ ],
36
+ "scripts": {
37
+ "build": "rimraf dist && tsc && rollup -c rollup.config.mjs",
38
+ "test": "vitest run",
39
+ "test:watch": "vitest",
40
+ "docgen": "docgen --api LottieSplashPlugin --output-readme README.md --output-json dist/docs.json",
41
+ "verify:web": "npm run build && npm test",
42
+ "verify:android": "gradle -p android compileDebugKotlin --no-daemon",
43
+ "verify:ios": "xcodebuild -scheme CapacitorLottieSplash -destination generic/platform=iOS build",
44
+ "verify": "npm run verify:web && npm run verify:android && npm run verify:ios",
45
+ "prepublishOnly": "npm run build && npm test"
46
+ },
47
+ "peerDependencies": {
48
+ "@capacitor/core": "^8.0.0"
49
+ },
50
+ "dependencies": {
51
+ "lottie-web": "5.13.0"
52
+ },
53
+ "devDependencies": {
54
+ "@capacitor/android": "8.2.0",
55
+ "@capacitor/core": "8.2.0",
56
+ "@capacitor/docgen": "0.3.1",
57
+ "@capacitor/ios": "8.2.0",
58
+ "@rollup/plugin-commonjs": "28.0.8",
59
+ "@rollup/plugin-node-resolve": "16.0.3",
60
+ "@rollup/plugin-typescript": "12.1.4",
61
+ "rimraf": "6.1.2",
62
+ "rollup": "4.53.2",
63
+ "typescript": "5.8.3",
64
+ "vitest": "3.2.4"
65
+ },
66
+ "capacitor": {
67
+ "pluginId": "capacitor-lottie-splash",
68
+ "name": "LottieSplash",
69
+ "ios": {
70
+ "src": "ios",
71
+ "pluginClass": "LottieSplashPlugin"
72
+ },
73
+ "android": {
74
+ "src": "android",
75
+ "pluginClass": "LottieSplashPlugin"
76
+ }
77
+ }
78
+ }