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