@capgo/native-audio 8.2.11 → 8.2.13

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.
@@ -8,11 +8,19 @@
8
8
 
9
9
  public class Constant {
10
10
  // Parameter keys
11
- public static let FadeKey = "fade"
11
+ public static let FadeKey = "fade" // legacy global fade toggle
12
+ public static let FadeIn = "fadeIn"
13
+ public static let FadeOut = "fadeOut"
14
+ public static let FadeInDuration = "fadeInDuration"
15
+ public static let FadeOutDuration = "fadeOutDuration"
16
+ public static let FadeOutStartTime = "fadeOutStartTime"
17
+ public static let FadeDuration = "duration"
12
18
  public static let FocusAudio = "focus"
13
- public static let AssetPathKey = "assetPath"
14
- public static let AssetIdKey = "assetId"
19
+ public static let AssetPath = "assetPath"
20
+ public static let AssetId = "assetId"
15
21
  public static let Volume = "volume"
22
+ public static let Time = "time"
23
+ public static let Delay = "delay"
16
24
  public static let Rate = "rate"
17
25
  public static let Loop = "loop"
18
26
  public static let Background = "background"
@@ -24,7 +32,7 @@ public class Constant {
24
32
  public static let DefaultVolume: Float = 1.0
25
33
  public static let DefaultRate: Float = 1.0
26
34
  public static let DefaultChannels: Int = 1
27
- public static let DefaultFadeDelay: Float = 1.0
35
+ public static let DefaultFadeDuration: Float = 1.0
28
36
  public static let MinRate: Float = 0.25
29
37
  public static let MaxRate: Float = 4.0
30
38
  public static let MinVolume: Float = 0.0
@@ -36,4 +44,9 @@ public class Constant {
36
44
  public static let ErrorAssetPath = "Asset Path is missing"
37
45
  public static let ErrorAssetNotFound = "Asset is not loaded"
38
46
  public static let ErrorAssetAlreadyLoaded = "Asset is already loaded"
47
+
48
+ // Backward compatibility aliases
49
+ public static let AssetPathKey = AssetPath
50
+ public static let AssetIdKey = AssetId
51
+ public static let DefaultFadeDelay = DefaultFadeDuration
39
52
  }
@@ -0,0 +1,43 @@
1
+ import Foundation
2
+ import os.log
3
+
4
+ public class Logger {
5
+ private var osLogger: OSLog?
6
+
7
+ public static var debugModeEnabled = false
8
+
9
+ public init(logTag: String) {
10
+ osLogger = OSLog(subsystem: Bundle.main.bundleIdentifier ?? "com.capgo.native-audio", category: logTag)
11
+ }
12
+
13
+ func error(_ message: String, _ args: CVarArg...) {
14
+ osLog(message, args, level: .error)
15
+ }
16
+
17
+ func warning(_ message: String, _ args: CVarArg...) {
18
+ osLog(message, args, level: .fault)
19
+ }
20
+
21
+ func info(_ message: String, _ args: CVarArg...) {
22
+ osLog(message, args, level: .info)
23
+ }
24
+
25
+ func debug(_ message: String, _ args: CVarArg...) {
26
+ osLog(message, args, level: .debug)
27
+ }
28
+
29
+ func verbose(_ message: String, _ args: CVarArg...) {
30
+ osLog(message, args, level: .default)
31
+ }
32
+
33
+ private func osLog(_ message: String, _ args: [CVarArg], level: OSLogType = .default) {
34
+ if !Logger.debugModeEnabled {
35
+ return
36
+ }
37
+ let formatted = String(format: message, arguments: args)
38
+ guard let osLogger else {
39
+ return
40
+ }
41
+ os_log("%{public}@", log: osLogger, type: level, formatted)
42
+ }
43
+ }
@@ -8,15 +8,15 @@ enum MyError: Error {
8
8
  case runtimeError(String)
9
9
  }
10
10
 
11
- /// Please read the Capacitor iOS Plugin Development Guide
12
- /// here: https://capacitor.ionicframework.com/docs/plugins/ios
13
- // swiftlint:disable type_body_length file_length
11
+ // swiftlint:disable file_length
14
12
  @objc(NativeAudio)
13
+ // swiftlint:disable:next type_body_length
15
14
  public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
16
- private let pluginVersion: String = "8.2.11"
15
+ private let pluginVersion: String = "8.2.13"
17
16
  public let identifier = "NativeAudio"
18
17
  public let jsName = "NativeAudio"
19
18
  public let pluginMethods: [CAPPluginMethod] = [
19
+ CAPPluginMethod(name: "setDebugMode", returnType: CAPPluginReturnPromise),
20
20
  CAPPluginMethod(name: "configure", returnType: CAPPluginReturnPromise),
21
21
  CAPPluginMethod(name: "preload", returnType: CAPPluginReturnPromise),
22
22
  CAPPluginMethod(name: "playOnce", returnType: CAPPluginReturnPromise),
@@ -37,6 +37,7 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
37
37
  CAPPluginMethod(name: "getPluginVersion", returnType: CAPPluginReturnPromise),
38
38
  CAPPluginMethod(name: "deinitPlugin", returnType: CAPPluginReturnPromise)
39
39
  ]
40
+ private var logger = Logger(logTag: "NativeAudio")
40
41
  internal let audioQueue = DispatchQueue(label: "ee.forgr.audio.queue", qos: .userInitiated, attributes: .concurrent)
41
42
  /// A dictionary that stores audio asset objects by their asset IDs.
42
43
  ///
@@ -48,7 +49,6 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
48
49
  }
49
50
  }
50
51
  private let queueKey = DispatchSpecificKey<Bool>()
51
- var fadeMusic = false
52
52
  var session = AVAudioSession.sharedInstance()
53
53
 
54
54
  // Track if audio session has been initialized
@@ -73,6 +73,10 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
73
73
  /// - Important: Must only be accessed within `audioQueue.sync` blocks.
74
74
  internal var playOnceAssets: Set<String> = []
75
75
 
76
+ private var pendingPlayTasks: [String: DispatchWorkItem] = [:]
77
+ private var audioAssetData: [String: [String: Any]] = [:]
78
+ var isRunningTests = false
79
+
76
80
  /// Initialize plugin state and audio-related handlers, and register background behavior for session management.
77
81
  ///
78
82
  /// Performs initial plugin setup after the plugin is loaded.
@@ -82,8 +86,6 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
82
86
  super.load()
83
87
  audioQueue.setSpecific(key: queueKey, value: true)
84
88
 
85
- self.fadeMusic = false
86
-
87
89
  // Don't setup audio session on load - defer until first use
88
90
  // setupAudioSession()
89
91
  setupInterruptionHandling()
@@ -251,6 +253,15 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
251
253
  }
252
254
  // swiftlint:enable function_body_length
253
255
 
256
+ @objc func setDebugMode(_ call: CAPPluginCall) {
257
+ let debug = call.getBool("enabled") ?? false
258
+ Logger.debugModeEnabled = debug
259
+ if debug {
260
+ logger.info("Debug mode enabled")
261
+ }
262
+ call.resolve()
263
+ }
264
+
254
265
  @objc func configure(_ call: CAPPluginCall) {
255
266
  // Save original category on first configure call
256
267
  if !audioSessionInitialized {
@@ -259,15 +270,13 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
259
270
  audioSessionInitialized = true
260
271
  }
261
272
 
262
- if let fade = call.getBool(Constant.FadeKey) {
263
- self.fadeMusic = fade
264
- }
265
-
266
273
  let focus = call.getBool(Constant.FocusAudio) ?? false
267
274
  let background = call.getBool(Constant.Background) ?? false
268
275
  let ignoreSilent = call.getBool(Constant.IgnoreSilent) ?? true
269
276
  self.showNotification = call.getBool(Constant.ShowNotification) ?? false
270
277
 
278
+ logger.info("Configuring audio session with focus=%@ background=%@ ignoreSilent=%@", "\(focus)", "\(background)", "\(ignoreSilent)")
279
+
271
280
  // Use a single audio session configuration block for better atomicity
272
281
  do {
273
282
  // Set category first
@@ -299,7 +308,7 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
299
308
  }
300
309
 
301
310
  } catch {
302
- print("Failed to configure audio session: \(error)")
311
+ logger.error("Failed to configure audio session: %@", error.localizedDescription)
303
312
  }
304
313
 
305
314
  call.resolve()
@@ -333,23 +342,7 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
333
342
  preloadAsset(call, isComplex: true)
334
343
  }
335
344
 
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.
345
+ // swiftlint:disable:next cyclomatic_complexity function_body_length
353
346
  @objc func playOnce(_ call: CAPPluginCall) {
354
347
  // Generate unique temporary asset ID
355
348
  let assetId = "playOnce_\(Int(Date().timeIntervalSince1970 * 1000))_\(UUID().uuidString.prefix(8))"
@@ -488,8 +481,7 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
488
481
  withAssetId: assetId,
489
482
  withPath: basePath,
490
483
  withChannels: 1,
491
- withVolume: volume,
492
- withFadeDelay: 0.5
484
+ withVolume: volume
493
485
  )
494
486
  self.audioList[assetId] = audioAsset
495
487
  } else {
@@ -514,7 +506,6 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
514
506
  withPath: assetPath,
515
507
  withChannels: 1,
516
508
  withVolume: volume,
517
- withFadeDelay: 0.5,
518
509
  withHeaders: headers
519
510
  )
520
511
  self.audioList[assetId] = remoteAudioAsset
@@ -537,8 +528,7 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
537
528
  withAssetId: assetId,
538
529
  withPath: basePath,
539
530
  withChannels: 1,
540
- withVolume: volume,
541
- withFadeDelay: 0.5
531
+ withVolume: volume
542
532
  )
543
533
  self.audioList[assetId] = audioAsset
544
534
  } else {
@@ -557,8 +547,7 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
557
547
  withAssetId: assetId,
558
548
  withPath: basePath,
559
549
  withChannels: 1,
560
- withVolume: volume,
561
- withFadeDelay: 0.5
550
+ withVolume: volume
562
551
  )
563
552
  self.audioList[assetId] = audioAsset
564
553
  } else {
@@ -584,7 +573,7 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
584
573
  // Auto-play if requested
585
574
  if autoPlay {
586
575
  self.activateSession()
587
- asset.play(time: 0, delay: 0)
576
+ asset.play(time: 0, volume: nil)
588
577
 
589
578
  // Update notification center if enabled
590
579
  if self.showNotification {
@@ -658,10 +647,17 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
658
647
  }
659
648
  }
660
649
 
650
+ // swiftlint:disable:next function_body_length
661
651
  @objc func play(_ call: CAPPluginCall) {
662
652
  let audioId = call.getString(Constant.AssetIdKey) ?? ""
663
- let time = max(call.getDouble("time") ?? 0, 0) // Ensure non-negative time
664
- let delay = max(call.getDouble("delay") ?? 0, 0) // Ensure non-negative delay
653
+ let time = max(call.getDouble(Constant.Time) ?? 0, 0)
654
+ let delay = max(call.getDouble(Constant.Delay) ?? 0, 0)
655
+ let volume = call.getFloat(Constant.Volume)
656
+ let fadeIn = call.getBool(Constant.FadeIn) ?? false
657
+ let fadeOut = call.getBool(Constant.FadeOut) ?? false
658
+ let fadeInDuration = call.getDouble(Constant.FadeInDuration) ?? Double(Constant.DefaultFadeDuration)
659
+ let fadeOutDuration = call.getDouble(Constant.FadeOutDuration) ?? Double(Constant.DefaultFadeDuration)
660
+ let fadeOutStartTime = call.getDouble(Constant.FadeOutStartTime) ?? 0.0
665
661
 
666
662
  // Ensure audio session is initialized before first play
667
663
  if !audioSessionInitialized {
@@ -682,20 +678,44 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
682
678
 
683
679
  if let audioAsset = asset as? AudioAsset {
684
680
  self.activateSession()
685
- if self.fadeMusic {
686
- audioAsset.playWithFade(time: time)
687
- } else {
688
- audioAsset.play(time: time, delay: delay)
689
- }
681
+ cancelPendingPlay(for: audioId)
682
+ clearAudioAssetData(for: audioId)
683
+
684
+ let playBlock = { [weak self] in
685
+ guard let self else { return }
686
+ self.executeOnAudioQueue {
687
+ if fadeIn {
688
+ audioAsset.playWithFade(time: time, volume: volume, fadeInDuration: fadeInDuration)
689
+ } else {
690
+ audioAsset.play(time: time, volume: volume)
691
+ }
692
+ self.pendingPlayTasks[audioId] = nil
693
+
694
+ if fadeOut {
695
+ self.handleFadeOut(
696
+ for: audioAsset,
697
+ audioId: audioId,
698
+ fadeOutDuration: fadeOutDuration,
699
+ fadeOutStartTime: fadeOutStartTime
700
+ )
701
+ }
690
702
 
691
- // Update notification center if enabled
692
- if self.showNotification {
693
- self.currentlyPlayingAssetId = audioId
694
- self.updateNowPlayingInfo(audioId: audioId, audioAsset: audioAsset)
695
- self.updatePlaybackState(isPlaying: true)
703
+ if self.showNotification {
704
+ self.currentlyPlayingAssetId = audioId
705
+ self.updateNowPlayingInfo(audioId: audioId, audioAsset: audioAsset)
706
+ self.updatePlaybackState(isPlaying: true)
707
+ }
708
+ call.resolve()
709
+ }
696
710
  }
697
711
 
698
- call.resolve()
712
+ if delay > 0 {
713
+ let workItem = DispatchWorkItem(block: playBlock)
714
+ pendingPlayTasks[audioId] = workItem
715
+ DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
716
+ } else {
717
+ playBlock()
718
+ }
699
719
  } else if let audioNumber = asset as? NSNumber {
700
720
  self.activateSession()
701
721
  AudioServicesPlaySystemSound(SystemSoundID(audioNumber.intValue))
@@ -722,7 +742,9 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
722
742
  return
723
743
  }
724
744
 
725
- let time = max(call.getDouble("time") ?? 0, 0) // Ensure non-negative time
745
+ cancelPendingPlay(for: audioAsset.assetId)
746
+ clearAudioAssetData(for: audioAsset.assetId)
747
+ let time = max(call.getDouble(Constant.Time) ?? 0, 0)
726
748
  audioAsset.setCurrentTime(time: time)
727
749
  call.resolve()
728
750
  }
@@ -761,7 +783,27 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
761
783
  return
762
784
  }
763
785
  self.activateSession()
764
- audioAsset.resume()
786
+ let fadeIn = call.getBool(Constant.FadeIn) ?? false
787
+ let fadeInDuration = call.getDouble(Constant.FadeInDuration) ?? Double(Constant.DefaultFadeDuration)
788
+ var restoredVolume: Float?
789
+ if let data = audioAssetData[audioAsset.assetId], let volume = data["volumeBeforePause"] as? Float {
790
+ restoredVolume = volume
791
+ }
792
+ if fadeIn {
793
+ let targetVolume = restoredVolume ?? (audioAsset.channels.first?.volume ?? audioAsset.initialVolume)
794
+ audioAsset.setVolume(volume: 0, fadeDuration: 0)
795
+ audioAsset.resume()
796
+ audioAsset.setVolume(volume: NSNumber(value: targetVolume), fadeDuration: fadeInDuration)
797
+ } else {
798
+ if let volume = restoredVolume {
799
+ audioAsset.setVolume(volume: NSNumber(value: volume), fadeDuration: 0)
800
+ }
801
+ audioAsset.resume()
802
+ }
803
+ if var data = audioAssetData[audioAsset.assetId] {
804
+ data.removeValue(forKey: "volumeBeforePause")
805
+ audioAssetData[audioAsset.assetId] = data
806
+ }
765
807
 
766
808
  // Update notification when resumed
767
809
  if self.showNotification {
@@ -778,8 +820,19 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
778
820
  call.reject("Failed to get audio asset")
779
821
  return
780
822
  }
781
-
782
- audioAsset.pause()
823
+ cancelPendingPlay(for: audioAsset.assetId)
824
+ let fadeOut = call.getBool(Constant.FadeOut) ?? false
825
+ let fadeOutDuration = call.getDouble(Constant.FadeOutDuration) ?? Double(Constant.DefaultFadeDuration)
826
+ let currentVolume = audioAsset.channels.first?.volume ?? audioAsset.initialVolume
827
+ var data = audioAssetData[audioAsset.assetId] ?? [:]
828
+ data["volumeBeforePause"] = currentVolume
829
+ audioAssetData[audioAsset.assetId] = data
830
+
831
+ if fadeOut {
832
+ audioAsset.stopWithFade(fadeOutDuration: fadeOutDuration, toPause: true)
833
+ } else {
834
+ audioAsset.pause()
835
+ }
783
836
 
784
837
  // Update notification when paused
785
838
  if self.showNotification {
@@ -796,6 +849,8 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
796
849
  /// 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.
797
850
  @objc func stop(_ call: CAPPluginCall) {
798
851
  let audioId = call.getString(Constant.AssetIdKey) ?? ""
852
+ let fadeOut = call.getBool(Constant.FadeOut) ?? false
853
+ let fadeOutDuration = call.getDouble(Constant.FadeOutDuration) ?? Double(Constant.DefaultFadeDuration)
799
854
 
800
855
  audioQueue.sync {
801
856
  guard !self.audioList.isEmpty else {
@@ -804,7 +859,7 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
804
859
  }
805
860
 
806
861
  do {
807
- try self.stopAudio(audioId: audioId)
862
+ try self.stopAudio(audioId: audioId, fadeOut: fadeOut, fadeOutDuration: fadeOutDuration)
808
863
 
809
864
  // Clear notification when stopped
810
865
  if self.showNotification {
@@ -887,7 +942,8 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
887
942
  }
888
943
 
889
944
  let volume = min(max(call.getFloat(Constant.Volume) ?? Constant.DefaultVolume, Constant.MinVolume), Constant.MaxVolume)
890
- audioAsset.setVolume(volume: volume as NSNumber)
945
+ let durationSecs = call.getDouble(Constant.FadeDuration) ?? 0.0
946
+ audioAsset.setVolume(volume: volume as NSNumber, fadeDuration: durationSecs)
891
947
  call.resolve()
892
948
  }
893
949
  }
@@ -925,29 +981,12 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
925
981
  }
926
982
  }
927
983
 
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.
984
+ // swiftlint:disable:next cyclomatic_complexity function_body_length
945
985
  @objc private func preloadAsset(_ call: CAPPluginCall, isComplex complex: Bool) {
946
986
  // Common default values to ensure consistency
947
987
  let audioId = call.getString(Constant.AssetIdKey) ?? ""
948
988
  let channels: Int?
949
989
  let volume: Float?
950
- let delay: Float?
951
990
  var isLocalUrl: Bool = call.getBool("isUrl") ?? false
952
991
 
953
992
  if audioId == "" {
@@ -987,11 +1026,9 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
987
1026
  if complex {
988
1027
  volume = min(max(call.getFloat("volume") ?? Constant.DefaultVolume, Constant.MinVolume), Constant.MaxVolume)
989
1028
  channels = max(call.getInt("channels") ?? Constant.DefaultChannels, 1)
990
- delay = max(call.getFloat("delay") ?? Constant.DefaultFadeDelay, 0.0)
991
1029
  } else {
992
1030
  channels = Constant.DefaultChannels
993
1031
  volume = Constant.DefaultVolume
994
- delay = Constant.DefaultFadeDelay
995
1032
  isLocalUrl = false
996
1033
  }
997
1034
 
@@ -1017,7 +1054,7 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
1017
1054
  let audioAsset = AudioAsset(
1018
1055
  owner: self,
1019
1056
  withAssetId: audioId, withPath: basePath, withChannels: channels,
1020
- withVolume: volume, withFadeDelay: delay)
1057
+ withVolume: volume)
1021
1058
  self.audioList[audioId] = audioAsset
1022
1059
  call.resolve()
1023
1060
  return
@@ -1034,7 +1071,14 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
1034
1071
  }
1035
1072
  }
1036
1073
  }
1037
- let remoteAudioAsset = RemoteAudioAsset(owner: self, withAssetId: audioId, withPath: assetPath, withChannels: channels, withVolume: volume, withFadeDelay: delay, withHeaders: headers)
1074
+ let remoteAudioAsset = RemoteAudioAsset(
1075
+ owner: self,
1076
+ withAssetId: audioId,
1077
+ withPath: assetPath,
1078
+ withChannels: channels,
1079
+ withVolume: volume,
1080
+ withHeaders: headers
1081
+ )
1038
1082
  self.audioList[audioId] = remoteAudioAsset
1039
1083
  call.resolve()
1040
1084
  return
@@ -1070,7 +1114,7 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
1070
1114
  let audioAsset = AudioAsset(
1071
1115
  owner: self,
1072
1116
  withAssetId: audioId, withPath: basePath, withChannels: channels,
1073
- withVolume: volume, withFadeDelay: delay)
1117
+ withVolume: volume)
1074
1118
  self.audioList[audioId] = audioAsset
1075
1119
  }
1076
1120
  } else {
@@ -1093,16 +1137,14 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
1093
1137
  let audioAsset = AudioAsset(
1094
1138
  owner: self,
1095
1139
  withAssetId: audioId, withPath: assetPath, withChannels: channels,
1096
- withVolume: volume, withFadeDelay: delay)
1140
+ withVolume: volume)
1097
1141
  self.audioList[audioId] = audioAsset
1098
1142
  }
1099
1143
  }
1100
1144
  call.resolve()
1101
1145
  }
1102
1146
  }
1103
- // swiftlint:enable cyclomatic_complexity function_body_length
1104
-
1105
- private func stopAudio(audioId: String) throws {
1147
+ private func stopAudio(audioId: String, fadeOut: Bool, fadeOutDuration: Double) throws {
1106
1148
  var asset: AudioAsset?
1107
1149
 
1108
1150
  audioQueue.sync {
@@ -1113,19 +1155,57 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
1113
1155
  throw MyError.runtimeError(Constant.ErrorAssetNotFound)
1114
1156
  }
1115
1157
 
1116
- if self.fadeMusic {
1117
- audioAsset.stopWithFade()
1158
+ clearAudioAssetData(for: audioId)
1159
+
1160
+ if fadeOut {
1161
+ audioAsset.stopWithFade(fadeOutDuration: fadeOutDuration)
1118
1162
  } else {
1119
1163
  audioAsset.stop()
1120
1164
  }
1121
1165
  }
1122
1166
 
1167
+ private func clearAudioAssetData(for audioId: String) {
1168
+ audioAssetData[audioId] = nil
1169
+ }
1170
+
1171
+ private func cancelPendingPlay(for audioId: String) {
1172
+ if let task = pendingPlayTasks[audioId] {
1173
+ task.cancel()
1174
+ pendingPlayTasks[audioId] = nil
1175
+ }
1176
+ }
1177
+
1178
+ private func handleFadeOut(for asset: AudioAsset, audioId: String, fadeOutDuration: TimeInterval, fadeOutStartTime: TimeInterval) {
1179
+ let duration = asset.getDuration()
1180
+ if duration <= 0 || !duration.isFinite {
1181
+ logger.warning("Audio asset has no finite duration, skipping fadeOut for %@", audioId)
1182
+ return
1183
+ }
1184
+
1185
+ var startTime = max(duration - fadeOutDuration, 0)
1186
+ if fadeOutStartTime > 0 {
1187
+ startTime = fadeOutStartTime
1188
+ }
1189
+
1190
+ audioAssetData[audioId] = [
1191
+ "fadeOut": true,
1192
+ "fadeOutStartTime": startTime,
1193
+ "fadeOutDuration": fadeOutDuration
1194
+ ]
1195
+ }
1196
+
1123
1197
  internal func executeOnAudioQueue(_ block: @escaping () -> Void) {
1124
1198
  if DispatchQueue.getSpecific(key: queueKey) != nil {
1125
1199
  block() // Already on queue
1126
1200
  } else {
1127
- audioQueue.sync(flags: .barrier) {
1128
- block()
1201
+ if isRunningTests {
1202
+ audioQueue.async {
1203
+ block()
1204
+ }
1205
+ } else {
1206
+ audioQueue.sync(flags: .barrier) {
1207
+ block()
1208
+ }
1129
1209
  }
1130
1210
  }
1131
1211
  }
@@ -1139,6 +1219,15 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
1139
1219
  "currentTime": currentTime,
1140
1220
  "assetId": asset.assetId
1141
1221
  ])
1222
+
1223
+ if let fadeData = audioAssetData[asset.assetId],
1224
+ let fadeOut = fadeData["fadeOut"] as? Bool, fadeOut,
1225
+ let fadeOutStartTime = fadeData["fadeOutStartTime"] as? Double,
1226
+ let fadeOutDuration = fadeData["fadeOutDuration"] as? Double,
1227
+ currentTime >= fadeOutStartTime {
1228
+ asset.stopWithFade(fadeOutDuration: fadeOutDuration)
1229
+ audioAssetData[asset.assetId] = nil
1230
+ }
1142
1231
  }
1143
1232
  }
1144
1233