@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.
- package/android/build.gradle +7 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +99 -87
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
- package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
- package/android/src/test/resources/robolectric.properties +1 -0
- package/ios/TrackPlayer.swift +47 -101
- package/ios/TrackPlayerBridge.mm +2 -0
- package/ios/player/AVPlayerEngine.swift +47 -35
- package/ios/player/AudioCache.swift +34 -0
- package/ios/player/AudioPlayer.swift +70 -22
- package/ios/player/CacheProxyServer.swift +429 -0
- package/ios/player/DownloadCoordinator.swift +242 -0
- package/ios/player/Preloader.swift +21 -90
- package/ios/player/SleepTimerController.swift +147 -0
- package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
- package/ios/tests/AudioPlayerTests.swift +6 -0
- package/ios/tests/CacheProxyServerTests.swift +403 -0
- package/ios/tests/DownloadCoordinatorTests.swift +197 -0
- package/ios/tests/LocalAudioServer.swift +171 -0
- package/ios/tests/MockPlayerEngine.swift +1 -0
- package/ios/tests/QueueManagerTests.swift +6 -0
- package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
- package/ios/tests/SleepTimerTests.swift +70 -0
- package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
- package/lib/commonjs/audio.js +19 -0
- package/lib/commonjs/audio.js.map +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
- package/lib/module/NativeTrackPlayer.js.map +1 -1
- package/lib/module/audio.js +17 -0
- package/lib/module/audio.js.map +1 -1
- package/lib/module/interfaces/PlayerConfig.js +1 -1
- package/lib/module/interfaces/PlayerConfig.js.map +1 -1
- package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
- package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
- package/lib/typescript/src/audio.d.ts +12 -1
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts +21 -2
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/NativeTrackPlayer.ts +4 -0
- package/src/audio.ts +18 -0
- package/src/interfaces/MediaItem.ts +4 -1
- package/src/interfaces/PlayerConfig.ts +24 -3
- 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
|
|
27
|
-
private let
|
|
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
|
|
63
|
-
let bytes = cache.cachedBytes(for:
|
|
64
|
-
guard bytes > 0
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
|
142
|
-
|
|
143
|
-
let
|
|
144
|
-
|
|
145
|
-
asset = AVURLAsset(url:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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:
|
|
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?.
|
|
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
|
-
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|