@rntp/player 5.0.0-beta.3 → 5.0.0-beta.5

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.
Files changed (61) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
  3. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
  4. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +107 -87
  5. package/android/src/main/java/com/doublesymmetry/trackplayer/models/BrowseTree.kt +51 -20
  6. package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
  7. package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
  8. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
  9. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
  10. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
  11. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
  12. package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
  13. package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
  14. package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
  15. package/android/src/test/resources/robolectric.properties +1 -0
  16. package/ios/CarPlay/RNTPCarPlaySceneDelegate.swift +43 -14
  17. package/ios/TrackPlayer.swift +46 -101
  18. package/ios/TrackPlayerBridge.mm +2 -0
  19. package/ios/player/AVPlayerEngine.swift +46 -32
  20. package/ios/player/AudioCache.swift +34 -0
  21. package/ios/player/AudioPlayer.swift +36 -21
  22. package/ios/player/CacheProxyServer.swift +429 -0
  23. package/ios/player/DownloadCoordinator.swift +242 -0
  24. package/ios/player/Preloader.swift +21 -90
  25. package/ios/player/SleepTimerController.swift +147 -0
  26. package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
  27. package/ios/tests/AudioPlayerTests.swift +6 -0
  28. package/ios/tests/CacheProxyServerTests.swift +403 -0
  29. package/ios/tests/DownloadCoordinatorTests.swift +197 -0
  30. package/ios/tests/LocalAudioServer.swift +171 -0
  31. package/ios/tests/MockPlayerEngine.swift +1 -0
  32. package/ios/tests/QueueManagerTests.swift +6 -0
  33. package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
  34. package/ios/tests/SleepTimerTests.swift +70 -0
  35. package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
  36. package/lib/commonjs/audio.js +39 -4
  37. package/lib/commonjs/audio.js.map +1 -1
  38. package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
  39. package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
  40. package/lib/module/NativeTrackPlayer.js.map +1 -1
  41. package/lib/module/audio.js +37 -4
  42. package/lib/module/audio.js.map +1 -1
  43. package/lib/module/interfaces/PlayerConfig.js +1 -1
  44. package/lib/module/interfaces/PlayerConfig.js.map +1 -1
  45. package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
  46. package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
  47. package/lib/typescript/src/audio.d.ts +16 -4
  48. package/lib/typescript/src/audio.d.ts.map +1 -1
  49. package/lib/typescript/src/interfaces/BrowseTree.d.ts +35 -5
  50. package/lib/typescript/src/interfaces/BrowseTree.d.ts.map +1 -1
  51. package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
  52. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  53. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +19 -2
  54. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  55. package/package.json +4 -1
  56. package/src/NativeTrackPlayer.ts +4 -0
  57. package/src/audio.ts +37 -4
  58. package/src/interfaces/BrowseTree.ts +40 -5
  59. package/src/interfaces/MediaItem.ts +4 -1
  60. package/src/interfaces/PlayerConfig.ts +22 -3
  61. package/ios/player/CachingResourceLoader.swift +0 -273
@@ -22,13 +22,7 @@ class TrackPlayer: RCTEventEmitter {
22
22
  private var progressSyncHttpUrl: String?
23
23
  private var progressSyncHttpHeaders: [String: String]?
24
24
 
25
- private var sleepTimerType: String? // "time" or "mediaItem"
26
- private var sleepTimerRemainingSeconds: Double = 0
27
- private var sleepTimerFadeOutSeconds: Double = 0
28
- private var sleepTimerTargetIndex: Int?
29
- private var sleepTimer: Timer?
30
- private var sleepTimerPreFadeVolume: Float?
31
- private var sleepTimerPreviousIndex: Int?
25
+ private var sleepTimerController: SleepTimerController?
32
26
 
33
27
  // MARK: - Initializers
34
28
 
@@ -71,19 +65,29 @@ class TrackPlayer: RCTEventEmitter {
71
65
  try? AVAudioSession.sharedInstance().setCategory(.playback, mode: mode)
72
66
 
73
67
  var cache: AudioCache? = nil
68
+ var preloadWindow = 0
74
69
  if let cacheConfig = config["cache"] as? [String: Any] {
75
- let maxSize = (cacheConfig["maxSizeBytes"] as? NSNumber)?.int64Value ?? (100 * 1024 * 1024)
70
+ let maxSize = (cacheConfig["maxSizeBytes"] as? NSNumber)?.int64Value ?? (500 * 1024 * 1024)
76
71
  cache = AudioCache(maxSizeBytes: maxSize)
72
+ if let preloadConfig = cacheConfig["preloading"] as? [String: Any] {
73
+ preloadWindow = (preloadConfig["window"] as? NSNumber)?.intValue ?? 0
74
+ }
77
75
  }
78
76
  audioCache = cache
79
77
 
80
78
  let handleNoisy = config["handleAudioBecomingNoisy"] as? Bool ?? true
81
79
  player = AudioPlayer(handleAudioBecomingNoisy: handleNoisy, cache: cache)
80
+ player.preloadWindow = preloadWindow
82
81
  bindPlayerCallbacks()
83
82
  BrowseTreeStore.shared.player = player
84
83
  lastEmittedStateString = nil
85
84
  lastIsPlaying = false
86
85
 
86
+ sleepTimerController = SleepTimerController(player: player)
87
+ sleepTimerController?.onTriggered = { [weak self] type in
88
+ self?.emitEvent(event: SleepTimerTriggeredEvent(sleepType: type))
89
+ }
90
+
87
91
  if let progressSync = config["progressSync"] as? [String: Any] {
88
92
  progressSyncIntervalSeconds = progressSync["intervalSeconds"] as? Double ?? 0
89
93
  if let http = progressSync["http"] as? [String: Any] {
@@ -152,6 +156,28 @@ class TrackPlayer: RCTEventEmitter {
152
156
  audioCache?.removeAll()
153
157
  }
154
158
 
159
+ @objc(preload:duration:)
160
+ func preload(item: [String: Any], duration: Double) {
161
+ guard let urlString = extractUrl(from: item),
162
+ let url = URL(string: urlString) else { return }
163
+
164
+ let headers = item["headers"] as? [String: String]
165
+ player.preloader?.preload(url: url, headers: headers)
166
+ }
167
+
168
+ @objc(cancelPreload:)
169
+ func cancelPreload(item: [String: Any]) {
170
+ guard let urlString = extractUrl(from: item),
171
+ let url = URL(string: urlString) else { return }
172
+ player.preloader?.cancel(url: url)
173
+ }
174
+
175
+ private func extractUrl(from item: [String: Any]) -> String? {
176
+ if let url = item["url"] as? String { return url }
177
+ if let urlObj = item["url"] as? [String: Any], let uri = urlObj["uri"] as? String { return uri }
178
+ return nil
179
+ }
180
+
155
181
  @objc(setPlaybackSpeed:)
156
182
  func setPlaybackSpeed(speed: Double) {
157
183
  player.rate = Float(speed)
@@ -233,8 +259,8 @@ class TrackPlayer: RCTEventEmitter {
233
259
  @objc(clear)
234
260
  func clear() {
235
261
  player.clear()
236
- if sleepTimerType == "mediaItem" {
237
- cancelSleepTimerInternal(restoreVolume: false)
262
+ if sleepTimerController?.sleepTimerType == "mediaItem" {
263
+ sleepTimerController?.cancelInternal(restoreVolume: false)
238
264
  }
239
265
  emitEvent(event: QueueChangedEvent())
240
266
  }
@@ -413,101 +439,24 @@ class TrackPlayer: RCTEventEmitter {
413
439
 
414
440
  // MARK: - Sleep Timer
415
441
 
416
- private func cancelSleepTimerInternal(restoreVolume: Bool) {
417
- sleepTimer?.invalidate()
418
- sleepTimer = nil
419
- if restoreVolume, let preFadeVolume = sleepTimerPreFadeVolume {
420
- player.volume = preFadeVolume
421
- }
422
- sleepTimerPreFadeVolume = nil
423
- sleepTimerType = nil
424
- sleepTimerRemainingSeconds = 0
425
- sleepTimerFadeOutSeconds = 0
426
- sleepTimerTargetIndex = nil
427
- sleepTimerPreviousIndex = nil
428
- }
429
-
430
- private func onSleepTimerTick() {
431
- sleepTimerRemainingSeconds -= 1
432
-
433
- // Handle fade-out (before zero-check so final tick sets volume to 0)
434
- if sleepTimerFadeOutSeconds > 0 && sleepTimerRemainingSeconds < sleepTimerFadeOutSeconds {
435
- if sleepTimerPreFadeVolume == nil {
436
- sleepTimerPreFadeVolume = player.volume
437
- }
438
- let progress = max(0, sleepTimerRemainingSeconds) / sleepTimerFadeOutSeconds
439
- player.volume = (sleepTimerPreFadeVolume ?? 1.0) * Float(progress)
440
- }
441
-
442
- if sleepTimerRemainingSeconds <= 0 {
443
- sleepTimerRemainingSeconds = 0
444
- player.pause()
445
- emitEvent(event: SleepTimerTriggeredEvent(sleepType: "time"))
446
- // Restore volume after pausing so next playback isn't muted
447
- cancelSleepTimerInternal(restoreVolume: true)
448
- return
449
- }
450
- }
451
-
452
- private func startSleepCountdownTimer() {
453
- sleepTimer?.invalidate()
454
- let timer = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
455
- self?.onSleepTimerTick()
456
- }
457
- RunLoop.main.add(timer, forMode: .common)
458
- sleepTimer = timer
459
- }
460
-
461
- private func pauseSleepCountdownTimer() {
462
- sleepTimer?.invalidate()
463
- sleepTimer = nil
464
- }
465
-
466
442
  @objc(sleepAfterTime:fadeOutSeconds:)
467
443
  func sleepAfterTime(seconds: Double, fadeOutSeconds: Double) {
468
- cancelSleepTimerInternal(restoreVolume: true)
469
- sleepTimerType = "time"
470
- sleepTimerRemainingSeconds = seconds
471
- sleepTimerFadeOutSeconds = min(fadeOutSeconds, seconds)
472
-
473
- if seconds <= 0 {
474
- player.pause()
475
- emitEvent(event: SleepTimerTriggeredEvent(sleepType: "time"))
476
- cancelSleepTimerInternal(restoreVolume: true)
477
- return
478
- }
479
-
480
- startSleepCountdownTimer()
444
+ sleepTimerController?.sleepAfterTime(seconds: seconds, fadeOutSeconds: fadeOutSeconds)
481
445
  }
482
446
 
483
447
  @objc(sleepAfterMediaItemAtIndex:)
484
448
  func sleepAfterMediaItemAtIndex(index: Double) {
485
- cancelSleepTimerInternal(restoreVolume: true)
486
- sleepTimerType = "mediaItem"
487
- sleepTimerTargetIndex = Int(index)
488
- sleepTimerPreviousIndex = player.currentIndex
449
+ sleepTimerController?.sleepAfterMediaItemAtIndex(index: Int(index))
489
450
  }
490
451
 
491
452
  @objc(getSleepTimer)
492
453
  func getSleepTimer() -> [String: Any]? {
493
- guard let type = sleepTimerType else { return nil }
494
- if type == "time" {
495
- return [
496
- "type": "time",
497
- "remainingSeconds": sleepTimerRemainingSeconds,
498
- "fadeOutSeconds": sleepTimerFadeOutSeconds
499
- ]
500
- } else {
501
- return [
502
- "type": "mediaItem",
503
- "index": sleepTimerTargetIndex ?? 0
504
- ]
505
- }
454
+ return sleepTimerController?.getState()
506
455
  }
507
456
 
508
457
  @objc(cancelSleepTimer)
509
458
  func cancelSleepTimer() {
510
- cancelSleepTimerInternal(restoreVolume: true)
459
+ sleepTimerController?.cancel()
511
460
  }
512
461
 
513
462
  // MARK: - Browse Tree
@@ -522,7 +471,8 @@ class TrackPlayer: RCTEventEmitter {
522
471
  @objc(destroy)
523
472
  func destroy() {
524
473
  BrowseTreeStore.shared.clear()
525
- cancelSleepTimerInternal(restoreVolume: false)
474
+ sleepTimerController?.cancel()
475
+ sleepTimerController = nil
526
476
  stopProgressSyncTimer(fireFinalTick: false)
527
477
  let commandCenter = MPRemoteCommandCenter.shared()
528
478
  commandCenter.togglePlayPauseCommand.removeTarget(nil)
@@ -580,17 +530,12 @@ class TrackPlayer: RCTEventEmitter {
580
530
 
581
531
  func handleCurrentItemChanged(item: AudioItem?, index: Int) {
582
532
  // Sleep timer: check if the target item just finished
583
- if sleepTimerType == "mediaItem", let targetIndex = sleepTimerTargetIndex {
584
- if sleepTimerPreviousIndex == targetIndex && index != targetIndex {
585
- emitEvent(event: SleepTimerTriggeredEvent(sleepType: "mediaItem"))
586
- cancelSleepTimerInternal(restoreVolume: false)
587
- // Defer pause to next run loop — calling during transition gets overridden
588
- DispatchQueue.main.async { [weak self] in
589
- self?.player.pause()
590
- }
533
+ if sleepTimerController?.handleItemTransition(to: index) == true {
534
+ // Defer pause to next run loop calling during transition gets overridden
535
+ DispatchQueue.main.async { [weak self] in
536
+ self?.player.pause()
591
537
  }
592
538
  }
593
- sleepTimerPreviousIndex = index
594
539
  let mediaItem = item as? MediaItem
595
540
  let dict = mediaItem?.toDictionary()
596
541
  BrowseTreeStore.shared.updateNowPlaying(mediaId: mediaItem?.mediaId)
@@ -29,6 +29,8 @@ RCT_EXTERN_METHOD(skipToPrevious);
29
29
  RCT_EXTERN_METHOD(skipToIndex:(double)index);
30
30
  RCT_EXTERN_METHOD(retry);
31
31
  RCT_EXTERN_METHOD(clearCache);
32
+ RCT_EXTERN_METHOD(preload:(NSDictionary *)item duration:(double)duration);
33
+ RCT_EXTERN_METHOD(cancelPreload:(NSDictionary *)item);
32
34
  RCT_EXTERN_METHOD(setPlaybackSpeed:(double)speed);
33
35
  RCT_EXTERN_METHOD(setVolume:(double)volume);
34
36
 
@@ -23,8 +23,14 @@ class AVPlayerEngine: PlayerEngine {
23
23
 
24
24
  // Caching
25
25
  private let cache: AudioCache?
26
- private var currentResourceLoader: CachingResourceLoader?
27
- private let resourceLoaderQueue = DispatchQueue(label: "trackplayer.resourceloader")
26
+ private let proxyServer: CacheProxyServer?
27
+ private let _downloadCoordinator: DownloadCoordinator?
28
+
29
+ /// Exposed so AudioPlayer can pass it to the Preloader.
30
+ var downloadCoordinator: DownloadCoordinator? { _downloadCoordinator }
31
+
32
+ /// Cache key for the currently loaded URL, used for cachedPosition.
33
+ private(set) var currentCacheKey: String?
28
34
 
29
35
  // MARK: - Callbacks
30
36
 
@@ -59,14 +65,20 @@ class AVPlayerEngine: PlayerEngine {
59
65
  }
60
66
 
61
67
  var cachedPosition: Double {
62
- guard let cache = cache, let loader = currentResourceLoader else { return 0 }
63
- let bytes = cache.cachedBytes(for: loader.cacheKey)
64
- guard bytes > 0,
65
- let info = cache.contentInfo(for: loader.cacheKey),
66
- info.contentLength > 0 else { return 0 }
67
- let dur = duration
68
- guard dur > 0 else { return 0 }
69
- return (Double(bytes) / Double(info.contentLength)) * dur
68
+ guard let cache = cache, let key = currentCacheKey else { return 0 }
69
+ let bytes = cache.cachedBytes(for: key)
70
+ guard bytes > 0 else { return 0 }
71
+
72
+ if let info = cache.contentInfo(for: key), info.contentLength > 0 {
73
+ let dur = duration
74
+ guard dur > 0 else { return 0 }
75
+ return (Double(bytes) / Double(info.contentLength)) * dur
76
+ }
77
+
78
+ // Content length unknown (download in progress, no response yet).
79
+ // Fall back to bufferedPosition — since AVPlayer streams through
80
+ // the proxy, what's buffered approximates what's cached.
81
+ return bufferedPosition
70
82
  }
71
83
 
72
84
  var defaultRate: Float {
@@ -99,6 +111,17 @@ class AVPlayerEngine: PlayerEngine {
99
111
  init(handleAudioBecomingNoisy: Bool = true, cache: AudioCache? = nil) {
100
112
  self.handleAudioBecomingNoisy = handleAudioBecomingNoisy
101
113
  self.cache = cache
114
+
115
+ if let cache = cache {
116
+ let coordinator = DownloadCoordinator(cache: cache)
117
+ self._downloadCoordinator = coordinator
118
+ self.proxyServer = try? CacheProxyServer(coordinator: coordinator, cache: cache)
119
+ self.proxyServer?.start()
120
+ } else {
121
+ self._downloadCoordinator = nil
122
+ self.proxyServer = nil
123
+ }
124
+
102
125
  avPlayer.allowsExternalPlayback = true
103
126
  avPlayer.usesExternalPlaybackWhileExternalScreenIsActive = true
104
127
  observeTimeControlStatus()
@@ -111,7 +134,7 @@ class AVPlayerEngine: PlayerEngine {
111
134
  }
112
135
 
113
136
  deinit {
114
- currentResourceLoader?.invalidate()
137
+ proxyServer?.stop()
115
138
  timeControlObservation?.invalidate()
116
139
  removeItemObservers()
117
140
  if let observer = interruptionObserver {
@@ -133,20 +156,18 @@ class AVPlayerEngine: PlayerEngine {
133
156
  }
134
157
 
135
158
  func load(url: URL, headers: [String: String]? = nil, isLive: Bool = false) {
136
- currentResourceLoader?.invalidate()
137
- currentResourceLoader = nil
138
-
139
159
  let asset: AVURLAsset
140
160
 
141
- if !isLive, let cache = cache, url.scheme?.hasPrefix("http") == true,
142
- let cacheURL = CachingResourceLoader.cacheURL(from: url) {
143
- let loader = CachingResourceLoader(url: url, headers: headers, cache: cache, queue: resourceLoaderQueue)
144
- currentResourceLoader = loader
145
- asset = AVURLAsset(url: cacheURL)
146
- asset.resourceLoader.setDelegate(loader, queue: resourceLoaderQueue)
161
+ if !isLive, let proxy = proxyServer, let cache = cache,
162
+ url.scheme?.hasPrefix("http") == true {
163
+ let proxyURL = proxy.proxyURL(for: url, headers: headers)
164
+ currentCacheKey = cache.cacheKey(for: url)
165
+ asset = AVURLAsset(url: proxyURL)
147
166
  } else if let headers = headers, !headers.isEmpty {
167
+ currentCacheKey = nil
148
168
  asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers])
149
169
  } else {
170
+ currentCacheKey = nil
150
171
  asset = AVURLAsset(url: url)
151
172
  }
152
173
 
@@ -162,8 +183,7 @@ class AVPlayerEngine: PlayerEngine {
162
183
  }
163
184
 
164
185
  func reset() {
165
- currentResourceLoader?.invalidate()
166
- currentResourceLoader = nil
186
+ currentCacheKey = nil
167
187
  removeItemObservers()
168
188
  avPlayer.replaceCurrentItem(with: nil)
169
189
  }
@@ -260,14 +280,15 @@ class AVPlayerEngine: PlayerEngine {
260
280
  private func observeItem(_ item: AVPlayerItem) {
261
281
  itemStatusObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in
262
282
  DispatchQueue.main.async {
283
+ guard let self = self else { return }
263
284
  switch item.status {
264
285
  case .readyToPlay:
265
- self?.emitAssetMetadata(from: item)
286
+ self.emitAssetMetadata(from: item)
266
287
  case .failed:
267
288
  let nsError = item.error as NSError?
268
289
  let code = Self.classifyError(nsError)
269
290
  let message = nsError?.localizedDescription ?? "Unknown playback error"
270
- self?.onItemFailed?(code, message)
291
+ self.onItemFailed?(code, message)
271
292
  default:
272
293
  break
273
294
  }
@@ -341,15 +362,8 @@ class AVPlayerEngine: PlayerEngine {
341
362
  // MARK: - Asset Metadata
342
363
 
343
364
  /// Extracts static metadata (ID3, etc.) from the asset when the item becomes ready.
344
- /// When caching is active the playback asset uses a custom URL scheme, so we use
345
- /// the original HTTP URL for metadata extraction instead.
346
365
  private func emitAssetMetadata(from item: AVPlayerItem) {
347
- let asset: AVURLAsset
348
- if let loader = currentResourceLoader {
349
- asset = AVURLAsset(url: loader.originalURL)
350
- } else {
351
- asset = item.asset as! AVURLAsset
352
- }
366
+ let asset = item.asset as! AVURLAsset
353
367
  asset.loadValuesAsynchronously(forKeys: ["commonMetadata"]) { [weak self] in
354
368
  var error: NSError?
355
369
  let status = asset.statusOfValue(forKey: "commonMetadata", error: &error)
@@ -117,6 +117,40 @@ final class AudioCache: @unchecked Sendable {
117
117
  }
118
118
  }
119
119
 
120
+ // MARK: - Debug
121
+
122
+ /// Dumps the full cache state to the console for debugging.
123
+ func dumpState(currentKey: String? = nil) {
124
+ queue.sync {
125
+ let fm = FileManager.default
126
+ guard let contents = try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: nil) else {
127
+ print("[AudioCache] empty or unreadable")
128
+ return
129
+ }
130
+
131
+ let metaFiles = contents.filter { $0.pathExtension == "meta" }
132
+ let totalSize = metaFiles.reduce(Int64(0)) { total, metaURL in
133
+ let key = metaURL.deletingPathExtension().lastPathComponent
134
+ return total + dataFileSize(for: key)
135
+ }
136
+
137
+ print("[AudioCache] \(metaFiles.count) items, \(totalSize / 1024 / 1024)MB / \(maxSize / 1024 / 1024)MB max")
138
+
139
+ for metaURL in metaFiles {
140
+ let key = metaURL.deletingPathExtension().lastPathComponent
141
+ let m = meta(for: key)
142
+ let size = dataFileSize(for: key)
143
+ let cl = m?.contentInfo?.contentLength ?? -1
144
+ let ct = m?.contentInfo?.contentType ?? "?"
145
+ let url = m?.url ?? "?"
146
+ let pct = cl > 0 ? Int(Double(size) / Double(cl) * 100) : -1
147
+ let isCurrent = (key == currentKey) ? " ← CURRENT" : ""
148
+ let urlShort = url.count > 60 ? "…" + url.suffix(55) : url
149
+ print(" [\(key.prefix(8))…] \(size / 1024)KB / \(cl > 0 ? "\(cl / 1024)KB" : "??") (\(pct)%) \(ct) \(urlShort)\(isCurrent)")
150
+ }
151
+ }
152
+ }
153
+
120
154
  // MARK: - Internal File Helpers
121
155
 
122
156
  private func dataFileURL(for key: String) -> URL {
@@ -22,11 +22,13 @@ class AudioPlayer {
22
22
 
23
23
  private let engine: PlayerEngine
24
24
  private let queue = QueueManager()
25
- private var preloader: Preloader?
25
+ private(set) var preloader: Preloader?
26
26
  #if canImport(UIKit)
27
27
  private let artworkCache = NSCache<NSString, UIImage>()
28
28
  #endif
29
29
 
30
+ var preloadWindow: Int = 0
31
+
30
32
  private static let previousThreshold: Double = 3.0
31
33
  private var lastEmittedMetadata: StreamMetadata?
32
34
  private var pendingItemMetadata: StreamMetadata?
@@ -77,20 +79,33 @@ class AudioPlayer {
77
79
 
78
80
  // MARK: - Init
79
81
 
80
- init(engine: PlayerEngine, cache: AudioCache? = nil) {
82
+ init(engine: PlayerEngine, cache: AudioCache? = nil, coordinator: DownloadCoordinator? = nil) {
81
83
  self.engine = engine
82
- if let cache = cache {
83
- self.preloader = Preloader(cache: cache)
84
+ if let cache = cache, let coordinator = coordinator {
85
+ self.preloader = Preloader(cache: cache, coordinator: coordinator)
86
+ }
87
+ coordinator?.onDownloadComplete = { [weak self] key, isFull in
88
+ guard let self = self, isFull else { return }
89
+ if let engine = self.engine as? AVPlayerEngine,
90
+ engine.currentCacheKey == key {
91
+ DispatchQueue.main.async {
92
+ self.onCurrentTrackCached()
93
+ }
94
+ }
84
95
  }
85
96
  setupEngineCallbacks()
86
97
  }
87
98
 
99
+ #if os(iOS)
88
100
  convenience init(handleAudioBecomingNoisy: Bool = true, cache: AudioCache? = nil) {
101
+ let engine = AVPlayerEngine(handleAudioBecomingNoisy: handleAudioBecomingNoisy, cache: cache)
89
102
  self.init(
90
- engine: AVPlayerEngine(handleAudioBecomingNoisy: handleAudioBecomingNoisy, cache: cache),
91
- cache: cache
103
+ engine: engine,
104
+ cache: cache,
105
+ coordinator: engine.downloadCoordinator
92
106
  )
93
107
  }
108
+ #endif
94
109
 
95
110
  private func setupEngineCallbacks() {
96
111
  engine.onPlaybackStateChange = { [weak self] status in
@@ -268,7 +283,7 @@ class AudioPlayer {
268
283
  // MARK: - Destroy
269
284
 
270
285
  func destroy() {
271
- preloader?.cancel()
286
+ preloader?.cancelAll()
272
287
  clear()
273
288
  onStateChange = nil
274
289
  onCurrentItemChanged = nil
@@ -313,23 +328,23 @@ class AudioPlayer {
313
328
  state = .loading
314
329
  updateNowPlayingMetadata(for: item)
315
330
  onCurrentItemChanged?(item, queue.currentIndex)
316
- if !item.isLive { preloadNextItem() }
317
331
  }
318
332
 
319
- private func preloadNextItem() {
320
- guard let preloader = preloader else { return }
321
- let nextIndex = queue.currentIndex + 1
322
- guard nextIndex < queue.items.count else {
323
- preloader.cancel()
324
- return
325
- }
326
- let nextItem = queue.items[nextIndex]
327
- guard nextItem.sourceType == .stream,
328
- let url = URL(string: nextItem.sourceUrl) else {
329
- preloader.cancel()
330
- return
333
+ /// Called when the current track's download completes (fully cached).
334
+ private func onCurrentTrackCached() {
335
+ guard preloadWindow > 0, let preloader = preloader else { return }
336
+ preloader.cancelAll()
337
+
338
+ let startIndex = queue.currentIndex + 1
339
+ let endIndex = min(startIndex + preloadWindow, queue.items.count)
340
+ guard startIndex < endIndex else { return }
341
+
342
+ for i in startIndex..<endIndex {
343
+ let item = queue.items[i]
344
+ guard !item.isLive, item.sourceType == .stream,
345
+ let url = URL(string: item.sourceUrl) else { continue }
346
+ preloader.preload(url: url, headers: item.headers)
331
347
  }
332
- preloader.preload(url: url, headers: nextItem.headers)
333
348
  }
334
349
 
335
350
  // MARK: - State Handling