@capgo/native-audio 8.2.12 → 8.2.14

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.
@@ -1,9 +1,6 @@
1
1
  import AVFoundation
2
2
 
3
- /**
4
- * RemoteAudioAsset extends AudioAsset to handle remote (URL-based) audio files
5
- * Provides network audio playback using AVPlayer instead of AVAudioPlayer
6
- */
3
+ // swiftlint:disable:next type_body_length
7
4
  public class RemoteAudioAsset: AudioAsset {
8
5
  var playerItems: [AVPlayerItem] = []
9
6
  var players: [AVPlayer] = []
@@ -11,172 +8,147 @@ public class RemoteAudioAsset: AudioAsset {
11
8
  var notificationObservers: [NSObjectProtocol] = []
12
9
  var duration: TimeInterval = 0
13
10
  var asset: AVURLAsset?
11
+ private var logger = Logger(logTag: "RemoteAudioAsset")
12
+ static let staticLogger = Logger(logTag: "RemoteAudioAsset")
14
13
 
15
- init(owner: NativeAudio, withAssetId assetId: String, withPath path: String!, withChannels channels: Int!, withVolume volume: Float!, withFadeDelay delay: Float!, withHeaders headers: [String: String]?) {
16
- super.init(owner: owner, withAssetId: assetId, withPath: path, withChannels: channels ?? 1, withVolume: volume ?? 1.0, withFadeDelay: delay ?? 0.0)
14
+ init(owner: NativeAudio, withAssetId assetId: String, withPath path: String, withChannels channels: Int?, withVolume volume: Float?, withHeaders headers: [String: String]?) {
15
+ super.init(owner: owner, withAssetId: assetId, withPath: path, withChannels: channels ?? 1, withVolume: volume ?? 1.0)
17
16
 
18
- owner.executeOnAudioQueue { [weak self] in
19
- guard let self = self else { return }
20
-
21
- guard let url = URL(string: path ?? "") else {
22
- print("Invalid URL: \(String(describing: path))")
17
+ let setupBlock = { [weak self] in
18
+ guard let self else { return }
19
+ guard let url = URL(string: path) else {
20
+ self.logger.error("Invalid URL: %@", String(describing: path))
23
21
  return
24
22
  }
25
23
 
26
- // Build AVURLAsset options with custom headers if provided
27
24
  var options: [String: Any] = [AVURLAssetPreferPreciseDurationAndTimingKey: true]
28
- if let headers = headers, !headers.isEmpty {
25
+ if let headers, !headers.isEmpty {
29
26
  options["AVURLAssetHTTPHeaderFieldsKey"] = headers
30
27
  }
31
28
 
32
29
  let asset = AVURLAsset(url: url, options: options)
33
30
  self.asset = asset
34
-
35
- // Limit channels to a reasonable maximum to prevent resource issues
36
31
  let channelCount = min(max(channels ?? Constant.DefaultChannels, 1), Constant.MaxChannels)
37
32
 
38
33
  for _ in 0..<channelCount {
39
34
  let playerItem = AVPlayerItem(asset: asset)
40
35
  let player = AVPlayer(playerItem: playerItem)
41
- // Apply volume constraints consistent with AudioAsset
42
36
  player.volume = self.initialVolume
43
37
  player.rate = 1.0
44
38
  self.playerItems.append(playerItem)
45
39
  self.players.append(player)
46
40
 
47
- // Add observer for duration
48
41
  let durationObserver = playerItem.observe(\.status) { [weak self] item, _ in
49
- guard let strongSelf = self else { return }
50
- strongSelf.owner?.executeOnAudioQueue {
42
+ guard let self else { return }
43
+ self.owner?.executeOnAudioQueue {
51
44
  if item.status == .readyToPlay {
52
- strongSelf.duration = item.duration.seconds
45
+ self.duration = item.duration.seconds
53
46
  }
54
47
  }
55
48
  }
56
49
  self.playerObservers.append(durationObserver)
57
50
 
58
- // Add observer for playback finished
59
51
  let observer = player.observe(\.timeControlStatus) { [weak self, weak player] observedPlayer, _ in
60
- guard let strongSelf = self,
61
- let strongPlayer = player,
62
- strongPlayer === observedPlayer else { return }
63
-
64
- if strongPlayer.timeControlStatus == .paused &&
65
- (strongPlayer.currentItem?.currentTime() == strongPlayer.currentItem?.duration ||
66
- strongPlayer.currentItem?.duration == .zero) {
67
- strongSelf.playerDidFinishPlaying(player: strongPlayer)
52
+ guard let self, let player, player === observedPlayer else { return }
53
+ if player.timeControlStatus == .paused &&
54
+ (player.currentItem?.currentTime() == player.currentItem?.duration || player.currentItem?.duration == .zero) {
55
+ self.playerDidFinishPlaying(player: player)
68
56
  }
69
57
  }
70
58
  self.playerObservers.append(observer)
71
59
  }
72
60
  }
61
+
62
+ if owner.isRunningTests {
63
+ setupBlock()
64
+ } else {
65
+ owner.executeOnAudioQueue(setupBlock)
66
+ }
67
+ }
68
+
69
+ // Backward-compatible initializer signature (delegates to primary init)
70
+ convenience init(owner: NativeAudio, withAssetId assetId: String, withPath path: String, withChannels channels: Int?, withVolume volume: Float?, withFadeDelay _: Float?, withHeaders headers: [String: String]?) {
71
+ self.init(owner: owner, withAssetId: assetId, withPath: path, withChannels: channels, withVolume: volume, withHeaders: headers)
73
72
  }
74
73
 
75
74
  deinit {
76
- // Clean up observers
77
75
  for observer in playerObservers {
78
76
  observer.invalidate()
79
77
  }
80
-
81
- for observer in notificationObservers {
82
- NotificationCenter.default.removeObserver(observer)
83
- }
84
-
85
- // Clean up players
78
+ cleanupNotificationObservers()
86
79
  for player in players {
87
80
  player.pause()
88
81
  }
89
-
90
82
  playerItems = []
91
83
  players = []
92
84
  playerObservers = []
93
- notificationObservers = []
85
+ cancelFade()
94
86
  }
95
87
 
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.
101
88
  func playerDidFinishPlaying(player: AVPlayer) {
102
89
  owner?.executeOnAudioQueue { [weak self] in
103
- guard let self = self else { return }
104
-
105
- self.owner?.notifyListeners("complete", data: [
106
- "assetId": self.assetId
107
- ])
108
-
109
- // Invoke completion callback if set
90
+ guard let self else { return }
91
+ self.owner?.notifyListeners("complete", data: ["assetId": self.assetId])
92
+ self.dispatchedCompleteMap[self.assetId] = true
110
93
  self.onComplete?()
111
94
  }
112
95
  }
113
96
 
114
- override func play(time: TimeInterval, delay: TimeInterval) {
97
+ override func play(time: TimeInterval, volume: Float? = nil) {
115
98
  owner?.executeOnAudioQueue { [weak self] in
116
- guard let self = self else { return }
117
-
99
+ guard let self else { return }
118
100
  guard !players.isEmpty else { return }
119
-
120
- // Reset play index if it's out of bounds
121
101
  if playIndex >= players.count {
122
102
  playIndex = 0
123
103
  }
124
-
104
+ cancelFade()
125
105
  let player = players[playIndex]
126
-
127
- // Ensure non-negative values for time and delay
128
- let validTime = max(time, 0)
129
- let validDelay = max(delay, 0)
130
-
131
- if validDelay > 0 {
132
- // Convert delay to CMTime and add to current time
133
- let currentTime = player.currentTime()
134
- let delayTime = CMTimeMakeWithSeconds(validDelay, preferredTimescale: currentTime.timescale)
135
- let timeToPlay = CMTimeAdd(currentTime, delayTime)
136
- player.seek(to: timeToPlay)
137
- } else {
138
- player.seek(to: CMTimeMakeWithSeconds(validTime, preferredTimescale: 1))
139
- }
106
+ player.seek(to: CMTimeMakeWithSeconds(max(time, 0), preferredTimescale: 1))
107
+ player.volume = volume ?? self.initialVolume
140
108
  player.play()
141
109
  playIndex = (playIndex + 1) % players.count
142
110
  startCurrentTimeUpdates()
143
111
  }
144
112
  }
145
113
 
114
+ // Backward-compatible signature
115
+ override func play(time: TimeInterval, delay: TimeInterval) {
116
+ let validDelay = max(delay, 0)
117
+ if validDelay > 0 {
118
+ DispatchQueue.main.asyncAfter(deadline: .now() + validDelay) { [weak self] in
119
+ self?.play(time: time, volume: nil)
120
+ }
121
+ } else {
122
+ play(time: time, volume: nil)
123
+ }
124
+ }
125
+
146
126
  override func pause() {
147
127
  owner?.executeOnAudioQueue { [weak self] in
148
- guard let self = self else { return }
149
-
128
+ guard let self else { return }
150
129
  guard !players.isEmpty && playIndex < players.count else { return }
151
-
152
- let player = players[playIndex]
153
- player.pause()
130
+ cancelFade()
131
+ players[playIndex].pause()
154
132
  stopCurrentTimeUpdates()
155
133
  }
156
134
  }
157
135
 
158
136
  override func resume() {
159
137
  owner?.executeOnAudioQueue { [weak self] in
160
- guard let self = self else { return }
161
-
138
+ guard let self else { return }
162
139
  guard !players.isEmpty && playIndex < players.count else { return }
163
140
 
164
141
  let player = players[playIndex]
165
142
  player.play()
166
-
167
- // Add notification observer for when playback stops
168
143
  cleanupNotificationObservers()
169
-
170
- // Capture weak reference to self
171
144
  let observer = NotificationCenter.default.addObserver(
172
145
  forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime,
173
146
  object: player.currentItem,
174
- queue: OperationQueue.main) { [weak self, weak player] notification in
175
- guard let strongSelf = self, let strongPlayer = player else { return }
176
-
177
- if let currentItem = notification.object as? AVPlayerItem,
178
- strongPlayer.currentItem === currentItem {
179
- strongSelf.playerDidFinishPlaying(player: strongPlayer)
147
+ queue: OperationQueue.main
148
+ ) { [weak self, weak player] notification in
149
+ guard let self, let player else { return }
150
+ if let currentItem = notification.object as? AVPlayerItem, player.currentItem === currentItem {
151
+ self.playerDidFinishPlaying(player: player)
180
152
  }
181
153
  }
182
154
  notificationObservers.append(observer)
@@ -184,106 +156,65 @@ public class RemoteAudioAsset: AudioAsset {
184
156
  }
185
157
  }
186
158
 
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.
192
159
  override func stop() {
193
160
  owner?.executeOnAudioQueue { [weak self] in
194
- guard let self = self else { return }
195
-
161
+ guard let self else { return }
196
162
  stopCurrentTimeUpdates()
197
-
163
+ cancelFade()
198
164
  for player in players {
199
- // First pause
200
165
  player.pause()
201
- // Then reset to beginning
202
166
  player.seek(to: .zero, completionHandler: { _ in
203
- // Reset any loop settings
204
167
  player.actionAtItemEnd = .pause
205
168
  })
206
169
  }
207
- // Reset playback state
208
170
  playIndex = 0
171
+ self.owner?.notifyListeners("complete", data: ["assetId": self.assetId as Any])
172
+ self.dispatchedCompleteMap[self.assetId] = true
209
173
  }
210
174
  }
211
175
 
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.
217
176
  override func loop() {
218
177
  owner?.executeOnAudioQueue { [weak self] in
219
- guard let self = self else { return }
220
-
178
+ guard let self else { return }
221
179
  cleanupNotificationObservers()
222
-
223
180
  for (index, player) in players.enumerated() {
224
181
  player.actionAtItemEnd = .none
225
-
226
182
  guard let playerItem = player.currentItem else { continue }
227
-
228
183
  let observer = NotificationCenter.default.addObserver(
229
184
  forName: .AVPlayerItemDidPlayToEndTime,
230
185
  object: playerItem,
231
- queue: OperationQueue.main) { [weak self, weak player] notification in
232
- guard let strongPlayer = player,
233
- let strongSelf = self,
186
+ queue: OperationQueue.main
187
+ ) { [weak player] notification in
188
+ guard let player,
234
189
  let item = notification.object as? AVPlayerItem,
235
- strongPlayer.currentItem === item else { return }
236
-
237
- strongPlayer.seek(to: .zero)
238
- strongPlayer.play()
190
+ player.currentItem === item else { return }
191
+ player.seek(to: .zero)
192
+ player.play()
239
193
  }
240
-
241
194
  notificationObservers.append(observer)
242
-
243
195
  if index == playIndex {
244
196
  player.seek(to: .zero)
245
197
  player.play()
246
198
  }
247
199
  }
248
-
249
200
  startCurrentTimeUpdates()
250
201
  }
251
202
  }
252
203
 
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() {
204
+ public func cleanupNotificationObservers() {
259
205
  for observer in notificationObservers {
260
206
  NotificationCenter.default.removeObserver(observer)
261
207
  }
262
208
  notificationObservers = []
263
209
  }
264
210
 
265
- @objc func playerItemDidReachEnd(notification: Notification) {
266
- owner?.executeOnAudioQueue { [weak self] in
267
- guard let self = self else { return }
268
-
269
- if let playerItem = notification.object as? AVPlayerItem,
270
- let player = players.first(where: { $0.currentItem == playerItem }) {
271
- player.seek(to: .zero)
272
- player.play()
273
- }
274
- }
275
- }
276
-
277
211
  override func unload() {
278
212
  owner?.executeOnAudioQueue { [weak self] in
279
- guard let self = self else { return }
280
-
213
+ guard let self else { return }
214
+ cancelFade()
281
215
  stopCurrentTimeUpdates()
282
216
  stop()
283
-
284
217
  cleanupNotificationObservers()
285
-
286
- // Remove KVO observers
287
218
  for observer in playerObservers {
288
219
  observer.invalidate()
289
220
  }
@@ -293,23 +224,28 @@ public class RemoteAudioAsset: AudioAsset {
293
224
  }
294
225
  }
295
226
 
296
- override func setVolume(volume: NSNumber!) {
227
+ override func setVolume(volume: NSNumber, fadeDuration: Double) {
297
228
  owner?.executeOnAudioQueue { [weak self] in
298
- guard let self = self else { return }
299
-
300
- // Ensure volume is in valid range (0.0-1.0)
229
+ guard let self else { return }
230
+ cancelFade()
301
231
  let validVolume = min(max(volume.floatValue, Constant.MinVolume), Constant.MaxVolume)
302
232
  for player in players {
303
- player.volume = validVolume
233
+ if isPlaying() && fadeDuration > 0 {
234
+ fadeTo(player: player, fadeOutDuration: fadeDuration, targetVolume: validVolume)
235
+ } else {
236
+ player.volume = validVolume
237
+ }
304
238
  }
305
239
  }
306
240
  }
307
241
 
308
- override func setRate(rate: NSNumber!) {
309
- owner?.executeOnAudioQueue { [weak self] in
310
- guard let self = self else { return }
242
+ override func setVolume(volume: NSNumber) {
243
+ setVolume(volume: volume, fadeDuration: 0)
244
+ }
311
245
 
312
- // Ensure rate is in valid range
246
+ override func setRate(rate: NSNumber) {
247
+ owner?.executeOnAudioQueue { [weak self] in
248
+ guard let self else { return }
313
249
  let validRate = min(max(rate.floatValue, Constant.MinRate), Constant.MaxRate)
314
250
  for player in players {
315
251
  player.rate = validRate
@@ -320,14 +256,12 @@ public class RemoteAudioAsset: AudioAsset {
320
256
  override func isPlaying() -> Bool {
321
257
  var result = false
322
258
  owner?.executeOnAudioQueue { [weak self] in
323
- guard let self = self else { return }
324
-
259
+ guard let self else { return }
325
260
  guard !players.isEmpty && playIndex < players.count else {
326
261
  result = false
327
262
  return
328
263
  }
329
- let player = players[playIndex]
330
- result = player.timeControlStatus == .playing
264
+ result = players[playIndex].timeControlStatus == .playing
331
265
  }
332
266
  return result
333
267
  }
@@ -335,14 +269,9 @@ public class RemoteAudioAsset: AudioAsset {
335
269
  override func getCurrentTime() -> TimeInterval {
336
270
  var result: TimeInterval = 0
337
271
  owner?.executeOnAudioQueue { [weak self] in
338
- guard let self = self else { return }
339
-
340
- guard !players.isEmpty && playIndex < players.count else {
341
- result = 0
342
- return
343
- }
344
- let player = players[playIndex]
345
- result = player.currentTime().seconds
272
+ guard let self else { return }
273
+ guard !players.isEmpty && playIndex < players.count else { return }
274
+ result = players[playIndex].currentTime().seconds
346
275
  }
347
276
  return result
348
277
  }
@@ -350,12 +279,8 @@ public class RemoteAudioAsset: AudioAsset {
350
279
  override func getDuration() -> TimeInterval {
351
280
  var result: TimeInterval = 0
352
281
  owner?.executeOnAudioQueue { [weak self] in
353
- guard let self = self else { return }
354
-
355
- guard !players.isEmpty && playIndex < players.count else {
356
- result = 0
357
- return
358
- }
282
+ guard let self else { return }
283
+ guard !players.isEmpty && playIndex < players.count else { return }
359
284
  let player = players[playIndex]
360
285
  if player.currentItem?.duration == CMTime.indefinite {
361
286
  result = 0
@@ -366,129 +291,48 @@ public class RemoteAudioAsset: AudioAsset {
366
291
  return result
367
292
  }
368
293
 
369
- override func playWithFade(time: TimeInterval) {
294
+ override func playWithFade(time: TimeInterval, volume: Float?, fadeInDuration: TimeInterval) {
370
295
  owner?.executeOnAudioQueue { [weak self] in
371
- guard let self = self else { return }
372
-
296
+ guard let self else { return }
373
297
  guard !players.isEmpty && playIndex < players.count else { return }
374
-
375
298
  let player = players[playIndex]
376
-
377
- if player.timeControlStatus != .playing {
378
- player.seek(to: CMTimeMakeWithSeconds(time, preferredTimescale: 1))
379
- player.volume = 0 // Start with volume at 0
380
- player.play()
381
- playIndex = (playIndex + 1) % players.count
382
- startCurrentTimeUpdates()
383
-
384
- // Start fade-in using the parent class method
385
- startVolumeRamp(from: 0, to: initialVolume, player: player)
386
- } else {
387
- if player.volume < initialVolume {
388
- // Continue fade-in if already in progress
389
- startVolumeRamp(from: player.volume, to: initialVolume, player: player)
299
+ player.seek(to: CMTimeMakeWithSeconds(time, preferredTimescale: 1)) { [weak self] _ in
300
+ guard let self else { return }
301
+ DispatchQueue.main.async {
302
+ if player.timeControlStatus != .playing {
303
+ player.volume = 0
304
+ player.play()
305
+ self.fadeIn(player: player, fadeInDuration: fadeInDuration, targetVolume: volume ?? self.initialVolume)
306
+ self.playIndex = (self.playIndex + 1) % self.players.count
307
+ self.startCurrentTimeUpdates()
308
+ }
390
309
  }
391
310
  }
392
311
  }
393
312
  }
394
313
 
395
- override func stopWithFade() {
396
- owner?.executeOnAudioQueue { [weak self] in
397
- guard let self = self else { return }
314
+ override func playWithFade(time: TimeInterval) {
315
+ playWithFade(time: time, volume: nil, fadeInDuration: TimeInterval(Constant.DefaultFadeDuration))
316
+ }
398
317
 
318
+ override func stopWithFade(fadeOutDuration: TimeInterval, toPause: Bool = false) {
319
+ owner?.executeOnAudioQueue { [weak self] in
320
+ guard let self else { return }
399
321
  guard !players.isEmpty && playIndex < players.count else {
400
- stop()
322
+ if !toPause { stop() }
401
323
  return
402
324
  }
403
-
404
325
  let player = players[playIndex]
405
-
406
326
  if player.timeControlStatus == .playing {
407
- // Use parent class fade method
408
- startVolumeRamp(from: player.volume, to: 0, player: player)
409
-
410
- // Schedule the stop when fade is complete
411
- DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(self.FADEDELAY * 1000))) { [weak self, weak player] in
412
- guard let strongSelf = self, let strongPlayer = player else { return }
413
-
414
- if strongPlayer.volume < strongSelf.FADESTEP {
415
- strongSelf.stop()
416
- }
417
- }
418
- } else {
327
+ fadeOut(player: player, fadeOutDuration: fadeOutDuration, toPause: toPause)
328
+ } else if !toPause {
419
329
  stop()
420
330
  }
421
331
  }
422
332
  }
423
333
 
424
- static func clearCache() {
425
- DispatchQueue.global(qos: .background).sync {
426
- let urls = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
427
- if let cachePath = urls.first {
428
- do {
429
- let fileURLs = try FileManager.default.contentsOfDirectory(at: cachePath, includingPropertiesForKeys: nil)
430
- // Clear all audio file types
431
- let audioExtensions = ["mp3", "wav", "aac", "m4a", "ogg", "mp4", "caf", "aiff"]
432
- for fileURL in fileURLs where audioExtensions.contains(fileURL.pathExtension.lowercased()) {
433
- try FileManager.default.removeItem(at: fileURL)
434
- }
435
- } catch {
436
- print("Error clearing audio cache: \(error)")
437
- }
438
- }
439
- }
334
+ override func stopWithFade() {
335
+ stopWithFade(fadeOutDuration: TimeInterval(Constant.DefaultFadeDuration), toPause: false)
440
336
  }
441
337
 
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.
449
- private func startVolumeRamp(from startVolume: Float, to endVolume: Float, player: AVPlayer) {
450
- player.volume = startVolume
451
-
452
- // Calculate steps
453
- let steps = abs(endVolume - startVolume) / FADESTEP
454
- guard steps > 0 else { return }
455
-
456
- let timeInterval = FADEDELAY / steps
457
- var currentStep = 0
458
- let totalSteps = Int(ceil(steps))
459
-
460
- stopFadeTimer()
461
-
462
- // Ensure timer creation happens on main thread
463
- DispatchQueue.main.async { [weak self, weak player] in
464
- guard let self = self else { return }
465
-
466
- self.fadeTimer = Timer.scheduledTimer(withTimeInterval: TimeInterval(timeInterval), repeats: true) { [weak self, weak player] timer in
467
- guard let strongPlayer = player, let strongSelf = self else {
468
- timer.invalidate()
469
- return
470
- }
471
-
472
- currentStep += 1
473
- let progress = Float(currentStep) / Float(totalSteps)
474
- let newVolume = startVolume + progress * (endVolume - startVolume)
475
-
476
- strongPlayer.volume = newVolume
477
-
478
- if currentStep >= totalSteps {
479
- strongPlayer.volume = endVolume
480
- timer.invalidate()
481
-
482
- // Update timer reference on main thread
483
- DispatchQueue.main.async {
484
- strongSelf.fadeTimer = nil
485
- }
486
- }
487
- }
488
-
489
- if let timer = self.fadeTimer {
490
- RunLoop.current.add(timer, forMode: .common)
491
- }
492
- }
493
- }
494
338
  }