@capgo/native-audio 7.11.2 → 8.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.
@@ -20,6 +20,7 @@ public class AudioAsset: NSObject, AVAudioPlayerDelegate {
20
20
  var initialVolume: Float = 1.0
21
21
  var fadeDelay: Float = 1.0
22
22
  weak var owner: NativeAudio?
23
+ var onComplete: (() -> Void)?
23
24
 
24
25
  // Constants for fade effect
25
26
  let FADESTEP: Float = 0.05
@@ -417,10 +418,9 @@ public class AudioAsset: NSObject, AVAudioPlayerDelegate {
417
418
  }
418
419
  }
419
420
 
420
- /**
421
- * Set the playback rate for all audio channels
422
- * - Parameter rate: Playback rate (0.5-2.0 is typical range)
423
- */
421
+ /// Sets the playback rate for every channel, clamping the provided value to the allowed range before applying it.
422
+ /// - Parameters:
423
+ /// - rate: Playback rate multiplier; the value is clamped to the asset's allowed rate range and applied to all channels.
424
424
  func setRate(rate: NSNumber!) {
425
425
  owner?.executeOnAudioQueue { [weak self] in
426
426
  guard let self = self else { return }
@@ -433,9 +433,16 @@ public class AudioAsset: NSObject, AVAudioPlayerDelegate {
433
433
  }
434
434
  }
435
435
 
436
- /**
437
- * AVAudioPlayerDelegate method called when playback finishes
438
- */
436
+ /// Handles an AVAudioPlayer finishing playback by notifying listeners, invoking the public completion callback, and forwarding the completion to the owner.
437
+ ///
438
+ /// This is called when a player finishes playing; the notifications and callback are dispatched on the audio queue. Notifications are sent to listeners with the asset's `assetId`, then `onComplete` is invoked if set, and finally the event is forwarded to the owner.
439
+ /// - Parameters:
440
+ /// - player: The `AVAudioPlayer` instance that finished playback.
441
+ /// Handle an AVAudioPlayer finishing playback by notifying listeners, invoking the optional `onComplete` callback, and forwarding the completion to the owner.
442
+ /// - Note: The handler's work is dispatched on the audio queue.
443
+ /// - Parameters:
444
+ /// - player: The `AVAudioPlayer` instance that finished playback.
445
+ /// - flag: `true` if playback finished successfully, `false` otherwise.
439
446
  public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
440
447
  owner?.executeOnAudioQueue { [weak self] in
441
448
  guard let self = self else { return }
@@ -444,6 +451,9 @@ public class AudioAsset: NSObject, AVAudioPlayerDelegate {
444
451
  "assetId": self.assetId
445
452
  ])
446
453
 
454
+ // Invoke completion callback if set
455
+ self.onComplete?()
456
+
447
457
  // Notify the owner that this player finished
448
458
  // The owner will check if any other assets are still playing
449
459
  owner?.audioPlayerDidFinishPlaying(player, successfully: flag)
@@ -497,6 +507,9 @@ public class AudioAsset: NSObject, AVAudioPlayerDelegate {
497
507
  }
498
508
  }
499
509
 
510
+ /// Stops and clears the periodic current-time update timer, ensuring invalidation occurs on the main thread.
511
+ ///
512
+ /// This method is safe to call from any thread; if not currently on the main thread the invalidation is dispatched to the main queue.
500
513
  internal func stopCurrentTimeUpdates() {
501
514
  if Thread.isMainThread {
502
515
  currentTimeTimer?.invalidate()
@@ -13,12 +13,13 @@ enum MyError: Error {
13
13
  // swiftlint:disable type_body_length file_length
14
14
  @objc(NativeAudio)
15
15
  public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
16
- private let pluginVersion: String = "7.11.2"
16
+ private let pluginVersion: String = "8.1.0"
17
17
  public let identifier = "NativeAudio"
18
18
  public let jsName = "NativeAudio"
19
19
  public let pluginMethods: [CAPPluginMethod] = [
20
20
  CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
21
21
  CAPPluginMethod(name: "preload", returnType: CAPPluginReturnPromise),
22
+ CAPPluginMethod(name: "playOnce", returnType: CAPPluginReturnPromise),
22
23
  CAPPluginMethod(name: "isPreloaded", returnType: CAPPluginReturnPromise),
23
24
  CAPPluginMethod(name: "play", returnType: CAPPluginReturnPromise),
24
25
  CAPPluginMethod(name: "pause", returnType: CAPPluginReturnPromise),
@@ -37,7 +38,10 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
37
38
  CAPPluginMethod(name: "deinitPlugin", returnType: CAPPluginReturnPromise)
38
39
  ]
39
40
  internal let audioQueue = DispatchQueue(label: "ee.forgr.audio.queue", qos: .userInitiated, attributes: .concurrent)
40
- private var audioList: [String: Any] = [:] {
41
+ /// A dictionary that stores audio asset objects by their asset IDs.
42
+ ///
43
+ /// - Important: Must only be accessed within `audioQueue.sync` blocks.
44
+ internal var audioList: [String: Any] = [:] {
41
45
  didSet {
42
46
  // Ensure audioList modifications happen on audioQueue
43
47
  assert(DispatchQueue.getSpecific(key: queueKey) != nil)
@@ -58,9 +62,22 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
58
62
 
59
63
  // Notification center support
60
64
  private var showNotification = false
61
- private var notificationMetadataMap: [String: [String: String]] = [:]
65
+ /// A mapping from asset IDs to their associated notification metadata for media playback.
66
+ ///
67
+ /// - Important: Must only be accessed within `audioQueue.sync` blocks.
68
+ internal var notificationMetadataMap: [String: [String: String]] = [:]
62
69
  private var currentlyPlayingAssetId: String?
63
70
 
71
+ /// Stores the asset IDs for playOnce operations to enable automatic cleanup after playback.
72
+ ///
73
+ /// - Important: Must only be accessed within `audioQueue.sync` blocks.
74
+ internal var playOnceAssets: Set<String> = []
75
+
76
+ /// Initialize plugin state and audio-related handlers, and register background behavior for session management.
77
+ ///
78
+ /// Performs initial plugin setup after the plugin is loaded.
79
+ ///
80
+ /// 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.
64
81
  @objc override public func load() {
65
82
  super.load()
66
83
  audioQueue.setSpecific(key: queueKey, value: true)
@@ -288,6 +305,9 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
288
305
  call.resolve()
289
306
  }
290
307
 
308
+ /// Checks whether an audio asset with the given assetId is currently loaded.
309
+ /// - 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.
310
+ /// - Returns: A dictionary with key `found` set to `true` if the asset is loaded, `false` otherwise.
291
311
  @objc func isPreloaded(_ call: CAPPluginCall) {
292
312
  guard let assetId = call.getString(Constant.AssetIdKey) else {
293
313
  call.reject("Missing assetId")
@@ -301,10 +321,288 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
301
321
  }
302
322
  }
303
323
 
324
+ /// Preloads an audio asset into the plugin's audio cache for full-featured playback.
325
+ ///
326
+ /// 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.
327
+ /// - Parameters:
328
+ /// Preloads an audio asset with advanced playback options for later use.
329
+ ///
330
+ /// 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.
331
+ /// - Parameter call: The CAPPluginCall containing preload options and identifiers.
304
332
  @objc func preload(_ call: CAPPluginCall) {
305
333
  preloadAsset(call, isComplex: true)
306
334
  }
307
335
 
336
+ /// Plays an audio file once with automatic cleanup after completion.
337
+ ///
338
+ /// This is a convenience method that combines preload, play, and unload into a single call.
339
+ /// The audio asset is automatically cleaned up after playback completes or if an error occurs.
340
+ /// Preloads and optionally plays a one-shot audio asset, then removes it from internal storage after completion.
341
+ ///
342
+ /// The method generates a unique temporary asset identifier, loads the asset from a local file, a public bundle resource, or a remote URL (with optional headers), and tracks it as a transient "play-once" asset. If `autoPlay` is true the asset will begin playback immediately and the plugin's audio session will be activated. When playback completes (or when the asset is unloaded), the asset and any associated Now Playing metadata are removed. If `deleteAfterPlay` is true and the source was a local file URL, the file is deleted from disk if it passes safe-sandbox checks.
343
+ ///
344
+ /// - Parameter call: The Capacitor plugin call containing:
345
+ /// - `assetPath`: Path to the audio file (required)
346
+ /// - `volume`: Playback volume 0.1-1.0 (default: 1.0)
347
+ /// - `isUrl`: Whether assetPath is a URL (default: false)
348
+ /// - `autoPlay`: Start playback immediately (default: true)
349
+ /// - `deleteAfterPlay`: Delete file after playback (default: false)
350
+ /// - `notificationMetadata`: Metadata for Now Playing info (optional)
351
+ ///
352
+ /// The call is resolved with `["assetId": "<generated id>"]` on success or rejected with an error message on failure.
353
+ @objc func playOnce(_ call: CAPPluginCall) {
354
+ // Generate unique temporary asset ID
355
+ let assetId = "playOnce_\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(8))"
356
+
357
+ // Extract options
358
+ let assetPath = call.getString(Constant.AssetPathKey) ?? ""
359
+ let autoPlay = call.getBool("autoPlay") ?? true
360
+ let deleteAfterPlay = call.getBool("deleteAfterPlay") ?? false
361
+ let volume = min(max(call.getFloat("volume") ?? Constant.DefaultVolume, Constant.MinVolume), Constant.MaxVolume)
362
+ let isLocalUrl = call.getBool("isUrl") ?? false
363
+
364
+ if assetPath == "" {
365
+ call.reject(Constant.ErrorAssetPath)
366
+ return
367
+ }
368
+
369
+ // Parse notification metadata if provided (on main thread)
370
+ var metadataDict: [String: String]?
371
+ if let metadata = call.getObject(Constant.NotificationMetadata) {
372
+ var tempDict: [String: String] = [:]
373
+ if let title = metadata["title"] as? String {
374
+ tempDict["title"] = title
375
+ }
376
+ if let artist = metadata["artist"] as? String {
377
+ tempDict["artist"] = artist
378
+ }
379
+ if let album = metadata["album"] as? String {
380
+ tempDict["album"] = album
381
+ }
382
+ if let artworkUrl = metadata["artworkUrl"] as? String {
383
+ tempDict["artworkUrl"] = artworkUrl
384
+ }
385
+ if !tempDict.isEmpty {
386
+ metadataDict = tempDict
387
+ }
388
+ }
389
+
390
+ // Ensure audio session is initialized
391
+ if !audioSessionInitialized {
392
+ setupAudioSession()
393
+ }
394
+
395
+ // Track this as a playOnce asset and store metadata (thread-safe)
396
+ audioQueue.sync(flags: .barrier) {
397
+ self.playOnceAssets.insert(assetId)
398
+ if let metadata = metadataDict {
399
+ self.notificationMetadataMap[assetId] = metadata
400
+ }
401
+ }
402
+
403
+ // Create a completion handler for cleanup
404
+ let cleanupHandler: () -> Void = { [weak self] in
405
+ guard let self = self else { return }
406
+
407
+ self.audioQueue.async(flags: .barrier) {
408
+ guard let asset = self.audioList[assetId] as? AudioAsset else { return }
409
+
410
+ // Get the file path before unloading if we need to delete
411
+ // Only delete if it's a local file:// URL, not remote streaming URLs
412
+ var filePathToDelete: String?
413
+ if deleteAfterPlay {
414
+ if let url = asset.channels.first?.url, url.isFileURL {
415
+ filePathToDelete = url.path
416
+ }
417
+ }
418
+
419
+ // Unload the asset
420
+ asset.unload()
421
+ self.audioList[assetId] = nil
422
+ self.playOnceAssets.remove(assetId)
423
+ self.notificationMetadataMap.removeValue(forKey: assetId)
424
+
425
+ // Clear notification if this was the currently playing asset
426
+ if self.currentlyPlayingAssetId == assetId {
427
+ self.clearNowPlayingInfo()
428
+ self.currentlyPlayingAssetId = nil
429
+ }
430
+
431
+ // Delete file if requested and it's a local file
432
+ if let filePath = filePathToDelete {
433
+ let fileManager = FileManager.default
434
+ let resolvedPath: String
435
+ if filePath.hasPrefix("file://") {
436
+ resolvedPath = URL(string: filePath)?.path ?? filePath
437
+ } else {
438
+ resolvedPath = filePath
439
+ }
440
+
441
+ do {
442
+ if fileManager.fileExists(atPath: resolvedPath) {
443
+ try fileManager.removeItem(atPath: resolvedPath)
444
+ print("Deleted file after playOnce: \(resolvedPath)")
445
+ }
446
+ } catch {
447
+ print("Error deleting file after playOnce: \(error.localizedDescription)")
448
+ }
449
+ }
450
+ }
451
+ }
452
+
453
+ /// Cleans up tracking data when playOnce fails to prevent memory leaks.
454
+ ///
455
+ /// Removes the asset ID from both playOnceAssets set and notificationMetadataMap
456
+ /// to ensure proper cleanup when an error occurs during playOnce execution.
457
+ ///
458
+ /// Removes transient tracking for a one-off playback asset and its associated notification metadata.
459
+ /// Remove tracking and Now Playing metadata for a play-once asset after a failed load or playback.
460
+ /// - Parameter assetId: The asset identifier to remove from play-once tracking and notification metadata.
461
+ func cleanupOnFailure(assetId: String) {
462
+ self.playOnceAssets.remove(assetId)
463
+ self.notificationMetadataMap.removeValue(forKey: assetId)
464
+ }
465
+
466
+ // Inline preload logic directly (avoid creating mock PluginCall)
467
+ audioQueue.async(flags: .barrier) { [weak self] in
468
+ guard let self = self else { return }
469
+
470
+ // Check if asset already exists
471
+ if self.audioList[assetId] != nil {
472
+ cleanupOnFailure(assetId: assetId)
473
+ call.reject(Constant.ErrorAssetAlreadyLoaded + " - " + assetId)
474
+ return
475
+ }
476
+
477
+ var basePath: String?
478
+
479
+ if let url = URL(string: assetPath), url.scheme != nil {
480
+ // Check if it's a local file URL or a remote URL
481
+ if url.isFileURL {
482
+ // Handle local file URL
483
+ basePath = url.path
484
+
485
+ if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
486
+ let audioAsset = AudioAsset(
487
+ owner: self,
488
+ withAssetId: assetId,
489
+ withPath: basePath,
490
+ withChannels: 1,
491
+ withVolume: volume,
492
+ withFadeDelay: 0.5
493
+ )
494
+ self.audioList[assetId] = audioAsset
495
+ } else {
496
+ cleanupOnFailure(assetId: assetId)
497
+ call.reject(Constant.ErrorAssetPath + " - " + assetPath)
498
+ return
499
+ }
500
+ } else {
501
+ // Handle remote URL
502
+ var headers: [String: String]?
503
+ if let headersObj = call.getObject("headers") {
504
+ headers = [:]
505
+ for (key, value) in headersObj {
506
+ if let stringValue = value as? String {
507
+ headers?[key] = stringValue
508
+ }
509
+ }
510
+ }
511
+ let remoteAudioAsset = RemoteAudioAsset(
512
+ owner: self,
513
+ withAssetId: assetId,
514
+ withPath: assetPath,
515
+ withChannels: 1,
516
+ withVolume: volume,
517
+ withFadeDelay: 0.5,
518
+ withHeaders: headers
519
+ )
520
+ self.audioList[assetId] = remoteAudioAsset
521
+ }
522
+ } else if !isLocalUrl {
523
+ // Handle public folder
524
+ let publicAssetPath = assetPath.starts(with: "public/") ? assetPath : "public/" + assetPath
525
+ let assetPathSplit = publicAssetPath.components(separatedBy: ".")
526
+ if assetPathSplit.count >= 2 {
527
+ basePath = Bundle.main.path(forResource: assetPathSplit[0], ofType: assetPathSplit[1])
528
+ } else {
529
+ cleanupOnFailure(assetId: assetId)
530
+ call.reject("Invalid asset path format: \(assetPath)")
531
+ return
532
+ }
533
+
534
+ if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
535
+ let audioAsset = AudioAsset(
536
+ owner: self,
537
+ withAssetId: assetId,
538
+ withPath: basePath,
539
+ withChannels: 1,
540
+ withVolume: volume,
541
+ withFadeDelay: 0.5
542
+ )
543
+ self.audioList[assetId] = audioAsset
544
+ } else {
545
+ cleanupOnFailure(assetId: assetId)
546
+ call.reject(Constant.ErrorAssetPath + " - " + assetPath)
547
+ return
548
+ }
549
+ } else {
550
+ // Handle local file path
551
+ let fileURL = URL(fileURLWithPath: assetPath)
552
+ basePath = fileURL.path
553
+
554
+ if let basePath = basePath, FileManager.default.fileExists(atPath: basePath) {
555
+ let audioAsset = AudioAsset(
556
+ owner: self,
557
+ withAssetId: assetId,
558
+ withPath: basePath,
559
+ withChannels: 1,
560
+ withVolume: volume,
561
+ withFadeDelay: 0.5
562
+ )
563
+ self.audioList[assetId] = audioAsset
564
+ } else {
565
+ cleanupOnFailure(assetId: assetId)
566
+ call.reject(Constant.ErrorAssetPath + " - " + assetPath)
567
+ return
568
+ }
569
+ }
570
+
571
+ // Get the loaded asset
572
+ guard let asset = self.audioList[assetId] as? AudioAsset else {
573
+ // Cleanup on failure
574
+ cleanupOnFailure(assetId: assetId)
575
+ call.reject("Failed to load asset for playOnce")
576
+ return
577
+ }
578
+
579
+ // Set up completion handler
580
+ asset.onComplete = { [weak self] in
581
+ cleanupHandler()
582
+ }
583
+
584
+ // Auto-play if requested
585
+ if autoPlay {
586
+ self.activateSession()
587
+ asset.play(time: 0, delay: 0)
588
+
589
+ // Update notification center if enabled
590
+ if self.showNotification {
591
+ self.currentlyPlayingAssetId = assetId
592
+ self.updateNowPlayingInfo(audioId: assetId, audioAsset: asset)
593
+ self.updatePlaybackState(isPlaying: true)
594
+ }
595
+ }
596
+
597
+ // Return the generated assetId
598
+ call.resolve(["assetId": assetId])
599
+ }
600
+ }
601
+
602
+ /// Activates the app's audio session when no other audio is playing.
603
+ /// Activate the shared AVAudioSession when no other audio is playing.
604
+ ///
605
+ /// If the system reports other audio is playing, the session is left inactive. On failure to activate, the error is printed to the console.
308
606
  func activateSession() {
309
607
  do {
310
608
  // Only activate if not already active
@@ -493,6 +791,9 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
493
791
  }
494
792
  }
495
793
 
794
+ /// Stops playback of the audio asset identified by `assetId` from the plugin call and performs related cleanup.
795
+ ///
796
+ /// The `assetId` is read from the call using `Constant.AssetIdKey`. If the asset is currently playing it will be stopped; if `showNotification` is enabled the Now Playing info is cleared and `currentlyPlayingAssetId` is reset. 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.
496
797
  @objc func stop(_ call: CAPPluginCall) {
497
798
  let audioId = call.getString(Constant.AssetIdKey) ?? ""
498
799
 
@@ -511,6 +812,12 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
511
812
  self.currentlyPlayingAssetId = nil
512
813
  }
513
814
 
815
+ // Clean up playOnce tracking if this was a playOnce asset
816
+ if self.playOnceAssets.contains(audioId) {
817
+ self.playOnceAssets.remove(audioId)
818
+ self.notificationMetadataMap.removeValue(forKey: audioId)
819
+ }
820
+
514
821
  self.endSession()
515
822
  call.resolve()
516
823
  } catch {
@@ -531,6 +838,9 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
531
838
  }
532
839
  }
533
840
 
841
+ /// Unloads a previously loaded audio asset identified by `assetId` and removes any associated one-shot tracking or metadata.
842
+ /// - Parameters:
843
+ /// - 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).
534
844
  @objc func unload(_ call: CAPPluginCall) {
535
845
  let audioId = call.getString(Constant.AssetIdKey) ?? ""
536
846
 
@@ -543,11 +853,25 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
543
853
  if let asset = self.audioList[audioId] as? AudioAsset {
544
854
  asset.unload()
545
855
  self.audioList[audioId] = nil
856
+
857
+ // Clean up playOnce tracking if this was a playOnce asset
858
+ if self.playOnceAssets.contains(audioId) {
859
+ self.playOnceAssets.remove(audioId)
860
+ self.notificationMetadataMap.removeValue(forKey: audioId)
861
+ }
862
+
546
863
  call.resolve()
547
864
  } else if let audioNumber = self.audioList[audioId] as? NSNumber {
548
865
  // Also handle unloading system sounds
549
866
  AudioServicesDisposeSystemSoundID(SystemSoundID(audioNumber.intValue))
550
867
  self.audioList[audioId] = nil
868
+
869
+ // Clean up playOnce tracking if this was a playOnce asset
870
+ if self.playOnceAssets.contains(audioId) {
871
+ self.playOnceAssets.remove(audioId)
872
+ self.notificationMetadataMap.removeValue(forKey: audioId)
873
+ }
874
+
551
875
  call.resolve()
552
876
  } else {
553
877
  call.reject("Cannot cast to AudioAsset")
@@ -601,7 +925,23 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
601
925
  }
602
926
  }
603
927
 
604
- // swiftlint:disable cyclomatic_complexity function_body_length
928
+ /// Preloads an audio asset into the plugin's internal registry for later playback.
929
+ ///
930
+ /// Accepts a CAPPluginCall containing asset information, validates inputs, stores optional now‑playing metadata, and creates either a lightweight system sound (for non-complex assets) or a full AudioAsset/RemoteAudioAsset (for complex assets). Supports local file paths, file URLs, public bundle resources, and remote URLs (with optional headers).
931
+ /// - Parameters:
932
+ /// - call: CAPPluginCall containing required keys:
933
+ /// - "assetId" (String): unique identifier for the asset.
934
+ /// - "assetPath" (String): local path, file URL, public bundle resource, or remote URL.
935
+ /// - "isUrl" (Bool, optional): treat the provided path as a raw URL when false/omitted for non-complex loads; ignored for complex loads.
936
+ /// - For complex loads:
937
+ /// - "volume" (Float, optional): initial volume (clamped to valid range).
938
+ /// - "channels" (Int, optional): number of audio channels.
939
+ /// - "delay" (Float, optional): fade delay.
940
+ /// - For remote URLs:
941
+ /// - "headers" (Object, optional): HTTP headers to use when loading the remote asset.
942
+ /// - "notificationMetadata" (Object, optional): now‑playing metadata with keys "title", "artist", "album", and "artworkUrl".
943
+ /// - isComplex: If true, creates a full-featured AudioAsset/RemoteAudioAsset; if false, creates a lightweight system sound identifier.
944
+ /// - Behavior: Resolves the provided call on successful preload; rejects the call with an error message if validation fails or the asset cannot be created.
605
945
  @objc private func preloadAsset(_ call: CAPPluginCall, isComplex complex: Bool) {
606
946
  // Common default values to ensure consistency
607
947
  let audioId = call.getString(Constant.AssetIdKey) ?? ""
@@ -637,7 +977,10 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
637
977
  metadataDict["artworkUrl"] = artworkUrl
638
978
  }
639
979
  if !metadataDict.isEmpty {
640
- notificationMetadataMap[audioId] = metadataDict
980
+ // Store metadata on audioQueue for thread safety
981
+ audioQueue.sync(flags: .barrier) {
982
+ notificationMetadataMap[audioId] = metadataDict
983
+ }
641
984
  }
642
985
  }
643
986
 
@@ -838,7 +1181,14 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
838
1181
  call.resolve()
839
1182
  }
840
1183
 
841
- // MARK: - Now Playing Info Methods
1184
+ /// Updates the system Now Playing information for the specified audio asset.
1185
+ ///
1186
+ /// Looks up stored metadata for `audioId` and publishes title, artist, album, artwork (if provided),
1187
+ /// playback duration, elapsed time, and playback rate to MPNowPlayingInfoCenter. Artwork, when present,
1188
+ /// is loaded asynchronously and applied when available.
1189
+ /// - Parameters:
1190
+ /// - audioId: The asset identifier used to retrieve Now Playing metadata.
1191
+ /// - audioAsset: The audio asset used to obtain current playback time and duration.
842
1192
 
843
1193
  private func updateNowPlayingInfo(audioId: String, audioAsset: AudioAsset) {
844
1194
  DispatchQueue.main.async { [weak self] in
@@ -846,8 +1196,9 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
846
1196
 
847
1197
  var nowPlayingInfo = [String: Any]()
848
1198
 
849
- // Get metadata from the map
850
- if let metadata = self.notificationMetadataMap[audioId] {
1199
+ // Get metadata from the map (read on audioQueue for thread safety)
1200
+ let metadata = self.audioQueue.sync { self.notificationMetadataMap[audioId] }
1201
+ if let metadata = metadata {
851
1202
  if let title = metadata["title"] {
852
1203
  nowPlayingInfo[MPMediaItemPropertyTitle] = title
853
1204
  }
@@ -894,6 +1245,10 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
894
1245
  }
895
1246
  }
896
1247
 
1248
+ /// Loads an image from a local file path or a remote URL and delivers it to the completion handler.
1249
+ /// - Parameters:
1250
+ /// - urlString: A string representing either a local file path (plain path or `file://` URL) or a remote URL (e.g., `http://` or `https://`).
1251
+ /// - completion: Called with the loaded `UIImage` on success, or `nil` if the image could not be loaded.
897
1252
  private func loadArtwork(from urlString: String, completion: @escaping (UIImage?) -> Void) {
898
1253
  // Check if it's a local file path or URL
899
1254
  if let url = URL(string: urlString) {
@@ -93,6 +93,11 @@ public class RemoteAudioAsset: AudioAsset {
93
93
  notificationObservers = []
94
94
  }
95
95
 
96
+ /// Notifies listeners that this asset finished playing and invokes the optional completion callback if set.
97
+ ///
98
+ /// Handle a player's end-of-playback by notifying listeners and invoking the optional completion callback.
99
+ /// Dispatches the notification on the owner's audio queue and sends a `complete` event containing the assetId.
100
+ /// - Parameter player: The `AVPlayer` instance that finished playback.
96
101
  func playerDidFinishPlaying(player: AVPlayer) {
97
102
  owner?.executeOnAudioQueue { [weak self] in
98
103
  guard let self = self else { return }
@@ -100,6 +105,9 @@ public class RemoteAudioAsset: AudioAsset {
100
105
  self.owner?.notifyListeners("complete", data: [
101
106
  "assetId": self.assetId
102
107
  ])
108
+
109
+ // Invoke completion callback if set
110
+ self.onComplete?()
103
111
  }
104
112
  }
105
113
 
@@ -176,6 +184,11 @@ public class RemoteAudioAsset: AudioAsset {
176
184
  }
177
185
  }
178
186
 
187
+ /// Stops playback on all player channels and resets them to the beginning.
188
+ ///
189
+ /// Stops periodic current-time updates, pauses each `AVPlayer`, seeks each player to time zero,
190
+ /// sets each player's `actionAtItemEnd` to `.pause`, and resets `playIndex` to `0`.
191
+ /// The operations are dispatched on the owner's audio queue.
179
192
  override func stop() {
180
193
  owner?.executeOnAudioQueue { [weak self] in
181
194
  guard let self = self else { return }
@@ -196,6 +209,11 @@ public class RemoteAudioAsset: AudioAsset {
196
209
  }
197
210
  }
198
211
 
212
+ /// Configures all player channels to loop and starts playback for the current channel.
213
+ ///
214
+ /// Configures all player channels to loop playback and starts playback on the current channel.
215
+ ///
216
+ /// Cleans existing end-of-playback observers, sets each player's end action to `.none`, and registers observers that seek the finished item back to the start and resume playback when it reaches its end. Seeks and starts the player at `playIndex`, and starts periodic current-time updates. This work is performed on the owner's audio queue.
199
217
  override func loop() {
200
218
  owner?.executeOnAudioQueue { [weak self] in
201
219
  guard let self = self else { return }
@@ -232,7 +250,12 @@ public class RemoteAudioAsset: AudioAsset {
232
250
  }
233
251
  }
234
252
 
235
- private func cleanupNotificationObservers() {
253
+ /// Removes all NotificationCenter observers tracked by this asset and clears the internal observer list.
254
+ ///
255
+ /// Removes all NotificationCenter observers tracked by this instance and clears the internal observer list.
256
+ ///
257
+ /// This unregisters each observer previously added to NotificationCenter.default and resets `notificationObservers` to an empty array.
258
+ internal func cleanupNotificationObservers() {
236
259
  for observer in notificationObservers {
237
260
  NotificationCenter.default.removeObserver(observer)
238
261
  }
@@ -416,7 +439,13 @@ public class RemoteAudioAsset: AudioAsset {
416
439
  }
417
440
  }
418
441
 
419
- // Add helper method for parent class
442
+ /// Gradually adjusts the given player's volume from a start level to an end level over the configured fade duration.
443
+ ///
444
+ /// The method stops any existing fade timer, sets the player's volume to `startVolume`, and schedules a `Timer` on the main run loop that increments the volume in steps determined by `FADESTEP` and `FADEDELAY`. When the ramp completes the player's volume is set to `endVolume` and the internal `fadeTimer` reference is cleared.
445
+ /// - Parameters:
446
+ /// - startVolume: The initial volume level to apply before beginning the ramp (typically 0.0–1.0).
447
+ /// - endVolume: The target volume level to reach at the end of the ramp (typically 0.0–1.0).
448
+ /// - player: The `AVPlayer` whose `volume` property will be adjusted.
420
449
  private func startVolumeRamp(from startVolume: Float, to endVolume: Float, player: AVPlayer) {
421
450
  player.volume = startVolume
422
451