@rntp/player 5.0.0-beta.4 → 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 (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 +46 -101
  16. package/ios/TrackPlayerBridge.mm +2 -0
  17. package/ios/player/AVPlayerEngine.swift +46 -32
  18. package/ios/player/AudioCache.swift +34 -0
  19. package/ios/player/AudioPlayer.swift +36 -21
  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 +19 -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 +22 -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
  }
@@ -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