@capgo/native-audio 8.3.8 → 8.3.10
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.
- package/android/build.gradle +1 -1
- package/ios/Sources/NativeAudioPlugin/AudioAsset+Fade.swift +49 -5
- package/ios/Sources/NativeAudioPlugin/AudioAsset.swift +35 -8
- package/ios/Sources/NativeAudioPlugin/Plugin.swift +166 -49
- package/ios/Sources/NativeAudioPlugin/RemoteAudioAsset+Fade.swift +47 -7
- package/ios/Sources/NativeAudioPlugin/RemoteAudioAsset.swift +51 -1
- package/package.json +1 -1
package/android/build.gradle
CHANGED
|
@@ -81,7 +81,7 @@ dependencies {
|
|
|
81
81
|
implementation 'androidx.media3:media3-session:1.9.2'
|
|
82
82
|
implementation 'androidx.media3:media3-transformer:1.9.2'
|
|
83
83
|
implementation 'androidx.media3:media3-ui:1.9.2'
|
|
84
|
-
implementation 'androidx.media3:media3-database:1.9.
|
|
84
|
+
implementation 'androidx.media3:media3-database:1.9.3'
|
|
85
85
|
implementation 'androidx.media3:media3-common:1.9.2'
|
|
86
86
|
// Media notification support
|
|
87
87
|
implementation 'androidx.media:media:1.7.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
|
-
|
|
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 {
|
|
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
|
-
|
|
109
|
+
self.performLocalFadeOutPauseOnMain(audio: audio, beforePause: beforePause)
|
|
65
110
|
} else {
|
|
66
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
252
|
-
|
|
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.
|
|
15
|
+
private let pluginVersion: String = "8.3.10"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
644
|
-
|
|
645
|
-
|
|
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
|
-
|
|
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
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
audioAsset.setVolume(volume: NSNumber(value:
|
|
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.
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
94
|
+
self.performRemoteFadeOutPauseOnMain(player: player, beforePause: beforePause)
|
|
52
95
|
} else {
|
|
53
|
-
|
|
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
|
-
|
|
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
|
}
|