@rntp/player 5.0.0-beta.4 → 5.0.0-beta.6

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 (56) 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 +99 -87
  5. package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
  6. package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
  7. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
  8. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
  9. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
  10. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
  11. package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
  12. package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
  13. package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
  14. package/android/src/test/resources/robolectric.properties +1 -0
  15. package/ios/TrackPlayer.swift +47 -101
  16. package/ios/TrackPlayerBridge.mm +2 -0
  17. package/ios/player/AVPlayerEngine.swift +47 -35
  18. package/ios/player/AudioCache.swift +34 -0
  19. package/ios/player/AudioPlayer.swift +70 -22
  20. package/ios/player/CacheProxyServer.swift +429 -0
  21. package/ios/player/DownloadCoordinator.swift +242 -0
  22. package/ios/player/Preloader.swift +21 -90
  23. package/ios/player/SleepTimerController.swift +147 -0
  24. package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
  25. package/ios/tests/AudioPlayerTests.swift +6 -0
  26. package/ios/tests/CacheProxyServerTests.swift +403 -0
  27. package/ios/tests/DownloadCoordinatorTests.swift +197 -0
  28. package/ios/tests/LocalAudioServer.swift +171 -0
  29. package/ios/tests/MockPlayerEngine.swift +1 -0
  30. package/ios/tests/QueueManagerTests.swift +6 -0
  31. package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
  32. package/ios/tests/SleepTimerTests.swift +70 -0
  33. package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
  34. package/lib/commonjs/audio.js +19 -0
  35. package/lib/commonjs/audio.js.map +1 -1
  36. package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
  37. package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
  38. package/lib/module/NativeTrackPlayer.js.map +1 -1
  39. package/lib/module/audio.js +17 -0
  40. package/lib/module/audio.js.map +1 -1
  41. package/lib/module/interfaces/PlayerConfig.js +1 -1
  42. package/lib/module/interfaces/PlayerConfig.js.map +1 -1
  43. package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
  44. package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
  45. package/lib/typescript/src/audio.d.ts +12 -1
  46. package/lib/typescript/src/audio.d.ts.map +1 -1
  47. package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
  48. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  49. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +21 -2
  50. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  51. package/package.json +4 -1
  52. package/src/NativeTrackPlayer.ts +4 -0
  53. package/src/audio.ts +18 -0
  54. package/src/interfaces/MediaItem.ts +4 -1
  55. package/src/interfaces/PlayerConfig.ts +24 -3
  56. package/ios/player/CachingResourceLoader.swift +0 -273
@@ -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
  }
@@ -228,9 +248,7 @@ class AVPlayerEngine: PlayerEngine {
228
248
  }
229
249
 
230
250
  if reason == .oldDeviceUnavailable {
231
- if isPlaying {
232
- pause()
233
- }
251
+ pause()
234
252
  }
235
253
  }
236
254
  #endif
@@ -260,14 +278,15 @@ class AVPlayerEngine: PlayerEngine {
260
278
  private func observeItem(_ item: AVPlayerItem) {
261
279
  itemStatusObservation = item.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in
262
280
  DispatchQueue.main.async {
281
+ guard let self = self else { return }
263
282
  switch item.status {
264
283
  case .readyToPlay:
265
- self?.emitAssetMetadata(from: item)
284
+ self.emitAssetMetadata(from: item)
266
285
  case .failed:
267
286
  let nsError = item.error as NSError?
268
287
  let code = Self.classifyError(nsError)
269
288
  let message = nsError?.localizedDescription ?? "Unknown playback error"
270
- self?.onItemFailed?(code, message)
289
+ self.onItemFailed?(code, message)
271
290
  default:
272
291
  break
273
292
  }
@@ -341,15 +360,8 @@ class AVPlayerEngine: PlayerEngine {
341
360
  // MARK: - Asset Metadata
342
361
 
343
362
  /// 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
363
  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
- }
364
+ let asset = item.asset as! AVURLAsset
353
365
  asset.loadValuesAsynchronously(forKeys: ["commonMetadata"]) { [weak self] in
354
366
  var error: NSError?
355
367
  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,14 @@ class AudioPlayer {
22
22
 
23
23
  private let engine: PlayerEngine
24
24
  private let queue = QueueManager()
25
- private var preloader: Preloader?
25
+ private let cache: AudioCache?
26
+ private(set) var preloader: Preloader?
26
27
  #if canImport(UIKit)
27
28
  private let artworkCache = NSCache<NSString, UIImage>()
28
29
  #endif
29
30
 
31
+ var preloadWindow: Int = 0
32
+
30
33
  private static let previousThreshold: Double = 3.0
31
34
  private var lastEmittedMetadata: StreamMetadata?
32
35
  private var pendingItemMetadata: StreamMetadata?
@@ -77,20 +80,34 @@ class AudioPlayer {
77
80
 
78
81
  // MARK: - Init
79
82
 
80
- init(engine: PlayerEngine, cache: AudioCache? = nil) {
83
+ init(engine: PlayerEngine, cache: AudioCache? = nil, coordinator: DownloadCoordinator? = nil) {
81
84
  self.engine = engine
82
- if let cache = cache {
83
- self.preloader = Preloader(cache: cache)
85
+ self.cache = cache
86
+ if let cache = cache, let coordinator = coordinator {
87
+ self.preloader = Preloader(cache: cache, coordinator: coordinator)
88
+ }
89
+ coordinator?.onDownloadComplete = { [weak self] key, isFull in
90
+ guard let self = self, isFull else { return }
91
+ if let engine = self.engine as? AVPlayerEngine,
92
+ engine.currentCacheKey == key {
93
+ DispatchQueue.main.async {
94
+ self.triggerAutoPreloadIfNeeded()
95
+ }
96
+ }
84
97
  }
85
98
  setupEngineCallbacks()
86
99
  }
87
100
 
101
+ #if os(iOS)
88
102
  convenience init(handleAudioBecomingNoisy: Bool = true, cache: AudioCache? = nil) {
103
+ let engine = AVPlayerEngine(handleAudioBecomingNoisy: handleAudioBecomingNoisy, cache: cache)
89
104
  self.init(
90
- engine: AVPlayerEngine(handleAudioBecomingNoisy: handleAudioBecomingNoisy, cache: cache),
91
- cache: cache
105
+ engine: engine,
106
+ cache: cache,
107
+ coordinator: engine.downloadCoordinator
92
108
  )
93
109
  }
110
+ #endif
94
111
 
95
112
  private func setupEngineCallbacks() {
96
113
  engine.onPlaybackStateChange = { [weak self] status in
@@ -160,6 +177,8 @@ class AudioPlayer {
160
177
  }
161
178
 
162
179
  // MARK: - Queue: Load / Set
180
+ // Note: Any method that changes the queue or current item must call
181
+ // triggerAutoPreloadIfNeeded() so the preload window stays in sync.
163
182
 
164
183
  func load(item: AudioItem) {
165
184
  queue.replaceCurrentItem(with: item)
@@ -173,6 +192,7 @@ class AudioPlayer {
173
192
  try? queue.jump(to: 0)
174
193
  loadCurrentItem()
175
194
  }
195
+ triggerAutoPreloadIfNeeded()
176
196
  }
177
197
 
178
198
  func add(items newItems: [AudioItem], at index: Int) throws {
@@ -182,6 +202,7 @@ class AudioPlayer {
182
202
  try queue.jump(to: 0)
183
203
  loadCurrentItem()
184
204
  }
205
+ triggerAutoPreloadIfNeeded()
185
206
  }
186
207
 
187
208
  // MARK: - Queue: Navigate
@@ -228,6 +249,7 @@ class AudioPlayer {
228
249
  }
229
250
  } else {
230
251
  try? queue.replaceItem(at: index, with: newItem)
252
+ triggerAutoPreloadIfNeeded()
231
253
  }
232
254
  }
233
255
 
@@ -254,6 +276,8 @@ class AudioPlayer {
254
276
  state = .idle
255
277
  clearNowPlayingInfo()
256
278
  }
279
+ } else {
280
+ triggerAutoPreloadIfNeeded()
257
281
  }
258
282
  }
259
283
 
@@ -265,10 +289,18 @@ class AudioPlayer {
265
289
  deactivateAudioSession()
266
290
  }
267
291
 
292
+ /// Cancel all active downloads (preloads and in-progress streaming).
293
+ func cancelAllDownloads() {
294
+ preloader?.cancelAll()
295
+ if let engine = engine as? AVPlayerEngine {
296
+ engine.downloadCoordinator?.cancelAll()
297
+ }
298
+ }
299
+
268
300
  // MARK: - Destroy
269
301
 
270
302
  func destroy() {
271
- preloader?.cancel()
303
+ preloader?.cancelAll()
272
304
  clear()
273
305
  onStateChange = nil
274
306
  onCurrentItemChanged = nil
@@ -313,23 +345,39 @@ class AudioPlayer {
313
345
  state = .loading
314
346
  updateNowPlayingMetadata(for: item)
315
347
  onCurrentItemChanged?(item, queue.currentIndex)
316
- if !item.isLive { preloadNextItem() }
317
- }
318
-
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
348
+ triggerAutoPreloadIfNeeded()
349
+ }
350
+
351
+ /// Check if auto preloading should trigger and start it if so.
352
+ /// Preloads run when the current item is live, a local file, fully cached
353
+ /// on disk, or when the queue changes while one of those is already true.
354
+ private func triggerAutoPreloadIfNeeded() {
355
+ guard preloadWindow > 0, let preloader = preloader else { return }
356
+
357
+ let shouldPreload: Bool
358
+ if let current = queue.current, (current.isLive || current.sourceType == .file) {
359
+ shouldPreload = true
360
+ } else if let engine = engine as? AVPlayerEngine,
361
+ let key = engine.currentCacheKey,
362
+ let cache = cache, cache.isFullyCached(key: key) {
363
+ shouldPreload = true
364
+ } else {
365
+ shouldPreload = false
325
366
  }
326
- let nextItem = queue.items[nextIndex]
327
- guard nextItem.sourceType == .stream,
328
- let url = URL(string: nextItem.sourceUrl) else {
329
- preloader.cancel()
330
- return
367
+ guard shouldPreload else { return }
368
+
369
+ preloader.cancelAll()
370
+
371
+ let startIndex = queue.currentIndex + 1
372
+ let endIndex = min(startIndex + preloadWindow, queue.items.count)
373
+ guard startIndex < endIndex else { return }
374
+
375
+ for i in startIndex..<endIndex {
376
+ let item = queue.items[i]
377
+ guard !item.isLive, item.sourceType == .stream,
378
+ let url = URL(string: item.sourceUrl) else { continue }
379
+ preloader.preload(url: url, headers: item.headers)
331
380
  }
332
- preloader.preload(url: url, headers: nextItem.headers)
333
381
  }
334
382
 
335
383
  // MARK: - State Handling