@capgo/capacitor-native-audio 8.4.3

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.
Files changed (46) hide show
  1. package/CapgoCapacitorNativeAudio.podspec +16 -0
  2. package/LICENSE +373 -0
  3. package/Package.swift +31 -0
  4. package/README.md +1229 -0
  5. package/android/build.gradle +89 -0
  6. package/android/src/main/AndroidManifest.xml +3 -0
  7. package/android/src/main/java/ee/forgr/audio/AudioAsset.java +611 -0
  8. package/android/src/main/java/ee/forgr/audio/AudioCompletionListener.java +5 -0
  9. package/android/src/main/java/ee/forgr/audio/AudioDispatcher.java +208 -0
  10. package/android/src/main/java/ee/forgr/audio/Constant.java +36 -0
  11. package/android/src/main/java/ee/forgr/audio/HlsAvailabilityChecker.java +84 -0
  12. package/android/src/main/java/ee/forgr/audio/Logger.java +55 -0
  13. package/android/src/main/java/ee/forgr/audio/NativeAudio.java +2022 -0
  14. package/android/src/main/java/ee/forgr/audio/RemoteAudioAsset.java +886 -0
  15. package/android/src/main/java/ee/forgr/audio/StreamAudioAsset.java +708 -0
  16. package/android/src/main/res/values/colors.xml +3 -0
  17. package/android/src/main/res/values/strings.xml +3 -0
  18. package/android/src/main/res/values/styles.xml +3 -0
  19. package/dist/docs.json +1470 -0
  20. package/dist/esm/audio-asset.d.ts +4 -0
  21. package/dist/esm/audio-asset.js +6 -0
  22. package/dist/esm/audio-asset.js.map +1 -0
  23. package/dist/esm/definitions.d.ts +597 -0
  24. package/dist/esm/definitions.js +2 -0
  25. package/dist/esm/definitions.js.map +1 -0
  26. package/dist/esm/index.d.ts +4 -0
  27. package/dist/esm/index.js +7 -0
  28. package/dist/esm/index.js.map +1 -0
  29. package/dist/esm/web.d.ts +82 -0
  30. package/dist/esm/web.js +553 -0
  31. package/dist/esm/web.js.map +1 -0
  32. package/dist/plugin.cjs.js +571 -0
  33. package/dist/plugin.cjs.js.map +1 -0
  34. package/dist/plugin.js +574 -0
  35. package/dist/plugin.js.map +1 -0
  36. package/ios/Sources/NativeAudioPlugin/AudioAsset+Fade.swift +157 -0
  37. package/ios/Sources/NativeAudioPlugin/AudioAsset.swift +403 -0
  38. package/ios/Sources/NativeAudioPlugin/Constant.swift +52 -0
  39. package/ios/Sources/NativeAudioPlugin/Logger.swift +43 -0
  40. package/ios/Sources/NativeAudioPlugin/Plugin.swift +1786 -0
  41. package/ios/Sources/NativeAudioPlugin/RemoteAudioAsset+Fade.swift +152 -0
  42. package/ios/Sources/NativeAudioPlugin/RemoteAudioAsset.swift +405 -0
  43. package/ios/Tests/NativeAudioPluginTests/PluginTests.swift +648 -0
  44. package/ios/Tests/README.md +39 -0
  45. package/package.json +101 -0
  46. package/scripts/configure-dependencies.js +251 -0
@@ -0,0 +1,1786 @@
1
+ @preconcurrency import AVFoundation
2
+ import Capacitor
3
+ import CoreAudio
4
+ import Foundation
5
+ @preconcurrency import MediaPlayer
6
+
7
+ enum MyError: Error {
8
+ case runtimeError(String)
9
+ }
10
+
11
+ private enum PlaybackStateValue: String {
12
+ case playing
13
+ case paused
14
+ case stopped
15
+ }
16
+
17
+ // swiftlint:disable file_length
18
+ @objc(NativeAudio)
19
+ // swiftlint:disable:next type_body_length
20
+ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
21
+ private let pluginVersion: String = "8.4.3"
22
+ public let identifier = "NativeAudio"
23
+ public let jsName = "NativeAudio"
24
+ public let pluginMethods: [CAPPluginMethod] = [
25
+ CAPPluginMethod(name: "setDebugMode", returnType: CAPPluginReturnPromise),
26
+ CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
27
+ CAPPluginMethod(name: "preload", returnType: CAPPluginReturnPromise),
28
+ CAPPluginMethod(name: "playOnce", returnType: CAPPluginReturnPromise),
29
+ CAPPluginMethod(name: "isPreloaded", returnType: CAPPluginReturnPromise),
30
+ CAPPluginMethod(name: "play", returnType: CAPPluginReturnPromise),
31
+ CAPPluginMethod(name: "pause", returnType: CAPPluginReturnPromise),
32
+ CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
33
+ CAPPluginMethod(name: "loop", returnType: CAPPluginReturnPromise),
34
+ CAPPluginMethod(name: "unload", returnType: CAPPluginReturnPromise),
35
+ CAPPluginMethod(name: "setVolume", returnType: CAPPluginReturnPromise),
36
+ CAPPluginMethod(name: "setRate", returnType: CAPPluginReturnPromise),
37
+ CAPPluginMethod(name: "isPlaying", returnType: CAPPluginReturnPromise),
38
+ CAPPluginMethod(name: "getCurrentTime", returnType: CAPPluginReturnPromise),
39
+ CAPPluginMethod(name: "getDuration", returnType: CAPPluginReturnPromise),
40
+ CAPPluginMethod(name: "resume", returnType: CAPPluginReturnPromise),
41
+ CAPPluginMethod(name: "setCurrentTime", returnType: CAPPluginReturnPromise),
42
+ CAPPluginMethod(name: "clearCache", returnType: CAPPluginReturnPromise),
43
+ CAPPluginMethod(name: "getPluginVersion", returnType: CAPPluginReturnPromise),
44
+ CAPPluginMethod(name: "deinitPlugin", returnType: CAPPluginReturnPromise)
45
+ ]
46
+ private var logger = Logger(logTag: "NativeAudio")
47
+ internal let audioQueue = DispatchQueue(label: "ee.forgr.audio.queue", qos: .userInitiated, attributes: .concurrent)
48
+ /// A dictionary that stores audio asset objects by their asset IDs.
49
+ ///
50
+ /// - Important: Must only be accessed within `audioQueue.sync` blocks.
51
+ internal var audioList: [String: Any] = [:] {
52
+ didSet {
53
+ // Ensure audioList modifications happen on audioQueue
54
+ assert(DispatchQueue.getSpecific(key: queueKey) != nil)
55
+ }
56
+ }
57
+ private let queueKey = DispatchSpecificKey<Bool>()
58
+ /// Set while executing a block on the audio queue so getAudioAsset/endSession can avoid reentrant sync (deadlock).
59
+ private let audioQueueContextKey = DispatchSpecificKey<Bool?>()
60
+ var session = AVAudioSession.sharedInstance()
61
+
62
+ // Track if audio session has been initialized
63
+ private var audioSessionInitialized = false
64
+ // Store the original audio category to restore on deinit
65
+ private var originalAudioCategory: AVAudioSession.Category?
66
+ private var originalAudioOptions: AVAudioSession.CategoryOptions?
67
+
68
+ // Add observer for audio session interruptions
69
+ private var interruptionObserver: Any?
70
+
71
+ // Notification center support
72
+ private var showNotification = false
73
+ /// A mapping from asset IDs to their associated notification metadata for media playback.
74
+ ///
75
+ /// - Important: Must only be accessed within `audioQueue.sync` blocks.
76
+ internal var notificationMetadataMap: [String: [String: String]] = [:]
77
+ private var currentlyPlayingAssetId: String?
78
+
79
+ /// Stores the asset IDs for playOnce operations to enable automatic cleanup after playback.
80
+ ///
81
+ /// - Important: Must only be accessed within `audioQueue.sync` blocks.
82
+ internal var playOnceAssets: Set<String> = []
83
+
84
+ private var pendingPlayTasks: [String: DispatchWorkItem] = [:]
85
+ private var audioAssetData: [String: [String: Any]] = [:]
86
+ var isRunningTests = false
87
+
88
+ /// Initialize plugin state and audio-related handlers, and register background behavior for session management.
89
+ ///
90
+ /// Performs initial plugin setup after the plugin is loaded.
91
+ ///
92
+ /// Registers the plugin's audio queue, initializes default flags, defers full audio session activation until first use, and configures interruption handling and remote command controls. Also adds a background observer that will deactivate the audio session when the app enters background if no plugin-managed audio is playing and the system reports no other active audio.
93
+ @objc override public func load() {
94
+ super.load()
95
+ audioQueue.setSpecific(key: queueKey, value: true)
96
+
97
+ // Don't setup audio session on load - defer until first use
98
+ // setupAudioSession()
99
+ setupInterruptionHandling()
100
+ setupRemoteCommandCenter()
101
+
102
+ NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: .main) { [weak self] _ in
103
+ guard let strongSelf = self else { return }
104
+
105
+ // When entering background, automatically deactivate audio session if not playing any audio
106
+ strongSelf.audioQueue.sync {
107
+ // Check if there are any playing assets
108
+ let hasPlayingAssets = strongSelf.audioList.values.contains { asset in
109
+ if let audioAsset = asset as? AudioAsset {
110
+ return audioAsset.isPlaying()
111
+ }
112
+ return false
113
+ }
114
+
115
+ // Only deactivate if we have no playing assets AND no other audio is active
116
+ // This prevents interfering with VoIP calls or other audio sessions
117
+ if !hasPlayingAssets && !strongSelf.session.isOtherAudioPlaying && strongSelf.session.secondaryAudioShouldBeSilencedHint == false {
118
+ strongSelf.endSession()
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ // Clean up on deinit
125
+ deinit {
126
+ if let observer = interruptionObserver {
127
+ NotificationCenter.default.removeObserver(observer)
128
+ }
129
+ }
130
+
131
+ private func setupAudioSession() {
132
+ // Save the original audio session category before making changes
133
+ if !audioSessionInitialized {
134
+ originalAudioCategory = session.category
135
+ originalAudioOptions = session.categoryOptions
136
+ audioSessionInitialized = true
137
+ }
138
+
139
+ do {
140
+ // Only set the category without immediately activating/deactivating
141
+ try self.session.setCategory(AVAudioSession.Category.playback, options: .mixWithOthers)
142
+ // Don't activate/deactivate in setup - we'll do this explicitly when needed
143
+ } catch {
144
+ print("Failed to setup audio session: \(error)")
145
+ }
146
+ }
147
+
148
+ private func setupInterruptionHandling() {
149
+ // Handle audio session interruptions
150
+ interruptionObserver = NotificationCenter.default.addObserver(
151
+ forName: AVAudioSession.interruptionNotification,
152
+ object: nil,
153
+ queue: nil) { [weak self] notification in
154
+ guard let strongSelf = self else { return }
155
+
156
+ guard let userInfo = notification.userInfo,
157
+ let typeInt = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
158
+ let type = AVAudioSession.InterruptionType(rawValue: typeInt) else {
159
+ return
160
+ }
161
+
162
+ switch type {
163
+ case .began:
164
+ // Audio was interrupted - we could pause all playing audio here
165
+ strongSelf.notifyListeners("interrupt", data: ["interrupted": true])
166
+ case .ended:
167
+ // Interruption ended - we could resume audio here if appropriate
168
+ if let optionsInt = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt,
169
+ AVAudioSession.InterruptionOptions(rawValue: optionsInt).contains(.shouldResume) {
170
+ // Resume playback if appropriate (user wants to resume)
171
+ strongSelf.notifyListeners("interrupt", data: ["interrupted": false, "shouldResume": true])
172
+ } else {
173
+ strongSelf.notifyListeners("interrupt", data: ["interrupted": false, "shouldResume": false])
174
+ }
175
+ @unknown default:
176
+ break
177
+ }
178
+ }
179
+ }
180
+
181
+ private func resolvePlaybackState(assetId: String, audioAsset: AudioAsset?) -> PlaybackStateValue {
182
+ if let audioAsset, audioAsset.isPlaying() {
183
+ return .playing
184
+ }
185
+ if let data = audioAssetData[assetId],
186
+ data["timeBeforePause"] != nil || data["volumeBeforePause"] != nil {
187
+ return .paused
188
+ }
189
+ if currentlyPlayingAssetId == assetId {
190
+ return .paused
191
+ }
192
+ return .stopped
193
+ }
194
+
195
+ private func notifyPlaybackState(assetId: String, reason: String, state: PlaybackStateValue? = nil, audioAsset: AudioAsset? = nil) {
196
+ let emit = {
197
+ let asset = audioAsset ?? self.audioList[assetId] as? AudioAsset
198
+ let resolvedState = state ?? self.resolvePlaybackState(assetId: assetId, audioAsset: asset)
199
+ var data: [String: Any] = [
200
+ "assetId": assetId,
201
+ "state": resolvedState.rawValue,
202
+ "reason": reason,
203
+ "isPlaying": resolvedState == .playing
204
+ ]
205
+ if let asset {
206
+ data["currentTime"] = asset.getCurrentTime()
207
+ let duration = asset.getDuration()
208
+ if duration.isFinite {
209
+ data["duration"] = duration
210
+ }
211
+ }
212
+ self.notifyListeners("playbackState", data: data)
213
+ }
214
+
215
+ if DispatchQueue.getSpecific(key: queueKey) != nil || DispatchQueue.getSpecific(key: audioQueueContextKey) == true {
216
+ emit()
217
+ } else {
218
+ audioQueue.async(execute: emit)
219
+ }
220
+ }
221
+
222
+ internal func handlePlaybackCompletion(assetId: String, audioAsset: AudioAsset? = nil) {
223
+ if currentlyPlayingAssetId == assetId {
224
+ currentlyPlayingAssetId = nil
225
+ clearNowPlayingInfo()
226
+ }
227
+ notifyPlaybackState(assetId: assetId, reason: "complete", state: .stopped, audioAsset: audioAsset)
228
+ }
229
+
230
+ /// Must be called on `audioQueue`. If `timeBeforePause` is stored, clears it and seeks (async for remote) before running `resume` + Now Playing refresh.
231
+ /// Mirrors `resume(_:)` (non–fade-in path): restores `volumeBeforePause` via `setVolume`, clears that key from `audioAssetData`, then `resume()`.
232
+ private func resumeAssetFromStoredPausePositionIfNeeded(assetId: String, asset: AudioAsset, reason: String = "resume") {
233
+ var restoredTime: TimeInterval?
234
+ if var data = audioAssetData[assetId],
235
+ let time = data["timeBeforePause"] as? TimeInterval {
236
+ restoredTime = time
237
+ data.removeValue(forKey: "timeBeforePause")
238
+ audioAssetData[assetId] = data
239
+ }
240
+
241
+ var restoredVolume: Float?
242
+ if let data = audioAssetData[assetId], let volume = data["volumeBeforePause"] as? Float {
243
+ restoredVolume = volume
244
+ }
245
+
246
+ let resumeAndRefreshNowPlaying: () -> Void = { [weak self] in
247
+ guard let self else { return }
248
+ if let volume = restoredVolume {
249
+ asset.setVolume(volume: NSNumber(value: volume), fadeDuration: 0)
250
+ }
251
+ if var data = self.audioAssetData[assetId] {
252
+ data.removeValue(forKey: "volumeBeforePause")
253
+ self.audioAssetData[assetId] = data
254
+ }
255
+ asset.resume()
256
+ if self.showNotification {
257
+ self.currentlyPlayingAssetId = assetId
258
+ self.updateNowPlayingInfo(audioId: assetId, audioAsset: asset)
259
+ }
260
+ self.notifyPlaybackState(assetId: assetId, reason: reason, state: .playing, audioAsset: asset)
261
+ }
262
+ if let resumeTime = restoredTime {
263
+ asset.setCurrentTime(time: resumeTime) { [weak self] in
264
+ guard let self else { return }
265
+ audioQueue.async(flags: .barrier, execute: resumeAndRefreshNowPlaying)
266
+ }
267
+ } else {
268
+ resumeAndRefreshNowPlaying()
269
+ }
270
+ }
271
+
272
+ // swiftlint:disable function_body_length
273
+ private func setupRemoteCommandCenter() {
274
+ let commandCenter = MPRemoteCommandCenter.shared()
275
+
276
+ // Play command
277
+ commandCenter.playCommand.addTarget { [weak self] _ in
278
+ guard let self = self, let assetId = self.currentlyPlayingAssetId else {
279
+ return .noSuchContent
280
+ }
281
+
282
+ self.audioQueue.sync {
283
+ guard let asset = self.audioList[assetId] as? AudioAsset else {
284
+ return
285
+ }
286
+
287
+ if !asset.isPlaying() {
288
+ self.resumeAssetFromStoredPausePositionIfNeeded(assetId: assetId, asset: asset, reason: "remotePlay")
289
+ }
290
+ }
291
+ return .success
292
+ }
293
+
294
+ // Pause command
295
+ commandCenter.pauseCommand.addTarget { [weak self] _ in
296
+ guard let self = self, let assetId = self.currentlyPlayingAssetId else {
297
+ return .noSuchContent
298
+ }
299
+
300
+ self.audioQueue.sync {
301
+ guard let asset = self.audioList[assetId] as? AudioAsset else {
302
+ return
303
+ }
304
+
305
+ // Persist the paused position for the next resume.
306
+ let timeBeforePause = asset.getCurrentTime()
307
+ var data = self.audioAssetData[assetId] ?? [:]
308
+ data["timeBeforePause"] = timeBeforePause
309
+ self.audioAssetData[assetId] = data
310
+
311
+ asset.pause()
312
+ self.updatePlaybackState(isPlaying: false, elapsedTime: timeBeforePause, duration: asset.getDuration())
313
+ self.notifyPlaybackState(assetId: assetId, reason: "remotePause", state: .paused, audioAsset: asset)
314
+ }
315
+ return .success
316
+ }
317
+
318
+ // Stop command
319
+ commandCenter.stopCommand.addTarget { [weak self] _ in
320
+ guard let self = self, let assetId = self.currentlyPlayingAssetId else {
321
+ return .noSuchContent
322
+ }
323
+
324
+ self.audioQueue.sync {
325
+ guard let asset = self.audioList[assetId] as? AudioAsset else {
326
+ return
327
+ }
328
+
329
+ // Sample before `stop()` — `AudioAsset.stop()` resets every channel's `currentTime` to 0.
330
+ let elapsedTime = asset.getCurrentTime()
331
+ let duration = asset.getDuration()
332
+ asset.stop()
333
+ // Keep `currentlyPlayingAssetId` and Now Playing metadata so the lock screen card
334
+ // stays until `unload()` (or natural completion / another `play()` replaces it).
335
+ if self.showNotification,
336
+ self.currentlyPlayingAssetId == assetId {
337
+ self.updatePlaybackState(
338
+ isPlaying: false,
339
+ elapsedTime: elapsedTime,
340
+ duration: duration
341
+ )
342
+ }
343
+ self.notifyPlaybackState(assetId: assetId, reason: "remoteStop", state: .stopped, audioAsset: asset)
344
+ }
345
+ return .success
346
+ }
347
+
348
+ // Toggle play/pause command
349
+ commandCenter.togglePlayPauseCommand.addTarget { [weak self] _ in
350
+ guard let self = self, let assetId = self.currentlyPlayingAssetId else {
351
+ return .noSuchContent
352
+ }
353
+
354
+ self.audioQueue.sync {
355
+ guard let asset = self.audioList[assetId] as? AudioAsset else {
356
+ return
357
+ }
358
+
359
+ if asset.isPlaying() {
360
+ // Persist the paused position for the next resume.
361
+ let timeBeforePause = asset.getCurrentTime()
362
+ var data = self.audioAssetData[assetId] ?? [:]
363
+ data["timeBeforePause"] = timeBeforePause
364
+ self.audioAssetData[assetId] = data
365
+
366
+ asset.pause()
367
+ self.updatePlaybackState(isPlaying: false, elapsedTime: timeBeforePause, duration: asset.getDuration())
368
+ self.notifyPlaybackState(assetId: assetId, reason: "remotePause", state: .paused, audioAsset: asset)
369
+ } else {
370
+ self.resumeAssetFromStoredPausePositionIfNeeded(assetId: assetId, asset: asset, reason: "remotePlay")
371
+ }
372
+ }
373
+ return .success
374
+ }
375
+
376
+ // Skip forward command
377
+ commandCenter.skipForwardCommand.preferredIntervals = [NSNumber(value: 15)]
378
+ commandCenter.skipForwardCommand.isEnabled = true
379
+ commandCenter.skipForwardCommand.addTarget { [weak self] event in
380
+ guard let self else { return .commandFailed }
381
+ guard let skipEvent = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
382
+ return self.handleSeekCommand(delta: skipEvent.interval)
383
+ }
384
+
385
+ // Skip backward command
386
+ commandCenter.skipBackwardCommand.preferredIntervals = [NSNumber(value: 15)]
387
+ commandCenter.skipBackwardCommand.isEnabled = true
388
+ commandCenter.skipBackwardCommand.addTarget { [weak self] event in
389
+ guard let self else { return .commandFailed }
390
+ guard let skipEvent = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
391
+ return self.handleSeekCommand(delta: -skipEvent.interval)
392
+ }
393
+
394
+ // Scrub / change position command
395
+ commandCenter.changePlaybackPositionCommand.isEnabled = true
396
+ commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
397
+ guard let self else { return .commandFailed }
398
+ guard let positionEvent = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
399
+ return self.handleSeekCommand(targetTime: positionEvent.positionTime)
400
+ }
401
+ }
402
+ // swiftlint:enable function_body_length
403
+
404
+ private func handleSeekCommand(delta: TimeInterval? = nil, targetTime: TimeInterval? = nil) -> MPRemoteCommandHandlerStatus {
405
+ guard let assetId = currentlyPlayingAssetId else {
406
+ return .noSuchContent
407
+ }
408
+
409
+ var asset: AudioAsset?
410
+ audioQueue.sync {
411
+ asset = audioList[assetId] as? AudioAsset
412
+ }
413
+
414
+ guard let audioAsset = asset else {
415
+ return .noSuchContent
416
+ }
417
+
418
+ let duration = audioAsset.getDuration()
419
+ let currentTime = audioAsset.getCurrentTime()
420
+ let requestedTime: TimeInterval
421
+
422
+ if let delta {
423
+ requestedTime = currentTime + delta
424
+ } else if let targetTime {
425
+ requestedTime = targetTime
426
+ } else {
427
+ return .commandFailed
428
+ }
429
+
430
+ let clampedTime: TimeInterval
431
+ if duration.isFinite && duration > 0 {
432
+ clampedTime = min(max(requestedTime, 0), duration)
433
+ } else {
434
+ clampedTime = max(requestedTime, 0)
435
+ }
436
+
437
+ audioAsset.setCurrentTime(time: clampedTime) { [weak self, weak audioAsset] in
438
+ guard let self else { return }
439
+ let isPlaying = audioAsset?.isPlaying() ?? false
440
+ let durationValue = duration.isFinite && duration > 0 ? duration : nil
441
+
442
+ if self.showNotification,
443
+ self.currentlyPlayingAssetId == assetId {
444
+ self.updatePlaybackState(
445
+ isPlaying: isPlaying,
446
+ elapsedTime: clampedTime,
447
+ duration: durationValue
448
+ )
449
+ }
450
+
451
+ // Emit a currentTime event so JS can sync UI immediately after remote seek.
452
+ let roundedTime = round(clampedTime * 10) / 10
453
+ self.notifyListeners("currentTime", data: [
454
+ "currentTime": roundedTime,
455
+ "assetId": assetId
456
+ ])
457
+ }
458
+
459
+ return .success
460
+ }
461
+
462
+ @objc func setDebugMode(_ call: CAPPluginCall) {
463
+ let debug = call.getBool("enabled") ?? false
464
+ Logger.debugModeEnabled = debug
465
+ if debug {
466
+ logger.info("Debug mode enabled")
467
+ }
468
+ call.resolve()
469
+ }
470
+
471
+ @objc func configure(_ call: CAPPluginCall) {
472
+ // Save original category on first configure call
473
+ if !audioSessionInitialized {
474
+ originalAudioCategory = session.category
475
+ originalAudioOptions = session.categoryOptions
476
+ audioSessionInitialized = true
477
+ }
478
+
479
+ let focus = call.getBool(Constant.FocusAudio) ?? false
480
+ let background = call.getBool(Constant.Background) ?? false
481
+ let ignoreSilent = call.getBool(Constant.IgnoreSilent) ?? true
482
+ // Only update showNotification when explicitly provided so repeated configure() calls
483
+ // (e.g. when switching assets) don't reset it to false and break Now Playing for the next play
484
+ if let showNotification = call.getBool(Constant.ShowNotification) {
485
+ self.showNotification = showNotification
486
+ }
487
+
488
+ logger.info("Configuring audio session with focus=%@ background=%@ ignoreSilent=%@", "\(focus)", "\(background)", "\(ignoreSilent)")
489
+
490
+ // Use a single audio session configuration block for better atomicity
491
+ do {
492
+ // Set category first
493
+ // Fix for issue #202: When showNotification is enabled, use .playback without
494
+ // .mixWithOthers or .duckOthers to allow Now Playing info to display in
495
+ // Control Center and lock screen.
496
+ //
497
+ // IMPORTANT: This is a behavior trade-off:
498
+ // - With .playback + .default mode: Now Playing info shows, but interrupts other audio
499
+ // - With .mixWithOthers or .duckOthers: Audio mixes, but no Now Playing info
500
+ //
501
+ // This is required because iOS only shows Now Playing controls for audio sessions
502
+ // that use the .playback category without mixing options. This means the app becomes
503
+ // the primary audio source and will interrupt background music from other apps.
504
+ if self.showNotification {
505
+ // Use playback category with default mode for notification support
506
+ try self.session.setCategory(AVAudioSession.Category.playback, mode: .default)
507
+ } else if focus {
508
+ try self.session.setCategory(AVAudioSession.Category.playback, options: .duckOthers)
509
+ } else if !ignoreSilent {
510
+ try self.session.setCategory(AVAudioSession.Category.ambient, options: focus ? .duckOthers : .mixWithOthers)
511
+ } else {
512
+ try self.session.setCategory(AVAudioSession.Category.playback, options: .mixWithOthers)
513
+ }
514
+
515
+ // Only activate if needed (background mode)
516
+ if background {
517
+ try self.session.setActive(true)
518
+ }
519
+
520
+ } catch {
521
+ logger.error("Failed to configure audio session: %@", error.localizedDescription)
522
+ }
523
+
524
+ call.resolve()
525
+ }
526
+
527
+ /// Checks whether an audio asset with the given assetId is currently loaded.
528
+ /// - Parameter call: A CAPPluginCall that must include the `"assetId"` string identifying the audio asset to check. The call is rejected with `"Missing assetId"` if the parameter is absent.
529
+ /// - Returns: A dictionary with key `found` set to `true` if the asset is loaded, `false` otherwise.
530
+ @objc func isPreloaded(_ call: CAPPluginCall) {
531
+ guard let assetId = call.getString(Constant.AssetIdKey) else {
532
+ call.reject("Missing assetId")
533
+ return
534
+ }
535
+
536
+ audioQueue.sync {
537
+ call.resolve([
538
+ "found": self.audioList[assetId] != nil
539
+ ])
540
+ }
541
+ }
542
+
543
+ /// Preloads an audio asset into the plugin's audio cache for full-featured playback.
544
+ ///
545
+ /// The call should include the asset configuration (for example `assetId`, `assetPath`) and may include optional playback and metadata options such as `channels`, `volume`, `delay`, `isUrl`, `headers`, and notification metadata. The plugin will load the asset so it is ready for subsequent play, loop, stop and other playback operations.
546
+ /// - Parameters:
547
+ /// Preloads an audio asset with advanced playback options for later use.
548
+ ///
549
+ /// Prepares the asset specified in the plugin call (local file, bundled resource, or remote URL) using options such as `assetId`, `assetPath`, `isUrl`, `volume`, `channels`, `delay`, headers, and notification metadata so it is ready for playback.
550
+ /// - Parameter call: The CAPPluginCall containing preload options and identifiers.
551
+ @objc func preload(_ call: CAPPluginCall) {
552
+ preloadAsset(call, isComplex: true)
553
+ }
554
+
555
+ // swiftlint:disable:next cyclomatic_complexity function_body_length
556
+ @objc func playOnce(_ call: CAPPluginCall) {
557
+ // Generate unique temporary asset ID
558
+ let assetId = "playOnce_\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(8))"
559
+
560
+ // Extract options
561
+ let assetPath = call.getString(Constant.AssetPathKey) ?? ""
562
+ let autoPlay = call.getBool("autoPlay") ?? true
563
+ let deleteAfterPlay = call.getBool("deleteAfterPlay") ?? false
564
+ let volume = min(max(call.getFloat("volume") ?? Constant.DefaultVolume, Constant.MinVolume), Constant.MaxVolume)
565
+ let isLocalUrl = call.getBool("isUrl") ?? false
566
+
567
+ if assetPath == "" {
568
+ call.reject(Constant.ErrorAssetPath)
569
+ return
570
+ }
571
+
572
+ // Parse notification metadata if provided (on main thread)
573
+ var metadataDict: [String: String]?
574
+ if let metadata = call.getObject(Constant.NotificationMetadata) {
575
+ var tempDict: [String: String] = [:]
576
+ if let title = metadata["title"] as? String {
577
+ tempDict["title"] = title
578
+ }
579
+ if let artist = metadata["artist"] as? String {
580
+ tempDict["artist"] = artist
581
+ }
582
+ if let album = metadata["album"] as? String {
583
+ tempDict["album"] = album
584
+ }
585
+ if let artworkUrl = metadata["artworkUrl"] as? String {
586
+ tempDict["artworkUrl"] = artworkUrl
587
+ }
588
+ if !tempDict.isEmpty {
589
+ metadataDict = tempDict
590
+ }
591
+ }
592
+
593
+ // Ensure audio session is initialized
594
+ if !audioSessionInitialized {
595
+ setupAudioSession()
596
+ }
597
+
598
+ // Track this as a playOnce asset and store metadata (thread-safe)
599
+ audioQueue.sync(flags: .barrier) {
600
+ self.playOnceAssets.insert(assetId)
601
+ if let metadata = metadataDict {
602
+ self.notificationMetadataMap[assetId] = metadata
603
+ }
604
+ }
605
+
606
+ // Create a completion handler for cleanup
607
+ let cleanupHandler: () -> Void = { [weak self] in
608
+ guard let self = self else { return }
609
+
610
+ self.audioQueue.async(flags: .barrier) {
611
+ guard let asset = self.audioList[assetId] as? AudioAsset else { return }
612
+
613
+ // Get the file path before unloading if we need to delete
614
+ // Only delete if it's a local file:// URL, not remote streaming URLs
615
+ var filePathToDelete: String?
616
+ if deleteAfterPlay {
617
+ if let url = asset.channels.first?.url, url.isFileURL {
618
+ filePathToDelete = url.path
619
+ }
620
+ }
621
+
622
+ // Unload the asset
623
+ asset.unload()
624
+ self.audioList[assetId] = nil
625
+ self.playOnceAssets.remove(assetId)
626
+ self.notificationMetadataMap.removeValue(forKey: assetId)
627
+
628
+ // Reset current track if this was the currently playing asset (next play will overwrite Now Playing)
629
+ if self.currentlyPlayingAssetId == assetId {
630
+ self.currentlyPlayingAssetId = nil
631
+ }
632
+
633
+ // Delete file if requested and it's a local file
634
+ if let filePath = filePathToDelete {
635
+ let fileManager = FileManager.default
636
+ let resolvedPath: String
637
+ if filePath.hasPrefix("file://") {
638
+ resolvedPath = URL(string: filePath)?.path ?? filePath
639
+ } else {
640
+ resolvedPath = filePath
641
+ }
642
+
643
+ do {
644
+ if fileManager.fileExists(atPath: resolvedPath) {
645
+ try fileManager.removeItem(atPath: resolvedPath)
646
+ print("Deleted file after playOnce: \(resolvedPath)")
647
+ }
648
+ } catch {
649
+ print("Error deleting file after playOnce: \(error.localizedDescription)")
650
+ }
651
+ }
652
+ }
653
+ }
654
+
655
+ /// Cleans up tracking data when playOnce fails to prevent memory leaks.
656
+ ///
657
+ /// Removes the asset ID from both playOnceAssets set and notificationMetadataMap
658
+ /// to ensure proper cleanup when an error occurs during playOnce execution.
659
+ ///
660
+ /// Removes transient tracking for a one-off playback asset and its associated notification metadata.
661
+ /// Remove tracking and Now Playing metadata for a play-once asset after a failed load or playback.
662
+ /// - Parameter assetId: The asset identifier to remove from play-once tracking and notification metadata.
663
+ func cleanupOnFailure(assetId: String) {
664
+ self.playOnceAssets.remove(assetId)
665
+ self.notificationMetadataMap.removeValue(forKey: assetId)
666
+ }
667
+
668
+ // Inline preload logic directly (avoid creating mock PluginCall)
669
+ audioQueue.async(flags: .barrier) { [weak self] in
670
+ guard let self = self else { return }
671
+
672
+ // Check if asset already exists
673
+ if self.audioList[assetId] != nil {
674
+ cleanupOnFailure(assetId: assetId)
675
+ call.reject(Constant.ErrorAssetAlreadyLoaded + " - " + assetId)
676
+ return
677
+ }
678
+
679
+ var basePath: String?
680
+
681
+ if let url = URL(string: assetPath), url.scheme != nil {
682
+ // Check if it's a local file URL or a remote URL
683
+ if url.isFileURL {
684
+ // Handle local file URL
685
+ basePath = url.path
686
+
687
+ if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
688
+ let audioAsset = AudioAsset(
689
+ owner: self,
690
+ withAssetId: assetId,
691
+ withPath: basePath,
692
+ withChannels: 1,
693
+ withVolume: volume
694
+ )
695
+ self.audioList[assetId] = audioAsset
696
+ } else {
697
+ cleanupOnFailure(assetId: assetId)
698
+ call.reject(Constant.ErrorAssetPath + " - " + assetPath)
699
+ return
700
+ }
701
+ } else {
702
+ // Handle remote URL
703
+ var headers: [String: String]?
704
+ if let headersObj = call.getObject("headers") {
705
+ headers = [:]
706
+ for (key, value) in headersObj {
707
+ if let stringValue = value as? String {
708
+ headers?[key] = stringValue
709
+ }
710
+ }
711
+ }
712
+ let remoteAudioAsset = RemoteAudioAsset(
713
+ owner: self,
714
+ withAssetId: assetId,
715
+ withPath: assetPath,
716
+ withChannels: 1,
717
+ withVolume: volume,
718
+ withHeaders: headers
719
+ )
720
+ self.audioList[assetId] = remoteAudioAsset
721
+ }
722
+ } else if !isLocalUrl {
723
+ // Handle public folder
724
+ let publicAssetPath = assetPath.starts(with: "public/") ? assetPath : "public/" + assetPath
725
+ let assetPathSplit = publicAssetPath.components(separatedBy: ".")
726
+ if assetPathSplit.count >= 2 {
727
+ basePath = Bundle.main.path(forResource: assetPathSplit[0], ofType: assetPathSplit[1])
728
+ } else {
729
+ cleanupOnFailure(assetId: assetId)
730
+ call.reject("Invalid asset path format: \(assetPath)")
731
+ return
732
+ }
733
+
734
+ if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
735
+ let audioAsset = AudioAsset(
736
+ owner: self,
737
+ withAssetId: assetId,
738
+ withPath: basePath,
739
+ withChannels: 1,
740
+ withVolume: volume
741
+ )
742
+ self.audioList[assetId] = audioAsset
743
+ } else {
744
+ cleanupOnFailure(assetId: assetId)
745
+ call.reject(Constant.ErrorAssetPath + " - " + assetPath)
746
+ return
747
+ }
748
+ } else {
749
+ // Handle local file path
750
+ let fileURL = URL(fileURLWithPath: assetPath)
751
+ basePath = fileURL.path
752
+
753
+ if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
754
+ let audioAsset = AudioAsset(
755
+ owner: self,
756
+ withAssetId: assetId,
757
+ withPath: basePath,
758
+ withChannels: 1,
759
+ withVolume: volume
760
+ )
761
+ self.audioList[assetId] = audioAsset
762
+ } else {
763
+ cleanupOnFailure(assetId: assetId)
764
+ call.reject(Constant.ErrorAssetPath + " - " + assetPath)
765
+ return
766
+ }
767
+ }
768
+
769
+ // Get the loaded asset
770
+ guard let asset = self.audioList[assetId] as? AudioAsset else {
771
+ // Cleanup on failure
772
+ cleanupOnFailure(assetId: assetId)
773
+ call.reject("Failed to load asset for playOnce")
774
+ return
775
+ }
776
+
777
+ // Set up completion handler
778
+ asset.onComplete = {
779
+ cleanupHandler()
780
+ }
781
+
782
+ // Auto-play if requested
783
+ if autoPlay {
784
+ self.activateSession()
785
+ asset.play(time: 0, volume: nil)
786
+
787
+ // Update notification center if enabled
788
+ if self.showNotification {
789
+ self.currentlyPlayingAssetId = assetId
790
+ self.updateNowPlayingInfo(audioId: assetId, audioAsset: asset)
791
+ self.updatePlaybackState(isPlaying: true)
792
+ }
793
+ self.notifyPlaybackState(assetId: assetId, reason: "playOnce", state: .playing, audioAsset: asset)
794
+ }
795
+
796
+ // Return the generated assetId
797
+ call.resolve(["assetId": assetId])
798
+ }
799
+ }
800
+
801
+ /// Activates the app's audio session when no other audio is playing.
802
+ /// Activate the shared AVAudioSession when no other audio is playing.
803
+ ///
804
+ /// If the system reports other audio is playing, the session is left inactive. On failure to activate, the error is printed to the console.
805
+ func activateSession() {
806
+ do {
807
+ // Only activate if not already active
808
+ if !session.isOtherAudioPlaying {
809
+ try self.session.setActive(true)
810
+ }
811
+ } catch {
812
+ print("Failed to set session active: \(error)")
813
+ }
814
+ }
815
+
816
+ func endSession() {
817
+ do {
818
+ // Avoid reentrant sync when already on audio queue (e.g. from pause(), didEnterBackground) to prevent deadlock
819
+ let hasPlayingAssets: Bool
820
+ if DispatchQueue.getSpecific(key: queueKey) != nil || DispatchQueue.getSpecific(key: audioQueueContextKey) == true {
821
+ hasPlayingAssets = self.audioList.values.contains { asset in
822
+ if let audioAsset = asset as? AudioAsset {
823
+ return audioAsset.isPlaying()
824
+ }
825
+ return false
826
+ }
827
+ } else {
828
+ hasPlayingAssets = audioQueue.sync {
829
+ return self.audioList.values.contains { asset in
830
+ if let audioAsset = asset as? AudioAsset {
831
+ return audioAsset.isPlaying()
832
+ }
833
+ return false
834
+ }
835
+ }
836
+ }
837
+
838
+ // Only deactivate if no assets are playing AND no other audio is active,
839
+ // and only when we're not in a record-capable mode (e.g. usage with CameraPreview plugin).
840
+ let isRecordCapableCategory: Bool = {
841
+ switch session.category {
842
+ case .record, .playAndRecord, .multiRoute:
843
+ return true
844
+ default:
845
+ return false
846
+ }
847
+ }()
848
+
849
+ if !hasPlayingAssets &&
850
+ !session.isOtherAudioPlaying &&
851
+ session.secondaryAudioShouldBeSilencedHint == false &&
852
+ !isRecordCapableCategory {
853
+ try self.session.setActive(false, options: .notifyOthersOnDeactivation)
854
+ }
855
+ } catch {
856
+ print("Failed to deactivate audio session: \(error)")
857
+ }
858
+ }
859
+
860
+ public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
861
+ // Don't immediately end the session here, as other players might still be active
862
+ // Instead, check if all players are done and clear Now Playing if this asset was current
863
+ audioQueue.async { [weak self] in
864
+ guard let self = self else { return }
865
+ var completedAssetId: String?
866
+
867
+ // Find which asset this player belongs to; if it was the currently playing one, clear notification
868
+ for (audioId, asset) in self.audioList {
869
+ if let audioAsset = asset as? AudioAsset, audioAsset.channels.contains(player) {
870
+ completedAssetId = audioId
871
+ self.handlePlaybackCompletion(assetId: audioId, audioAsset: audioAsset)
872
+ break
873
+ }
874
+ }
875
+
876
+ // Avoid recursive calls by checking if the asset is still in the list
877
+ let hasPlayingAssets = self.audioList.values.contains { asset in
878
+ if let audioAsset = asset as? AudioAsset {
879
+ // Check if the asset has any playing channels other than the one that just finished
880
+ return audioAsset.channels.contains { $0 != player && $0.isPlaying }
881
+ }
882
+ return false
883
+ }
884
+
885
+ // Only end the session if no more assets are playing
886
+ if !hasPlayingAssets {
887
+ // If we didn't find the asset above (e.g. playOnce already removed it), clear notification when nothing is playing
888
+ if completedAssetId == nil, self.currentlyPlayingAssetId != nil {
889
+ self.currentlyPlayingAssetId = nil
890
+ self.clearNowPlayingInfo()
891
+ }
892
+ self.endSession()
893
+ }
894
+ }
895
+ }
896
+
897
+ // swiftlint:disable:next function_body_length
898
+ @objc func play(_ call: CAPPluginCall) {
899
+ let audioId = call.getString(Constant.AssetIdKey) ?? ""
900
+ let time = max(call.getDouble(Constant.Time) ?? 0, 0)
901
+ let delay = max(call.getDouble(Constant.Delay) ?? 0, 0)
902
+ let volume = call.getFloat(Constant.Volume)
903
+ let fadeIn = call.getBool(Constant.FadeIn) ?? false
904
+ let fadeOut = call.getBool(Constant.FadeOut) ?? false
905
+ let fadeInDuration = call.getDouble(Constant.FadeInDuration) ?? Double(Constant.DefaultFadeDuration)
906
+ let fadeOutDuration = call.getDouble(Constant.FadeOutDuration) ?? Double(Constant.DefaultFadeDuration)
907
+ let fadeOutStartTime = call.getDouble(Constant.FadeOutStartTime) ?? 0.0
908
+
909
+ // Ensure audio session is initialized before first play
910
+ if !audioSessionInitialized {
911
+ setupAudioSession()
912
+ }
913
+
914
+ // Use sync for operations that need to be blocking
915
+ audioQueue.sync {
916
+ guard !audioList.isEmpty else {
917
+ call.reject("Audio list is empty")
918
+ return
919
+ }
920
+
921
+ guard let asset = audioList[audioId] else {
922
+ call.reject(Constant.ErrorAssetNotFound)
923
+ return
924
+ }
925
+
926
+ if let audioAsset = asset as? AudioAsset {
927
+ self.activateSession()
928
+ cancelPendingPlay(for: audioId)
929
+ clearAudioAssetData(for: audioId)
930
+
931
+ let playBlock = { [weak self] in
932
+ guard let self else { return }
933
+ self.executeOnAudioQueue {
934
+ if fadeIn {
935
+ audioAsset.playWithFade(time: time, volume: volume, fadeInDuration: fadeInDuration)
936
+ } else {
937
+ audioAsset.play(time: time, volume: volume)
938
+ }
939
+ self.pendingPlayTasks[audioId] = nil
940
+
941
+ if fadeOut {
942
+ self.handleFadeOut(
943
+ for: audioAsset,
944
+ audioId: audioId,
945
+ fadeOutDuration: fadeOutDuration,
946
+ fadeOutStartTime: fadeOutStartTime
947
+ )
948
+ }
949
+
950
+ if self.showNotification {
951
+ self.currentlyPlayingAssetId = audioId
952
+ self.updateNowPlayingInfo(audioId: audioId, audioAsset: audioAsset)
953
+ self.updatePlaybackState(isPlaying: true)
954
+ }
955
+ self.notifyPlaybackState(assetId: audioId, reason: "play", state: .playing, audioAsset: audioAsset)
956
+ call.resolve()
957
+ }
958
+ }
959
+
960
+ if delay > 0 {
961
+ let workItem = DispatchWorkItem(block: playBlock)
962
+ pendingPlayTasks[audioId] = workItem
963
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
964
+ } else {
965
+ playBlock()
966
+ }
967
+ } else if let audioNumber = asset as? NSNumber {
968
+ self.activateSession()
969
+ AudioServicesPlaySystemSound(SystemSoundID(audioNumber.intValue))
970
+ call.resolve()
971
+ } else {
972
+ call.reject(Constant.ErrorAssetNotFound)
973
+ }
974
+ }
975
+ }
976
+
977
+ @objc private func getAudioAsset(_ call: CAPPluginCall) -> AudioAsset? {
978
+ // Avoid reentrant sync when already on audio queue (e.g. from pause()) to prevent deadlock
979
+ if DispatchQueue.getSpecific(key: audioQueueContextKey) == true {
980
+ return self.audioList[call.getString(Constant.AssetIdKey) ?? ""] as? AudioAsset
981
+ }
982
+ var asset: AudioAsset?
983
+ audioQueue.sync {
984
+ asset = self.audioList[call.getString(Constant.AssetIdKey) ?? ""] as? AudioAsset
985
+ }
986
+ return asset
987
+ }
988
+
989
+ @objc func setCurrentTime(_ call: CAPPluginCall) {
990
+ audioQueue.sync {
991
+ self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
992
+ defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
993
+ guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
994
+ call.reject("Failed to get audio asset")
995
+ return
996
+ }
997
+
998
+ cancelPendingPlay(for: audioAsset.assetId)
999
+ clearAudioAssetData(for: audioAsset.assetId)
1000
+ let time = max(call.getDouble(Constant.Time) ?? 0, 0)
1001
+ audioAsset.setCurrentTime(time: time) {
1002
+ call.resolve()
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ @objc func getDuration(_ call: CAPPluginCall) {
1008
+ audioQueue.sync {
1009
+ self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
1010
+ defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
1011
+ guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
1012
+ call.reject("Failed to get audio asset")
1013
+ return
1014
+ }
1015
+
1016
+ call.resolve([
1017
+ "duration": audioAsset.getDuration()
1018
+ ])
1019
+ }
1020
+ }
1021
+
1022
+ @objc func getCurrentTime(_ call: CAPPluginCall) {
1023
+ audioQueue.sync {
1024
+ self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
1025
+ defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
1026
+ guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
1027
+ call.reject("Failed to get audio asset")
1028
+ return
1029
+ }
1030
+
1031
+ call.resolve([
1032
+ "currentTime": audioAsset.getCurrentTime()
1033
+ ])
1034
+ }
1035
+ }
1036
+
1037
+ // swiftlint:disable:next function_body_length
1038
+ @objc func resume(_ call: CAPPluginCall) {
1039
+ let audioId = call.getString(Constant.AssetIdKey) ?? ""
1040
+ audioQueue.sync {
1041
+ self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
1042
+ defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
1043
+ guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
1044
+ call.reject("Failed to get audio asset")
1045
+ return
1046
+ }
1047
+ self.activateSession()
1048
+ let fadeIn = call.getBool(Constant.FadeIn) ?? false
1049
+ let fadeInDuration = call.getDouble(Constant.FadeInDuration) ?? Double(Constant.DefaultFadeDuration)
1050
+ var restoredVolume: Float?
1051
+
1052
+ var restoredTime: TimeInterval?
1053
+ if var data = audioAssetData[audioAsset.assetId],
1054
+ let time = data["timeBeforePause"] as? TimeInterval {
1055
+ restoredTime = time
1056
+ data.removeValue(forKey: "timeBeforePause")
1057
+ audioAssetData[audioAsset.assetId] = data
1058
+ }
1059
+
1060
+ if let data = audioAssetData[audioAsset.assetId], let volume = data["volumeBeforePause"] as? Float {
1061
+ restoredVolume = volume
1062
+ }
1063
+
1064
+ let finishResume: () -> Void = { [weak self] in
1065
+ guard let self else { return }
1066
+ if fadeIn {
1067
+ let targetVolume = restoredVolume ?? (audioAsset.channels.first?.volume ?? audioAsset.initialVolume)
1068
+ audioAsset.setVolume(volume: 0, fadeDuration: 0)
1069
+ audioAsset.resume()
1070
+ audioAsset.setVolume(volume: NSNumber(value: targetVolume), fadeDuration: fadeInDuration)
1071
+ } else {
1072
+ if let volume = restoredVolume {
1073
+ audioAsset.setVolume(volume: NSNumber(value: volume), fadeDuration: 0)
1074
+ }
1075
+ audioAsset.resume()
1076
+ }
1077
+ if var data = self.audioAssetData[audioAsset.assetId] {
1078
+ data.removeValue(forKey: "volumeBeforePause")
1079
+ self.audioAssetData[audioAsset.assetId] = data
1080
+ }
1081
+ if self.showNotification {
1082
+ self.currentlyPlayingAssetId = audioId
1083
+ self.updateNowPlayingInfo(audioId: audioId, audioAsset: audioAsset)
1084
+ }
1085
+ self.notifyPlaybackState(assetId: audioId, reason: "resume", state: .playing, audioAsset: audioAsset)
1086
+ call.resolve()
1087
+ }
1088
+
1089
+ if let resumeTime = restoredTime {
1090
+ audioAsset.setCurrentTime(time: resumeTime) { [weak self] in
1091
+ guard let self else { return }
1092
+ self.audioQueue.async(flags: .barrier, execute: finishResume)
1093
+ }
1094
+ } else {
1095
+ finishResume()
1096
+ }
1097
+ }
1098
+ }
1099
+
1100
+ @objc func pause(_ call: CAPPluginCall) {
1101
+ audioQueue.sync {
1102
+ self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
1103
+ defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
1104
+ guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
1105
+ call.reject("Failed to get audio asset")
1106
+ return
1107
+ }
1108
+ cancelPendingPlay(for: audioAsset.assetId)
1109
+ let fadeOut = call.getBool(Constant.FadeOut) ?? false
1110
+ let fadeOutDuration = call.getDouble(Constant.FadeOutDuration) ?? Double(Constant.DefaultFadeDuration)
1111
+ let currentVolume = audioAsset.channels.first?.volume ?? audioAsset.initialVolume
1112
+ var data = audioAssetData[audioAsset.assetId] ?? [:]
1113
+ data["volumeBeforePause"] = currentVolume
1114
+
1115
+ // Without fade: store position now. With fade: `recordPausePositionAfterFade` runs when the fade finishes.
1116
+ if !fadeOut {
1117
+ data["timeBeforePause"] = audioAsset.getCurrentTime()
1118
+ }
1119
+ audioAssetData[audioAsset.assetId] = data
1120
+
1121
+ if fadeOut {
1122
+ audioAsset.stopWithFade(fadeOutDuration: fadeOutDuration, toPause: true)
1123
+ } else {
1124
+ audioAsset.pause()
1125
+ }
1126
+
1127
+ // Fade-out: `recordPausePositionAfterFade` updates Now Playing when fade-to-pause completes.
1128
+ if self.showNotification && !fadeOut {
1129
+ self.updatePlaybackState(isPlaying: false, elapsedTime: audioAsset.getCurrentTime(), duration: audioAsset.getDuration())
1130
+ }
1131
+
1132
+ self.endSession()
1133
+ if !fadeOut {
1134
+ self.notifyPlaybackState(assetId: audioAsset.assetId, reason: "pause", state: .paused, audioAsset: audioAsset)
1135
+ }
1136
+ call.resolve()
1137
+ }
1138
+ }
1139
+
1140
+ /// Stops playback of the audio asset identified by `assetId` from the plugin call and performs related cleanup.
1141
+ ///
1142
+ /// The `assetId` is read from the call using `Constant.AssetIdKey`. If the asset is currently playing it will be stopped. When `showNotification` is enabled and this asset owns Now Playing, playback state is updated to stopped but the Now Playing card is left in place until `unload()` or natural completion. If the asset was created by `playOnce`, it is removed from `playOnceAssets` and its notification metadata is removed. The audio session is ended if appropriate. The call is resolved on success or rejected with an error message on failure.
1143
+ @objc func stop(_ call: CAPPluginCall) {
1144
+ let audioId = call.getString(Constant.AssetIdKey) ?? ""
1145
+ let fadeOut = call.getBool(Constant.FadeOut) ?? false
1146
+ let fadeOutDuration = call.getDouble(Constant.FadeOutDuration) ?? Double(Constant.DefaultFadeDuration)
1147
+
1148
+ audioQueue.sync {
1149
+ self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
1150
+ defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
1151
+ guard !self.audioList.isEmpty else {
1152
+ call.reject("Audio list is empty")
1153
+ return
1154
+ }
1155
+
1156
+ do {
1157
+ // Sample before `stopAudio` — non-fade `AudioAsset.stop()` resets `currentTime` to 0.
1158
+ var preStopNowPlayingElapsed: TimeInterval?
1159
+ var preStopNowPlayingDuration: TimeInterval?
1160
+ if !fadeOut,
1161
+ self.showNotification,
1162
+ self.currentlyPlayingAssetId == audioId,
1163
+ let preStopAsset = self.audioList[audioId] as? AudioAsset {
1164
+ preStopNowPlayingElapsed = preStopAsset.getCurrentTime()
1165
+ preStopNowPlayingDuration = preStopAsset.getDuration()
1166
+ }
1167
+
1168
+ try self.stopAudio(audioId: audioId, fadeOut: fadeOut, fadeOutDuration: fadeOutDuration)
1169
+
1170
+ // Keep `currentlyPlayingAssetId` so lock screen / Control Center stays tied to this asset
1171
+ // until `unload()` clears it; refresh Now Playing to a stopped state (rate 0).
1172
+ // Skip when fading out to stop: `recordStoppedPlaybackStateAfterFade` runs when the fade finishes
1173
+ // (and for zero-volume immediate stop inside `stopWithFade`).
1174
+ if let elapsed = preStopNowPlayingElapsed,
1175
+ let duration = preStopNowPlayingDuration,
1176
+ self.showNotification,
1177
+ self.currentlyPlayingAssetId == audioId {
1178
+ self.updatePlaybackState(
1179
+ isPlaying: false,
1180
+ elapsedTime: elapsed,
1181
+ duration: duration
1182
+ )
1183
+ }
1184
+
1185
+ // Clean up playOnce tracking if this was a playOnce asset
1186
+ if self.playOnceAssets.contains(audioId) {
1187
+ self.playOnceAssets.remove(audioId)
1188
+ self.notificationMetadataMap.removeValue(forKey: audioId)
1189
+ }
1190
+
1191
+ self.endSession()
1192
+ if !fadeOut {
1193
+ if let audioAsset = self.audioList[audioId] as? AudioAsset {
1194
+ self.notifyPlaybackState(assetId: audioId, reason: "stop", state: .stopped, audioAsset: audioAsset)
1195
+ } else {
1196
+ self.notifyPlaybackState(assetId: audioId, reason: "stop", state: .stopped)
1197
+ }
1198
+ }
1199
+ call.resolve()
1200
+ } catch {
1201
+ call.reject(error.localizedDescription)
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ @objc func loop(_ call: CAPPluginCall) {
1207
+ audioQueue.sync {
1208
+ self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
1209
+ defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
1210
+ guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
1211
+ call.reject("Failed to get audio asset")
1212
+ return
1213
+ }
1214
+
1215
+ audioAsset.loop()
1216
+ if self.showNotification {
1217
+ self.currentlyPlayingAssetId = audioAsset.assetId
1218
+ self.updateNowPlayingInfo(audioId: audioAsset.assetId, audioAsset: audioAsset)
1219
+ self.updatePlaybackState(isPlaying: true)
1220
+ }
1221
+ self.notifyPlaybackState(assetId: audioAsset.assetId, reason: "loop", state: .playing, audioAsset: audioAsset)
1222
+ call.resolve()
1223
+ }
1224
+ }
1225
+
1226
+ /// Unloads a previously loaded audio asset identified by `assetId` and removes any associated one-shot tracking or metadata.
1227
+ /// - Parameters:
1228
+ /// - call: The plugin call that must include the `assetId` string under the key used by the plugin; on success the call is resolved, on failure the call is rejected (for example if the audio list is empty or the asset cannot be cast/unloaded).
1229
+ @objc func unload(_ call: CAPPluginCall) {
1230
+ let audioId = call.getString(Constant.AssetIdKey) ?? ""
1231
+
1232
+ audioQueue.sync(flags: .barrier) { // Use barrier for writing operations
1233
+ self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
1234
+ defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
1235
+
1236
+ guard !self.audioList.isEmpty else {
1237
+ call.reject("Audio list is empty")
1238
+ return
1239
+ }
1240
+
1241
+ let wasCurrentlyPlaying = self.currentlyPlayingAssetId == audioId
1242
+
1243
+ if let asset = self.audioList[audioId] as? AudioAsset {
1244
+ asset.unload()
1245
+ self.audioList[audioId] = nil
1246
+
1247
+ // Clean up playOnce tracking if this was a playOnce asset
1248
+ if self.playOnceAssets.contains(audioId) {
1249
+ self.playOnceAssets.remove(audioId)
1250
+ self.notificationMetadataMap.removeValue(forKey: audioId)
1251
+ }
1252
+
1253
+ if wasCurrentlyPlaying {
1254
+ // This asset controlled the Now Playing / remote command state.
1255
+ self.currentlyPlayingAssetId = nil
1256
+ if self.showNotification {
1257
+ self.clearNowPlayingInfo()
1258
+ }
1259
+ }
1260
+
1261
+ self.endSession()
1262
+ call.resolve()
1263
+ } else if let audioNumber = self.audioList[audioId] as? NSNumber {
1264
+ // Also handle unloading system sounds
1265
+ AudioServicesDisposeSystemSoundID(SystemSoundID(audioNumber.intValue))
1266
+ self.audioList[audioId] = nil
1267
+
1268
+ // Clean up playOnce tracking if this was a playOnce asset
1269
+ if self.playOnceAssets.contains(audioId) {
1270
+ self.playOnceAssets.remove(audioId)
1271
+ self.notificationMetadataMap.removeValue(forKey: audioId)
1272
+ }
1273
+
1274
+ call.resolve()
1275
+ } else {
1276
+ call.reject("Cannot cast to AudioAsset")
1277
+ }
1278
+ }
1279
+ }
1280
+
1281
+ @objc func setVolume(_ call: CAPPluginCall) {
1282
+ audioQueue.sync {
1283
+ self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
1284
+ defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
1285
+ guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
1286
+ call.reject("Failed to get audio asset")
1287
+ return
1288
+ }
1289
+
1290
+ let volume = min(max(call.getFloat(Constant.Volume) ?? Constant.DefaultVolume, Constant.MinVolume), Constant.MaxVolume)
1291
+ let durationSecs = call.getDouble(Constant.FadeDuration) ?? 0.0
1292
+ audioAsset.setVolume(volume: volume as NSNumber, fadeDuration: durationSecs)
1293
+ call.resolve()
1294
+ }
1295
+ }
1296
+
1297
+ @objc func setRate(_ call: CAPPluginCall) {
1298
+ audioQueue.sync {
1299
+ self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
1300
+ defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
1301
+ guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
1302
+ call.reject("Failed to get audio asset")
1303
+ return
1304
+ }
1305
+
1306
+ let rate = min(max(call.getFloat(Constant.Rate) ?? Constant.DefaultRate, Constant.MinRate), Constant.MaxRate)
1307
+ audioAsset.setRate(rate: rate as NSNumber)
1308
+ call.resolve()
1309
+ }
1310
+ }
1311
+
1312
+ @objc func isPlaying(_ call: CAPPluginCall) {
1313
+ audioQueue.sync {
1314
+ self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
1315
+ defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
1316
+ guard let audioAsset: AudioAsset = self.getAudioAsset(call) else {
1317
+ call.reject("Failed to get audio asset")
1318
+ return
1319
+ }
1320
+
1321
+ call.resolve([
1322
+ "isPlaying": audioAsset.isPlaying()
1323
+ ])
1324
+ }
1325
+ }
1326
+
1327
+ @objc func clearCache(_ call: CAPPluginCall) {
1328
+ DispatchQueue.global(qos: .background).async {
1329
+ RemoteAudioAsset.clearCache()
1330
+ call.resolve()
1331
+ }
1332
+ }
1333
+
1334
+ // swiftlint:disable:next cyclomatic_complexity function_body_length
1335
+ @objc private func preloadAsset(_ call: CAPPluginCall, isComplex complex: Bool) {
1336
+ // Common default values to ensure consistency
1337
+ let audioId = call.getString(Constant.AssetIdKey) ?? ""
1338
+ let channels: Int?
1339
+ let volume: Float?
1340
+ var isLocalUrl: Bool = call.getBool("isUrl") ?? false
1341
+
1342
+ if audioId == "" {
1343
+ call.reject(Constant.ErrorAssetId)
1344
+ return
1345
+ }
1346
+ var assetPath: String = call.getString(Constant.AssetPathKey) ?? ""
1347
+
1348
+ if assetPath == "" {
1349
+ call.reject(Constant.ErrorAssetPath)
1350
+ return
1351
+ }
1352
+
1353
+ // Store notification metadata if provided
1354
+ if let metadata = call.getObject(Constant.NotificationMetadata) {
1355
+ var metadataDict: [String: String] = [:]
1356
+ if let title = metadata["title"] as? String {
1357
+ metadataDict["title"] = title
1358
+ }
1359
+ if let artist = metadata["artist"] as? String {
1360
+ metadataDict["artist"] = artist
1361
+ }
1362
+ if let album = metadata["album"] as? String {
1363
+ metadataDict["album"] = album
1364
+ }
1365
+ if let artworkUrl = metadata["artworkUrl"] as? String {
1366
+ metadataDict["artworkUrl"] = artworkUrl
1367
+ }
1368
+ if !metadataDict.isEmpty {
1369
+ // Store metadata on audioQueue for thread safety
1370
+ audioQueue.sync(flags: .barrier) {
1371
+ notificationMetadataMap[audioId] = metadataDict
1372
+ }
1373
+ }
1374
+ }
1375
+
1376
+ if complex {
1377
+ volume = min(max(call.getFloat("volume") ?? Constant.DefaultVolume, Constant.MinVolume), Constant.MaxVolume)
1378
+ channels = max(call.getInt("channels") ?? Constant.DefaultChannels, 1)
1379
+ } else {
1380
+ channels = Constant.DefaultChannels
1381
+ volume = Constant.DefaultVolume
1382
+ isLocalUrl = false
1383
+ }
1384
+
1385
+ audioQueue.sync(flags: .barrier) { [self] in
1386
+ if audioList.isEmpty {
1387
+ audioList = [:]
1388
+ }
1389
+
1390
+ if audioList[audioId] != nil {
1391
+ call.reject(Constant.ErrorAssetAlreadyLoaded + " - " + audioId)
1392
+ return
1393
+ }
1394
+
1395
+ var basePath: String?
1396
+ if let url = URL(string: assetPath), url.scheme != nil {
1397
+ // Check if it's a local file URL or a remote URL
1398
+ if url.isFileURL {
1399
+ // Handle local file URL
1400
+ let fileURL = url
1401
+ basePath = fileURL.path
1402
+
1403
+ if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
1404
+ let audioAsset = AudioAsset(
1405
+ owner: self,
1406
+ withAssetId: audioId, withPath: basePath, withChannels: channels,
1407
+ withVolume: volume)
1408
+ self.audioList[audioId] = audioAsset
1409
+ call.resolve()
1410
+ return
1411
+ }
1412
+ } else {
1413
+ // Handle remote URL
1414
+ // Extract headers if provided
1415
+ var headers: [String: String]?
1416
+ if let headersObj = call.getObject("headers") {
1417
+ headers = [:]
1418
+ for (key, value) in headersObj {
1419
+ if let stringValue = value as? String {
1420
+ headers?[key] = stringValue
1421
+ }
1422
+ }
1423
+ }
1424
+ let remoteAudioAsset = RemoteAudioAsset(
1425
+ owner: self,
1426
+ withAssetId: audioId,
1427
+ withPath: assetPath,
1428
+ withChannels: channels,
1429
+ withVolume: volume,
1430
+ withHeaders: headers
1431
+ )
1432
+ self.audioList[audioId] = remoteAudioAsset
1433
+ call.resolve()
1434
+ return
1435
+ }
1436
+ } else if isLocalUrl == false {
1437
+ // Handle public folder
1438
+ assetPath = assetPath.starts(with: "public/") ? assetPath : "public/" + assetPath
1439
+ let assetPathSplit = assetPath.components(separatedBy: ".")
1440
+ if assetPathSplit.count >= 2 {
1441
+ basePath = Bundle.main.path(forResource: assetPathSplit[0], ofType: assetPathSplit[1])
1442
+ } else {
1443
+ call.reject("Invalid asset path format: \(assetPath)")
1444
+ return
1445
+ }
1446
+ } else {
1447
+ // Handle local file URL
1448
+ let fileURL = URL(fileURLWithPath: assetPath)
1449
+ basePath = fileURL.path
1450
+ }
1451
+
1452
+ if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
1453
+ if !complex {
1454
+ let soundFileUrl = URL(fileURLWithPath: basePath)
1455
+ var soundId = SystemSoundID()
1456
+ let result = AudioServicesCreateSystemSoundID(soundFileUrl as CFURL, &soundId)
1457
+ if result == kAudioServicesNoError {
1458
+ self.audioList[audioId] = NSNumber(value: Int32(soundId))
1459
+ } else {
1460
+ call.reject("Failed to create system sound: \(result)")
1461
+ return
1462
+ }
1463
+ } else {
1464
+ let audioAsset = AudioAsset(
1465
+ owner: self,
1466
+ withAssetId: audioId, withPath: basePath, withChannels: channels,
1467
+ withVolume: volume)
1468
+ self.audioList[audioId] = audioAsset
1469
+ }
1470
+ } else {
1471
+ if !FileManager.default.fileExists(atPath: assetPath) {
1472
+ call.reject(Constant.ErrorAssetPath + " - " + assetPath)
1473
+ return
1474
+ }
1475
+ // Use the original assetPath
1476
+ if !complex {
1477
+ let soundFileUrl = URL(fileURLWithPath: assetPath)
1478
+ var soundId = SystemSoundID()
1479
+ let result = AudioServicesCreateSystemSoundID(soundFileUrl as CFURL, &soundId)
1480
+ if result == kAudioServicesNoError {
1481
+ self.audioList[audioId] = NSNumber(value: Int32(soundId))
1482
+ } else {
1483
+ call.reject("Failed to create system sound: \(result)")
1484
+ return
1485
+ }
1486
+ } else {
1487
+ let audioAsset = AudioAsset(
1488
+ owner: self,
1489
+ withAssetId: audioId, withPath: assetPath, withChannels: channels,
1490
+ withVolume: volume)
1491
+ self.audioList[audioId] = audioAsset
1492
+ }
1493
+ }
1494
+ call.resolve()
1495
+ }
1496
+ }
1497
+ private func stopAudio(audioId: String, fadeOut: Bool, fadeOutDuration: Double) throws {
1498
+ var asset: AudioAsset?
1499
+
1500
+ // Avoid reentrant sync when already on audio queue (e.g. from stop()) to prevent deadlock
1501
+ if DispatchQueue.getSpecific(key: queueKey) != nil || DispatchQueue.getSpecific(key: audioQueueContextKey) == true {
1502
+ asset = self.audioList[audioId] as? AudioAsset
1503
+ } else {
1504
+ audioQueue.sync {
1505
+ asset = self.audioList[audioId] as? AudioAsset
1506
+ }
1507
+ }
1508
+
1509
+ guard let audioAsset = asset else {
1510
+ throw MyError.runtimeError(Constant.ErrorAssetNotFound)
1511
+ }
1512
+
1513
+ clearAudioAssetData(for: audioId)
1514
+
1515
+ if fadeOut {
1516
+ audioAsset.stopWithFade(fadeOutDuration: fadeOutDuration)
1517
+ } else {
1518
+ audioAsset.stop()
1519
+ }
1520
+ }
1521
+
1522
+ private func clearAudioAssetData(for audioId: String) {
1523
+ audioAssetData[audioId] = nil
1524
+ }
1525
+
1526
+ private func cancelPendingPlay(for audioId: String) {
1527
+ if let task = pendingPlayTasks[audioId] {
1528
+ task.cancel()
1529
+ pendingPlayTasks[audioId] = nil
1530
+ }
1531
+ }
1532
+
1533
+ private func handleFadeOut(for asset: AudioAsset, audioId: String, fadeOutDuration: TimeInterval, fadeOutStartTime: TimeInterval) {
1534
+ let duration = asset.getDuration()
1535
+ if duration <= 0 || !duration.isFinite {
1536
+ logger.warning("Audio asset has no finite duration, skipping fadeOut for %@", audioId)
1537
+ return
1538
+ }
1539
+
1540
+ var startTime = max(duration - fadeOutDuration, 0)
1541
+ if fadeOutStartTime > 0 {
1542
+ startTime = fadeOutStartTime
1543
+ }
1544
+
1545
+ audioAssetData[audioId] = [
1546
+ "fadeOut": true,
1547
+ "fadeOutStartTime": startTime,
1548
+ "fadeOutDuration": fadeOutDuration
1549
+ ]
1550
+ }
1551
+
1552
+ internal func executeOnAudioQueue(_ block: @escaping () -> Void) {
1553
+ if DispatchQueue.getSpecific(key: queueKey) != nil {
1554
+ block() // Already on queue
1555
+ } else {
1556
+ if isRunningTests {
1557
+ audioQueue.async {
1558
+ block()
1559
+ }
1560
+ } else {
1561
+ audioQueue.sync(flags: .barrier) {
1562
+ block()
1563
+ }
1564
+ }
1565
+ }
1566
+ }
1567
+
1568
+ /// Use this for read-only access to shared state — avoids the .barrier write lock
1569
+ /// that `executeOnAudioQueue` applies, preventing deadlocks with third-party SDKs.
1570
+ internal func readOnAudioQueue<T>(_ block: () -> T) -> T {
1571
+ if DispatchQueue.getSpecific(key: queueKey) != nil {
1572
+ return block()
1573
+ }
1574
+ return audioQueue.sync { block() }
1575
+ }
1576
+
1577
+ @objc func notifyCurrentTime(_ asset: AudioAsset) {
1578
+ audioQueue.sync {
1579
+ let rawTime = asset.getCurrentTime()
1580
+ // Round to nearest 100ms (0.1 seconds)
1581
+ let currentTime = round(rawTime * 10) / 10
1582
+ notifyListeners("currentTime", data: [
1583
+ "currentTime": currentTime,
1584
+ "assetId": asset.assetId
1585
+ ])
1586
+
1587
+ if let fadeData = audioAssetData[asset.assetId],
1588
+ let fadeOut = fadeData["fadeOut"] as? Bool, fadeOut,
1589
+ let fadeOutStartTime = fadeData["fadeOutStartTime"] as? Double,
1590
+ let fadeOutDuration = fadeData["fadeOutDuration"] as? Double,
1591
+ currentTime >= fadeOutStartTime {
1592
+ asset.stopWithFade(fadeOutDuration: fadeOutDuration)
1593
+ audioAssetData[asset.assetId] = nil
1594
+ }
1595
+ }
1596
+ }
1597
+
1598
+ @objc func getPluginVersion(_ call: CAPPluginCall) {
1599
+ call.resolve(["version": self.pluginVersion])
1600
+ }
1601
+
1602
+ @objc func deinitPlugin(_ call: CAPPluginCall) {
1603
+ // Stop all playing audio
1604
+ audioQueue.sync(flags: .barrier) {
1605
+ for (_, asset) in self.audioList {
1606
+ if let audioAsset = asset as? AudioAsset {
1607
+ audioAsset.stop()
1608
+ }
1609
+ }
1610
+ }
1611
+
1612
+ // Clear notification center
1613
+ clearNowPlayingInfo()
1614
+
1615
+ // Restore original audio session settings if we changed them
1616
+ if audioSessionInitialized, let originalCategory = originalAudioCategory {
1617
+ do {
1618
+ // Deactivate our audio session
1619
+ try self.session.setActive(false, options: .notifyOthersOnDeactivation)
1620
+
1621
+ // Restore original category and options
1622
+ if let originalOptions = originalAudioOptions {
1623
+ try self.session.setCategory(originalCategory, options: originalOptions)
1624
+ } else {
1625
+ try self.session.setCategory(originalCategory)
1626
+ }
1627
+
1628
+ audioSessionInitialized = false
1629
+ } catch {
1630
+ print("Failed to restore audio session: \(error)")
1631
+ }
1632
+ }
1633
+
1634
+ call.resolve()
1635
+ }
1636
+
1637
+ // swiftlint:disable cyclomatic_complexity
1638
+ /// Updates the system Now Playing information for the specified audio asset.
1639
+ ///
1640
+ /// Looks up stored metadata for `audioId` and publishes title, artist, album, artwork (if provided),
1641
+ /// playback duration, elapsed time, and playback rate to MPNowPlayingInfoCenter. Artwork, when present,
1642
+ /// is loaded asynchronously and applied when available.
1643
+ /// - Parameters:
1644
+ /// - audioId: The asset identifier used to retrieve Now Playing metadata.
1645
+ /// - audioAsset: The audio asset used to obtain current playback time and duration.
1646
+
1647
+ private func updateNowPlayingInfo(audioId: String, audioAsset: AudioAsset) {
1648
+ DispatchQueue.main.async { [weak self] in
1649
+ guard let self = self else { return }
1650
+
1651
+ var nowPlayingInfo = [String: Any]()
1652
+
1653
+ // Get metadata from the map (read on audioQueue for thread safety)
1654
+ let metadata = self.audioQueue.sync { self.notificationMetadataMap[audioId] }
1655
+ if let metadata = metadata {
1656
+ if let title = metadata["title"] {
1657
+ nowPlayingInfo[MPMediaItemPropertyTitle] = title
1658
+ }
1659
+ if let artist = metadata["artist"] {
1660
+ nowPlayingInfo[MPMediaItemPropertyArtist] = artist
1661
+ }
1662
+ if let album = metadata["album"] {
1663
+ nowPlayingInfo[MPMediaItemPropertyAlbumTitle] = album
1664
+ }
1665
+
1666
+ // Load artwork if provided
1667
+ if let artworkUrl = metadata["artworkUrl"] {
1668
+ let targetAudioId = audioId
1669
+ self.loadArtwork(from: artworkUrl) { [weak self] image in
1670
+ guard let self = self, let image = image else { return }
1671
+ self.audioQueue.async { [weak self] in
1672
+ guard let self = self else { return }
1673
+ guard self.currentlyPlayingAssetId == targetAudioId else { return }
1674
+
1675
+ DispatchQueue.main.async { [weak self] in
1676
+ guard let self = self else { return }
1677
+ let stillCurrent = self.audioQueue.sync { self.currentlyPlayingAssetId == targetAudioId }
1678
+ guard stillCurrent else { return }
1679
+
1680
+ var merged = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
1681
+ merged[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in
1682
+ image
1683
+ }
1684
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = merged
1685
+ }
1686
+ }
1687
+ }
1688
+ }
1689
+ }
1690
+
1691
+ // Add playback info
1692
+ nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = audioAsset.getDuration()
1693
+ nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = audioAsset.getCurrentTime()
1694
+ nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = 1.0
1695
+
1696
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
1697
+ }
1698
+ }
1699
+ // swiftlint:enable cyclomatic_complexity
1700
+
1701
+ /// Clears the Now Playing info when the plugin is no longer the active notifier.
1702
+ private func clearNowPlayingInfo() {
1703
+ DispatchQueue.main.async {
1704
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
1705
+ }
1706
+ }
1707
+
1708
+ /// Persists `timeBeforePause` and refreshes Now Playing after fade-out-to-pause completes.
1709
+ internal func recordPausePositionAfterFade(assetId: String, elapsedTime: TimeInterval, duration: TimeInterval) {
1710
+ audioQueue.async { [weak self] in
1711
+ guard let self else { return }
1712
+ var data = self.audioAssetData[assetId] ?? [:]
1713
+ data["timeBeforePause"] = elapsedTime
1714
+ self.audioAssetData[assetId] = data
1715
+ if self.showNotification && self.currentlyPlayingAssetId == assetId {
1716
+ self.updatePlaybackState(isPlaying: false, elapsedTime: elapsedTime, duration: duration)
1717
+ }
1718
+ if let audioAsset = self.audioList[assetId] as? AudioAsset {
1719
+ self.notifyPlaybackState(assetId: assetId, reason: "pause", state: .paused, audioAsset: audioAsset)
1720
+ } else {
1721
+ self.notifyPlaybackState(assetId: assetId, reason: "pause", state: .paused)
1722
+ }
1723
+ }
1724
+ }
1725
+
1726
+ /// Refreshes Now Playing to a stopped state after fade-out-to-stop completes (or zero-volume stop-with-fade).
1727
+ internal func recordStoppedPlaybackStateAfterFade(assetId: String, elapsedTime: TimeInterval, duration: TimeInterval) {
1728
+ audioQueue.async { [weak self] in
1729
+ guard let self else { return }
1730
+ if self.showNotification && self.currentlyPlayingAssetId == assetId {
1731
+ self.updatePlaybackState(isPlaying: false, elapsedTime: elapsedTime, duration: duration)
1732
+ }
1733
+ if let audioAsset = self.audioList[assetId] as? AudioAsset {
1734
+ self.notifyPlaybackState(assetId: assetId, reason: "stop", state: .stopped, audioAsset: audioAsset)
1735
+ } else {
1736
+ self.notifyPlaybackState(assetId: assetId, reason: "stop", state: .stopped)
1737
+ }
1738
+ }
1739
+ }
1740
+
1741
+ private func updatePlaybackState(isPlaying: Bool, elapsedTime: TimeInterval? = nil, duration: TimeInterval? = nil) {
1742
+ DispatchQueue.main.async {
1743
+ var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
1744
+ nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
1745
+ if let elapsed = elapsedTime {
1746
+ nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsed
1747
+ }
1748
+ if let dur = duration {
1749
+ nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = dur
1750
+ }
1751
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
1752
+ }
1753
+ }
1754
+
1755
+ /// Loads an image from a local file path or a remote URL and delivers it to the completion handler.
1756
+ /// - Parameters:
1757
+ /// - urlString: A string representing either a local file path (plain path or `file://` URL) or a remote URL (e.g., `http://` or `https://`).
1758
+ /// - completion: Called with the loaded `UIImage` on success, or `nil` if the image could not be loaded.
1759
+ private func loadArtwork(from urlString: String, completion: @escaping (UIImage?) -> Void) {
1760
+ // Check if it's a local file path or URL
1761
+ if let url = URL(string: urlString) {
1762
+ if url.scheme == nil || url.isFileURL {
1763
+ // Local file
1764
+ let path = url.path
1765
+ if FileManager.default.fileExists(atPath: path) {
1766
+ if let image = UIImage(contentsOfFile: path) {
1767
+ completion(image)
1768
+ return
1769
+ }
1770
+ }
1771
+ } else {
1772
+ // Remote URL
1773
+ URLSession.shared.dataTask(with: url) { data, _, _ in
1774
+ if let data = data, let image = UIImage(data: data) {
1775
+ completion(image)
1776
+ } else {
1777
+ completion(nil)
1778
+ }
1779
+ }.resume()
1780
+ return
1781
+ }
1782
+ }
1783
+ completion(nil)
1784
+ }
1785
+
1786
+ }