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