@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.
- 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 +46 -101
- package/ios/TrackPlayerBridge.mm +2 -0
- package/ios/player/AVPlayerEngine.swift +46 -32
- package/ios/player/AudioCache.swift +34 -0
- package/ios/player/AudioPlayer.swift +36 -21
- 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 +19 -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 +22 -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
|
}
|
|
@@ -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
|
|
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
|
|
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
|
|
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:
|
|
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?.
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
let
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|