@capgo/native-audio 8.3.9 → 8.3.11

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.
@@ -82,7 +82,7 @@ dependencies {
82
82
  implementation 'androidx.media3:media3-transformer:1.9.2'
83
83
  implementation 'androidx.media3:media3-ui:1.9.2'
84
84
  implementation 'androidx.media3:media3-database:1.9.3'
85
- implementation 'androidx.media3:media3-common:1.9.2'
85
+ implementation 'androidx.media3:media3-common:1.10.0'
86
86
  // Media notification support
87
87
  implementation 'androidx.media:media:1.7.1'
88
88
  implementation 'androidx.core:core:1.13.1'
@@ -15,6 +15,37 @@ extension AudioAsset {
15
15
  fadeTask = nil
16
16
  }
17
17
 
18
+ fileprivate func performLocalFadeOutPauseOnMain(audio: AVAudioPlayer, beforePause: ((TimeInterval, TimeInterval) -> Void)?) {
19
+ let elapsed = audio.currentTime
20
+ let duration = audio.duration.isFinite ? audio.duration : 0
21
+ beforePause?(elapsed, duration)
22
+ audio.pause()
23
+ }
24
+
25
+ fileprivate func scheduleLocalFadeOutPauseOnMain(audio: AVAudioPlayer, beforePause: ((TimeInterval, TimeInterval) -> Void)?) {
26
+ DispatchQueue.main.async { [weak self] in
27
+ guard let self else { return }
28
+ self.performLocalFadeOutPauseOnMain(audio: audio, beforePause: beforePause)
29
+ }
30
+ }
31
+
32
+ /// Same main-thread pause and `beforePause(elapsed, duration)` as fade-out-to-pause when no fade runs (e.g. volume already zero).
33
+ internal func schedulePauseWithPositionRecording(audio: AVAudioPlayer, beforePause: ((TimeInterval, TimeInterval) -> Void)?) {
34
+ scheduleLocalFadeOutPauseOnMain(audio: audio, beforePause: beforePause)
35
+ }
36
+
37
+ fileprivate func scheduleLocalFadeOutStopOnMain(audio: AVAudioPlayer) {
38
+ DispatchQueue.main.async { [weak self] in
39
+ guard let self else { return }
40
+ self.performLocalFadeOutStopOnMain(audio: audio)
41
+ }
42
+ }
43
+
44
+ fileprivate func performLocalFadeOutStopOnMain(audio: AVAudioPlayer) {
45
+ audio.stop()
46
+ dispatchComplete()
47
+ }
48
+
18
49
  func fadeIn(audio: AVAudioPlayer, fadeInDuration: TimeInterval, targetVolume: Float) {
19
50
  cancelFade()
20
51
  let steps = Int(fadeInDuration / TimeInterval(fadeDelaySecs))
@@ -40,10 +71,24 @@ extension AudioAsset {
40
71
  }
41
72
  }
42
73
 
43
- func fadeOut(audio: AVAudioPlayer, fadeOutDuration: TimeInterval, toPause: Bool = false) {
74
+ /// - Parameter beforePause: Called on the main queue immediately before `pause()` when `toPause` is true,
75
+ /// so the plugin can persist `timeBeforePause` and update Now Playing at the actual stop position.
76
+ func fadeOut(
77
+ audio: AVAudioPlayer,
78
+ fadeOutDuration: TimeInterval,
79
+ toPause: Bool = false,
80
+ beforePause: ((TimeInterval, TimeInterval) -> Void)? = nil
81
+ ) {
44
82
  cancelFade()
45
83
  let steps = Int(fadeOutDuration / TimeInterval(fadeDelaySecs))
46
- guard steps > 0 else { return }
84
+ guard steps > 0 else {
85
+ if toPause {
86
+ scheduleLocalFadeOutPauseOnMain(audio: audio, beforePause: beforePause)
87
+ } else {
88
+ scheduleLocalFadeOutStopOnMain(audio: audio)
89
+ }
90
+ return
91
+ }
47
92
  var currentVolume = audio.volume
48
93
  let fadeStep = currentVolume / Float(steps)
49
94
 
@@ -61,10 +106,9 @@ extension AudioAsset {
61
106
  DispatchQueue.main.async { [weak self] in
62
107
  guard let self else { return }
63
108
  if toPause {
64
- audio.pause()
109
+ self.performLocalFadeOutPauseOnMain(audio: audio, beforePause: beforePause)
65
110
  } else {
66
- audio.stop()
67
- self.dispatchComplete()
111
+ self.performLocalFadeOutStopOnMain(audio: audio)
68
112
  }
69
113
  }
70
114
  }
@@ -120,13 +120,24 @@ public class AudioAsset: NSObject, AVAudioPlayerDelegate {
120
120
  return result
121
121
  }
122
122
 
123
- func setCurrentTime(time: TimeInterval) {
124
- owner?.executeOnAudioQueue { [weak self] in
125
- guard let self else { return }
126
- guard !channels.isEmpty, playIndex < channels.count else { return }
123
+ func setCurrentTime(time: TimeInterval, completion: (() -> Void)? = nil) {
124
+ guard let owner else {
125
+ completion?()
126
+ return
127
+ }
128
+ owner.executeOnAudioQueue { [weak self] in
129
+ guard let self else {
130
+ completion?()
131
+ return
132
+ }
133
+ guard !channels.isEmpty, playIndex < channels.count else {
134
+ completion?()
135
+ return
136
+ }
127
137
  let player = channels[playIndex]
128
138
  let validTime = min(max(time, 0), player.duration)
129
139
  player.currentTime = validTime
140
+ completion?()
130
141
  }
131
142
  }
132
143
 
@@ -213,8 +224,7 @@ public class AudioAsset: NSObject, AVAudioPlayerDelegate {
213
224
  guard let self else { return }
214
225
  guard !channels.isEmpty, playIndex < channels.count else { return }
215
226
  let player = channels[playIndex]
216
- let timeOffset = player.deviceCurrentTime + 0.01
217
- player.play(atTime: timeOffset)
227
+ player.play()
218
228
  startCurrentTimeUpdates()
219
229
  }
220
230
  }
@@ -248,8 +258,25 @@ public class AudioAsset: NSObject, AVAudioPlayerDelegate {
248
258
  return
249
259
  }
250
260
  let player = channels[playIndex]
251
- if player.isPlaying && player.volume > 0 {
252
- fadeOut(audio: player, fadeOutDuration: fadeOutDuration, toPause: toPause)
261
+ if player.isPlaying {
262
+ if toPause {
263
+ if player.volume > 0 {
264
+ fadeOut(audio: player, fadeOutDuration: fadeOutDuration, toPause: true) { [weak self] elapsed, duration in
265
+ guard let self, let owner = self.owner else { return }
266
+ owner.recordPausePositionAfterFade(assetId: self.assetId, elapsedTime: elapsed, duration: duration)
267
+ }
268
+ } else {
269
+ cancelFade()
270
+ schedulePauseWithPositionRecording(audio: player) { [weak self] elapsed, duration in
271
+ guard let self, let owner = self.owner else { return }
272
+ owner.recordPausePositionAfterFade(assetId: self.assetId, elapsedTime: elapsed, duration: duration)
273
+ }
274
+ }
275
+ } else if player.volume > 0 {
276
+ fadeOut(audio: player, fadeOutDuration: fadeOutDuration, toPause: false)
277
+ } else {
278
+ stop()
279
+ }
253
280
  } else if !toPause {
254
281
  stop()
255
282
  }
@@ -12,7 +12,7 @@ enum MyError: Error {
12
12
  @objc(NativeAudio)
13
13
  // swiftlint:disable:next type_body_length
14
14
  public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
15
- private let pluginVersion: String = "8.3.9"
15
+ private let pluginVersion: String = "8.3.11"
16
16
  public let identifier = "NativeAudio"
17
17
  public let jsName = "NativeAudio"
18
18
  public let pluginMethods: [CAPPluginMethod] = [
@@ -172,6 +172,44 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
172
172
  }
173
173
  }
174
174
 
175
+ /// Must be called on `audioQueue`. If `timeBeforePause` is stored, clears it and seeks (async for remote) before running `resume` + Now Playing refresh.
176
+ /// Mirrors `resume(_:)` (non–fade-in path): restores `volumeBeforePause` via `setVolume`, clears that key from `audioAssetData`, then `resume()`.
177
+ private func resumeAssetFromStoredPausePositionIfNeeded(assetId: String, asset: AudioAsset) {
178
+ var restoredTime: TimeInterval?
179
+ if var data = audioAssetData[assetId],
180
+ let time = data["timeBeforePause"] as? TimeInterval {
181
+ restoredTime = time
182
+ data.removeValue(forKey: "timeBeforePause")
183
+ audioAssetData[assetId] = data
184
+ }
185
+
186
+ var restoredVolume: Float?
187
+ if let data = audioAssetData[assetId], let volume = data["volumeBeforePause"] as? Float {
188
+ restoredVolume = volume
189
+ }
190
+
191
+ let resumeAndRefreshNowPlaying: () -> Void = { [weak self] in
192
+ guard let self else { return }
193
+ if let volume = restoredVolume {
194
+ asset.setVolume(volume: NSNumber(value: volume), fadeDuration: 0)
195
+ }
196
+ if var data = self.audioAssetData[assetId] {
197
+ data.removeValue(forKey: "volumeBeforePause")
198
+ self.audioAssetData[assetId] = data
199
+ }
200
+ asset.resume()
201
+ updateNowPlayingInfo(audioId: assetId, audioAsset: asset)
202
+ }
203
+ if let t = restoredTime {
204
+ asset.setCurrentTime(time: t) { [weak self] in
205
+ guard let self else { return }
206
+ audioQueue.async(flags: .barrier, execute: resumeAndRefreshNowPlaying)
207
+ }
208
+ } else {
209
+ resumeAndRefreshNowPlaying()
210
+ }
211
+ }
212
+
175
213
  // swiftlint:disable function_body_length
176
214
  private func setupRemoteCommandCenter() {
177
215
  let commandCenter = MPRemoteCommandCenter.shared()
@@ -188,8 +226,7 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
188
226
  }
189
227
 
190
228
  if !asset.isPlaying() {
191
- asset.resume()
192
- self.updateNowPlayingInfo(audioId: assetId, audioAsset: asset)
229
+ self.resumeAssetFromStoredPausePositionIfNeeded(assetId: assetId, asset: asset)
193
230
  }
194
231
  }
195
232
  return .success
@@ -206,8 +243,14 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
206
243
  return
207
244
  }
208
245
 
246
+ // Persist the paused position for the next resume.
247
+ let timeBeforePause = asset.getCurrentTime()
248
+ var data = self.audioAssetData[assetId] ?? [:]
249
+ data["timeBeforePause"] = timeBeforePause
250
+ self.audioAssetData[assetId] = data
251
+
209
252
  asset.pause()
210
- self.updatePlaybackState(isPlaying: false)
253
+ self.updatePlaybackState(isPlaying: false, elapsedTime: timeBeforePause, duration: asset.getDuration())
211
254
  }
212
255
  return .success
213
256
  }
@@ -241,11 +284,16 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
241
284
  }
242
285
 
243
286
  if asset.isPlaying() {
287
+ // Persist the paused position for the next resume.
288
+ let timeBeforePause = asset.getCurrentTime()
289
+ var data = self.audioAssetData[assetId] ?? [:]
290
+ data["timeBeforePause"] = timeBeforePause
291
+ self.audioAssetData[assetId] = data
292
+
244
293
  asset.pause()
245
- self.updatePlaybackState(isPlaying: false)
294
+ self.updatePlaybackState(isPlaying: false, elapsedTime: timeBeforePause, duration: asset.getDuration())
246
295
  } else {
247
- asset.resume()
248
- self.updateNowPlayingInfo(audioId: assetId, audioAsset: asset)
296
+ self.resumeAssetFromStoredPausePositionIfNeeded(assetId: assetId, asset: asset)
249
297
  }
250
298
  }
251
299
  return .success
@@ -608,9 +656,9 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
608
656
 
609
657
  func endSession() {
610
658
  do {
611
- // Avoid reentrant sync when already on audio queue (e.g. from pause()) to prevent deadlock
659
+ // Avoid reentrant sync when already on audio queue (e.g. from pause(), didEnterBackground) to prevent deadlock
612
660
  let hasPlayingAssets: Bool
613
- if DispatchQueue.getSpecific(key: audioQueueContextKey) == true {
661
+ if DispatchQueue.getSpecific(key: queueKey) != nil || DispatchQueue.getSpecific(key: audioQueueContextKey) == true {
614
662
  hasPlayingAssets = self.audioList.values.contains { asset in
615
663
  if let audioAsset = asset as? AudioAsset {
616
664
  return audioAsset.isPlaying()
@@ -640,9 +688,9 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
640
688
  }()
641
689
 
642
690
  if !hasPlayingAssets &&
643
- !session.isOtherAudioPlaying &&
644
- session.secondaryAudioShouldBeSilencedHint == false &&
645
- !isRecordCapableCategory {
691
+ !session.isOtherAudioPlaying &&
692
+ session.secondaryAudioShouldBeSilencedHint == false &&
693
+ !isRecordCapableCategory {
646
694
  try self.session.setActive(false, options: .notifyOthersOnDeactivation)
647
695
  }
648
696
  } catch {
@@ -791,8 +839,9 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
791
839
  cancelPendingPlay(for: audioAsset.assetId)
792
840
  clearAudioAssetData(for: audioAsset.assetId)
793
841
  let time = max(call.getDouble(Constant.Time) ?? 0, 0)
794
- audioAsset.setCurrentTime(time: time)
795
- call.resolve()
842
+ audioAsset.setCurrentTime(time: time) {
843
+ call.resolve()
844
+ }
796
845
  }
797
846
  }
798
847
 
@@ -839,31 +888,50 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
839
888
  let fadeIn = call.getBool(Constant.FadeIn) ?? false
840
889
  let fadeInDuration = call.getDouble(Constant.FadeInDuration) ?? Double(Constant.DefaultFadeDuration)
841
890
  var restoredVolume: Float?
891
+
892
+ var restoredTime: TimeInterval?
893
+ if var data = audioAssetData[audioAsset.assetId],
894
+ let time = data["timeBeforePause"] as? TimeInterval {
895
+ restoredTime = time
896
+ data.removeValue(forKey: "timeBeforePause")
897
+ audioAssetData[audioAsset.assetId] = data
898
+ }
899
+
842
900
  if let data = audioAssetData[audioAsset.assetId], let volume = data["volumeBeforePause"] as? Float {
843
901
  restoredVolume = volume
844
902
  }
845
- if fadeIn {
846
- let targetVolume = restoredVolume ?? (audioAsset.channels.first?.volume ?? audioAsset.initialVolume)
847
- audioAsset.setVolume(volume: 0, fadeDuration: 0)
848
- audioAsset.resume()
849
- audioAsset.setVolume(volume: NSNumber(value: targetVolume), fadeDuration: fadeInDuration)
850
- } else {
851
- if let volume = restoredVolume {
852
- audioAsset.setVolume(volume: NSNumber(value: volume), fadeDuration: 0)
903
+
904
+ let finishResume: () -> Void = { [weak self] in
905
+ guard let self else { return }
906
+ if fadeIn {
907
+ let targetVolume = restoredVolume ?? (audioAsset.channels.first?.volume ?? audioAsset.initialVolume)
908
+ audioAsset.setVolume(volume: 0, fadeDuration: 0)
909
+ audioAsset.resume()
910
+ audioAsset.setVolume(volume: NSNumber(value: targetVolume), fadeDuration: fadeInDuration)
911
+ } else {
912
+ if let volume = restoredVolume {
913
+ audioAsset.setVolume(volume: NSNumber(value: volume), fadeDuration: 0)
914
+ }
915
+ audioAsset.resume()
853
916
  }
854
- audioAsset.resume()
855
- }
856
- if var data = audioAssetData[audioAsset.assetId] {
857
- data.removeValue(forKey: "volumeBeforePause")
858
- audioAssetData[audioAsset.assetId] = data
917
+ if var data = self.audioAssetData[audioAsset.assetId] {
918
+ data.removeValue(forKey: "volumeBeforePause")
919
+ self.audioAssetData[audioAsset.assetId] = data
920
+ }
921
+ if self.showNotification {
922
+ self.updateNowPlayingInfo(audioId: audioId, audioAsset: audioAsset)
923
+ }
924
+ call.resolve()
859
925
  }
860
926
 
861
- // Update notification when resumed
862
- if self.showNotification {
863
- self.updateNowPlayingInfo(audioId: audioId, audioAsset: audioAsset)
927
+ if let t = restoredTime {
928
+ audioAsset.setCurrentTime(time: t) { [weak self] in
929
+ guard let self else { return }
930
+ self.audioQueue.async(flags: .barrier, execute: finishResume)
931
+ }
932
+ } else {
933
+ finishResume()
864
934
  }
865
-
866
- call.resolve()
867
935
  }
868
936
  }
869
937
 
@@ -881,6 +949,11 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
881
949
  let currentVolume = audioAsset.channels.first?.volume ?? audioAsset.initialVolume
882
950
  var data = audioAssetData[audioAsset.assetId] ?? [:]
883
951
  data["volumeBeforePause"] = currentVolume
952
+
953
+ // Without fade: store position now. With fade: `recordPausePositionAfterFade` runs when the fade finishes.
954
+ if !fadeOut {
955
+ data["timeBeforePause"] = audioAsset.getCurrentTime()
956
+ }
884
957
  audioAssetData[audioAsset.assetId] = data
885
958
 
886
959
  if fadeOut {
@@ -889,9 +962,9 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
889
962
  audioAsset.pause()
890
963
  }
891
964
 
892
- // Update notification when paused
893
- if self.showNotification {
894
- self.updatePlaybackState(isPlaying: false)
965
+ // Fade-out: `recordPausePositionAfterFade` updates Now Playing when fade-to-pause completes.
966
+ if self.showNotification && !fadeOut {
967
+ self.updatePlaybackState(isPlaying: false, elapsedTime: audioAsset.getCurrentTime(), duration: audioAsset.getDuration())
895
968
  }
896
969
 
897
970
  self.endSession()
@@ -958,26 +1031,35 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
958
1031
  let audioId = call.getString(Constant.AssetIdKey) ?? ""
959
1032
 
960
1033
  audioQueue.sync(flags: .barrier) { // Use barrier for writing operations
1034
+ self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: true)
1035
+ defer { self.audioQueue.setSpecific(key: self.audioQueueContextKey, value: nil) }
1036
+
961
1037
  guard !self.audioList.isEmpty else {
962
1038
  call.reject("Audio list is empty")
963
1039
  return
964
1040
  }
965
1041
 
1042
+ let wasCurrentlyPlaying = self.currentlyPlayingAssetId == audioId
1043
+
966
1044
  if let asset = self.audioList[audioId] as? AudioAsset {
967
1045
  asset.unload()
968
1046
  self.audioList[audioId] = nil
969
1047
 
970
- // Reset current track if this was the currently playing asset (internal state tracking)
971
- if self.currentlyPlayingAssetId == audioId {
972
- self.currentlyPlayingAssetId = nil
973
- }
974
-
975
1048
  // Clean up playOnce tracking if this was a playOnce asset
976
1049
  if self.playOnceAssets.contains(audioId) {
977
1050
  self.playOnceAssets.remove(audioId)
978
1051
  self.notificationMetadataMap.removeValue(forKey: audioId)
979
1052
  }
980
1053
 
1054
+ if wasCurrentlyPlaying {
1055
+ // This asset controlled the Now Playing / remote command state.
1056
+ self.currentlyPlayingAssetId = nil
1057
+ if self.showNotification {
1058
+ self.clearNowPlayingInfo()
1059
+ }
1060
+ }
1061
+
1062
+ self.endSession()
981
1063
  call.resolve()
982
1064
  } else if let audioNumber = self.audioList[audioId] as? NSNumber {
983
1065
  // Also handle unloading system sounds
@@ -1216,8 +1298,13 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
1216
1298
  private func stopAudio(audioId: String, fadeOut: Bool, fadeOutDuration: Double) throws {
1217
1299
  var asset: AudioAsset?
1218
1300
 
1219
- audioQueue.sync {
1301
+ // Avoid reentrant sync when already on audio queue (e.g. from stop()) to prevent deadlock
1302
+ if DispatchQueue.getSpecific(key: queueKey) != nil || DispatchQueue.getSpecific(key: audioQueueContextKey) == true {
1220
1303
  asset = self.audioList[audioId] as? AudioAsset
1304
+ } else {
1305
+ audioQueue.sync {
1306
+ asset = self.audioList[audioId] as? AudioAsset
1307
+ }
1221
1308
  }
1222
1309
 
1223
1310
  guard let audioAsset = asset else {
@@ -1378,12 +1465,24 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
1378
1465
 
1379
1466
  // Load artwork if provided
1380
1467
  if let artworkUrl = metadata["artworkUrl"] {
1381
- self.loadArtwork(from: artworkUrl) { image in
1382
- if let image = image {
1383
- nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in
1384
- return image
1468
+ let targetAudioId = audioId
1469
+ self.loadArtwork(from: artworkUrl) { [weak self] image in
1470
+ guard let self = self, let image = image else { return }
1471
+ self.audioQueue.async { [weak self] in
1472
+ guard let self = self else { return }
1473
+ guard self.currentlyPlayingAssetId == targetAudioId else { return }
1474
+
1475
+ DispatchQueue.main.async { [weak self] in
1476
+ guard let self = self else { return }
1477
+ let stillCurrent = self.audioQueue.sync { self.currentlyPlayingAssetId == targetAudioId }
1478
+ guard stillCurrent else { return }
1479
+
1480
+ var merged = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
1481
+ merged[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in
1482
+ image
1483
+ }
1484
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = merged
1385
1485
  }
1386
- MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
1387
1486
  }
1388
1487
  }
1389
1488
  }
@@ -1398,18 +1497,36 @@ public class NativeAudio: CAPPlugin, AVAudioPlayerDelegate, CAPBridgedPlugin {
1398
1497
  }
1399
1498
  }
1400
1499
 
1401
- /// Clears the Now Playing info. Only used when tearing down (deinit); stop/unload do not clear
1402
- /// so that the next play can overwrite the notification without a race.
1500
+ /// Clears the Now Playing info when the plugin is no longer the active notifier.
1403
1501
  private func clearNowPlayingInfo() {
1404
1502
  DispatchQueue.main.async {
1405
1503
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
1406
1504
  }
1407
1505
  }
1408
1506
 
1409
- private func updatePlaybackState(isPlaying: Bool) {
1507
+ /// Persists `timeBeforePause` and refreshes Now Playing after fade-out-to-pause completes.
1508
+ internal func recordPausePositionAfterFade(assetId: String, elapsedTime: TimeInterval, duration: TimeInterval) {
1509
+ audioQueue.async { [weak self] in
1510
+ guard let self else { return }
1511
+ var data = self.audioAssetData[assetId] ?? [:]
1512
+ data["timeBeforePause"] = elapsedTime
1513
+ self.audioAssetData[assetId] = data
1514
+ if self.showNotification && self.currentlyPlayingAssetId == assetId {
1515
+ self.updatePlaybackState(isPlaying: false, elapsedTime: elapsedTime, duration: duration)
1516
+ }
1517
+ }
1518
+ }
1519
+
1520
+ private func updatePlaybackState(isPlaying: Bool, elapsedTime: TimeInterval? = nil, duration: TimeInterval? = nil) {
1410
1521
  DispatchQueue.main.async {
1411
1522
  var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
1412
1523
  nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
1524
+ if let elapsed = elapsedTime {
1525
+ nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsed
1526
+ }
1527
+ if let dur = duration {
1528
+ nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = dur
1529
+ }
1413
1530
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
1414
1531
  }
1415
1532
  }
@@ -2,6 +2,33 @@ import AVFoundation
2
2
 
3
3
  extension RemoteAudioAsset {
4
4
 
5
+ /// Pause after sampling elapsed/duration for Now Playing. Caller must be on the main queue.
6
+ fileprivate func performRemoteFadeOutPauseOnMain(player: AVPlayer, beforePause: ((TimeInterval, TimeInterval) -> Void)?) {
7
+ let elapsed = player.currentTime().seconds
8
+ let rawDuration = player.currentItem?.duration ?? .zero
9
+ let duration = rawDuration.isNumeric && rawDuration.isValid ? rawDuration.seconds : 0
10
+ beforePause?(elapsed, duration.isFinite ? duration : 0)
11
+ player.pause()
12
+ }
13
+
14
+ fileprivate func scheduleRemoteFadeOutPauseOnMain(player: AVPlayer, beforePause: ((TimeInterval, TimeInterval) -> Void)?) {
15
+ DispatchQueue.main.async { [weak self] in
16
+ guard let self else { return }
17
+ self.performRemoteFadeOutPauseOnMain(player: player, beforePause: beforePause)
18
+ }
19
+ }
20
+
21
+ /// Pause, seek to start, then emit `complete` on the main queue after the seek finishes.
22
+ fileprivate func pauseSeekToStartThenDispatchComplete(on player: AVPlayer) {
23
+ player.pause()
24
+ player.seek(to: .zero) { [weak self] _ in
25
+ guard let self else { return }
26
+ DispatchQueue.main.async {
27
+ self.dispatchComplete()
28
+ }
29
+ }
30
+ }
31
+
5
32
  func fadeIn(player: AVPlayer, fadeInDuration: TimeInterval, targetVolume: Float) {
6
33
  cancelFade()
7
34
  let steps = Int(fadeInDuration / TimeInterval(fadeDelaySecs))
@@ -27,10 +54,26 @@ extension RemoteAudioAsset {
27
54
  }
28
55
  }
29
56
 
30
- func fadeOut(player: AVPlayer, fadeOutDuration: TimeInterval, toPause: Bool = false) {
57
+ /// - Parameter beforePause: Called on the main queue immediately before `pause()` when `toPause` is true.
58
+ func fadeOut(
59
+ player: AVPlayer,
60
+ fadeOutDuration: TimeInterval,
61
+ toPause: Bool = false,
62
+ beforePause: ((TimeInterval, TimeInterval) -> Void)? = nil
63
+ ) {
31
64
  cancelFade()
32
65
  let steps = Int(fadeOutDuration / TimeInterval(fadeDelaySecs))
33
- guard steps > 0 else { return }
66
+ guard steps > 0 else {
67
+ if toPause {
68
+ scheduleRemoteFadeOutPauseOnMain(player: player, beforePause: beforePause)
69
+ } else {
70
+ DispatchQueue.main.async { [weak self] in
71
+ guard let self else { return }
72
+ self.pauseSeekToStartThenDispatchComplete(on: player)
73
+ }
74
+ }
75
+ return
76
+ }
34
77
  let fadeStep = player.volume / Float(steps)
35
78
  var currentVolume = player.volume
36
79
 
@@ -48,12 +91,9 @@ extension RemoteAudioAsset {
48
91
  DispatchQueue.main.async { [weak self] in
49
92
  guard let self else { return }
50
93
  if toPause {
51
- player.pause()
94
+ self.performRemoteFadeOutPauseOnMain(player: player, beforePause: beforePause)
52
95
  } else {
53
- player.pause()
54
- player.seek(to: .zero)
55
- self.owner?.notifyListeners("complete", data: ["assetId": self.assetId as Any])
56
- self.dispatchedCompleteMap[self.assetId] = true
96
+ self.pauseSeekToStartThenDispatchComplete(on: player)
57
97
  }
58
98
  }
59
99
  }
@@ -132,6 +132,49 @@ public class RemoteAudioAsset: AudioAsset {
132
132
  }
133
133
  }
134
134
 
135
+ /// Timescale for seek targets; 600 is a common media default and avoids coarse rounding from timescale 1.
136
+ private static let seekPreferredTimescale: CMTimeScale = 600
137
+
138
+ override func setCurrentTime(time: TimeInterval, completion: (() -> Void)? = nil) {
139
+ guard let owner else {
140
+ completion?()
141
+ return
142
+ }
143
+ owner.executeOnAudioQueue { [weak self] in
144
+ guard let self else {
145
+ completion?()
146
+ return
147
+ }
148
+ guard !players.isEmpty && playIndex < players.count else {
149
+ completion?()
150
+ return
151
+ }
152
+ let player = players[playIndex]
153
+ let lowerBound = max(time, 0)
154
+ let validTime: TimeInterval
155
+ if let item = player.currentItem {
156
+ let d = item.duration
157
+ if d == .indefinite || !d.isValid {
158
+ validTime = lowerBound
159
+ } else {
160
+ let durationSeconds = d.seconds
161
+ if durationSeconds.isFinite && durationSeconds > 0 {
162
+ validTime = min(lowerBound, durationSeconds)
163
+ } else {
164
+ validTime = lowerBound
165
+ }
166
+ }
167
+ } else {
168
+ validTime = lowerBound
169
+ }
170
+ let target = CMTime(seconds: validTime, preferredTimescale: Self.seekPreferredTimescale)
171
+ player.seek(to: target, toleranceBefore: .zero, toleranceAfter: .zero) { finished in
172
+ guard finished else { return }
173
+ completion?()
174
+ }
175
+ }
176
+ }
177
+
135
178
  override func resume() {
136
179
  owner?.executeOnAudioQueue { [weak self] in
137
180
  guard let self else { return }
@@ -338,7 +381,14 @@ public class RemoteAudioAsset: AudioAsset {
338
381
  }
339
382
  let player = players[playIndex]
340
383
  if player.timeControlStatus == .playing {
341
- fadeOut(player: player, fadeOutDuration: fadeOutDuration, toPause: toPause)
384
+ if toPause {
385
+ fadeOut(player: player, fadeOutDuration: fadeOutDuration, toPause: true) { [weak self] elapsed, duration in
386
+ guard let self, let owner = self.owner else { return }
387
+ owner.recordPausePositionAfterFade(assetId: self.assetId, elapsedTime: elapsed, duration: duration)
388
+ }
389
+ } else {
390
+ fadeOut(player: player, fadeOutDuration: fadeOutDuration, toPause: false)
391
+ }
342
392
  } else if !toPause {
343
393
  stop()
344
394
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@capgo/native-audio",
3
- "version": "8.3.9",
3
+ "version": "8.3.11",
4
4
  "description": "A native plugin for native audio engine",
5
5
  "license": "MPL-2.0",
6
6
  "main": "dist/plugin.cjs.js",