@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
@@ -3,17 +3,52 @@
3
3
  * Commercial use requires a license. See https://rntp.dev/pricing
4
4
  */
5
5
 
6
- import type { MediaItem } from './MediaItem';
6
+ import type { MediaUrl } from './MediaItem';
7
7
 
8
8
  /**
9
- * A category in the media browse tree shown in Android Auto / media browsers.
10
- * Each category appears as a top-level tab/section; its items are playable leaves.
9
+ * An item in the browse tree either a playable track or a browsable container.
10
+ *
11
+ * - `url` present → playable track (leaf node)
12
+ * - `children` present (no `url`) → browsable container (tapping drills down)
13
+ * - `url` takes precedence if both are present
14
+ *
15
+ * Maximum nesting depth: 4 levels (matching CarPlay's navigation stack limit).
16
+ */
17
+ export interface BrowseItem {
18
+ /** Unique identifier for this item. */
19
+ mediaId: string;
20
+ /** Display title. */
21
+ title: string;
22
+ /** Artist or subtitle. */
23
+ artist?: string;
24
+ /** Artwork image URL. */
25
+ artworkUrl?: MediaUrl;
26
+
27
+ // Playable fields
28
+ /** Audio source URL. If present, this item is playable. */
29
+ url?: MediaUrl;
30
+ /** Duration hint in seconds. */
31
+ duration?: number;
32
+ /** Whether this is a live stream. */
33
+ isLive?: boolean;
34
+ /** MIME type hint for format detection. */
35
+ mimeType?: string;
36
+
37
+ // Browsable fields
38
+ /** Child items. If present (and no `url`), this item is browsable. */
39
+ children?: BrowseItem[];
40
+ }
41
+
42
+ /**
43
+ * A top-level category in the browse tree.
44
+ * On Android Auto, each category is a browsable folder.
45
+ * On CarPlay, each category is a tab (max 4; overflow creates a "More" tab).
11
46
  */
12
47
  export interface BrowseCategory {
13
48
  /** Unique identifier for this category. */
14
49
  mediaId: string;
15
50
  /** Display title for the category (e.g. "Albums", "Podcasts"). */
16
51
  title: string;
17
- /** Playable media items within this category. */
18
- items: MediaItem[];
52
+ /** Items within this category — can be playable tracks or browsable containers. */
53
+ items: BrowseItem[];
19
54
  }
@@ -34,7 +34,10 @@ export interface MediaItem {
34
34
  */
35
35
  duration?: number;
36
36
  /**
37
- * Whether this is a live stream (affects seek behavior).
37
+ * Whether this is a live stream.
38
+ *
39
+ * When `true`, caching and preloading are disabled and the stream is
40
+ * played directly.
38
41
  */
39
42
  isLive?: boolean;
40
43
  /**
@@ -9,17 +9,36 @@ import type {
9
9
  PerCommandHandling,
10
10
  } from './PlayerCommand';
11
11
 
12
+ export interface PreloadConfig {
13
+ /**
14
+ * Auto-preload next N tracks in queue after current track is fully cached.
15
+ * @default 0 (disabled)
16
+ */
17
+ window?: number;
18
+ }
19
+
20
+ export interface PreloadOptions {
21
+ /**
22
+ * Target duration in seconds. If omitted, preloads the entire file.
23
+ */
24
+ duration?: number;
25
+ }
26
+
12
27
  export interface CacheConfig {
13
28
  /**
14
29
  * Maximum disk cache size in bytes.
15
30
  * When the cache exceeds this size, least-recently-used items are evicted.
16
- * @default 104857600 (100 MB)
31
+ * @default 524288000 (500 MB)
17
32
  */
18
33
  maxSizeBytes?: number;
34
+ /**
35
+ * Preloading configuration. Requires cache to be enabled.
36
+ */
37
+ preloading?: PreloadConfig;
19
38
  }
20
39
 
21
- export const DEFAULT_CACHE_CONFIG: Required<CacheConfig> = {
22
- maxSizeBytes: 100 * 1024 * 1024,
40
+ export const DEFAULT_CACHE_CONFIG: Required<Omit<CacheConfig, 'preloading'>> = {
41
+ maxSizeBytes: 500 * 1024 * 1024,
23
42
  };
24
43
 
25
44
  /**
@@ -1,273 +0,0 @@
1
- //
2
- // Copyright (c) Double Symmetry GmbH
3
- // Commercial use requires a license. See https://rntp.dev/pricing
4
- //
5
-
6
- import AVFoundation
7
- import UniformTypeIdentifiers
8
-
9
- final class CachingResourceLoader: NSObject, AVAssetResourceLoaderDelegate, URLSessionDataDelegate {
10
-
11
- private let cache: AudioCache
12
- let originalURL: URL
13
- private let headers: [String: String]?
14
- let cacheKey: String
15
-
16
- private var pendingRequests: [AVAssetResourceLoadingRequest] = []
17
- private var session: URLSession!
18
- private var activeTask: URLSessionDataTask?
19
-
20
- /// Byte offset of the next data chunk the active download will deliver.
21
- private var downloadCursor: Int64 = 0
22
- /// True when the download started at the cache boundary, so incoming data
23
- /// can be appended sequentially.
24
- private var downloadIsCacheable: Bool = false
25
-
26
- // MARK: - Init
27
-
28
- init(url: URL, headers: [String: String]?, cache: AudioCache, queue: DispatchQueue) {
29
- self.cache = cache
30
- self.originalURL = url
31
- self.headers = headers
32
- self.cacheKey = cache.cacheKey(for: url)
33
- super.init()
34
-
35
- let opQueue = OperationQueue()
36
- opQueue.underlyingQueue = queue
37
- opQueue.maxConcurrentOperationCount = 1
38
- self.session = URLSession(configuration: .default, delegate: self, delegateQueue: opQueue)
39
- }
40
-
41
- func invalidate() {
42
- activeTask?.cancel()
43
- activeTask = nil
44
- session.invalidateAndCancel()
45
- for request in pendingRequests where !request.isFinished && !request.isCancelled {
46
- request.finishLoading(with: URLError(.cancelled))
47
- }
48
- pendingRequests.removeAll()
49
- }
50
-
51
- // MARK: - URL Scheme Helpers
52
-
53
- static func cacheURL(from original: URL) -> URL? {
54
- guard var components = URLComponents(url: original, resolvingAgainstBaseURL: false) else { return nil }
55
- guard let scheme = components.scheme else { return nil }
56
- components.scheme = "trackplayer-\(scheme)"
57
- return components.url
58
- }
59
-
60
- static func originalURL(from cacheURL: URL) -> URL? {
61
- guard var components = URLComponents(url: cacheURL, resolvingAgainstBaseURL: false) else { return nil }
62
- guard let scheme = components.scheme, scheme.hasPrefix("trackplayer-") else { return nil }
63
- components.scheme = String(scheme.dropFirst("trackplayer-".count))
64
- return components.url
65
- }
66
-
67
- // MARK: - AVAssetResourceLoaderDelegate
68
-
69
- func resourceLoader(
70
- _ resourceLoader: AVAssetResourceLoader,
71
- shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
72
- ) -> Bool {
73
- pendingRequests.append(loadingRequest)
74
- processRequests()
75
- return true
76
- }
77
-
78
- func resourceLoader(
79
- _ resourceLoader: AVAssetResourceLoader,
80
- didCancel loadingRequest: AVAssetResourceLoadingRequest
81
- ) {
82
- pendingRequests.removeAll { $0 === loadingRequest }
83
- if pendingRequests.isEmpty {
84
- activeTask?.cancel()
85
- activeTask = nil
86
- }
87
- }
88
-
89
- // MARK: - Request Processing
90
-
91
- private func processRequests() {
92
- var completed: [AVAssetResourceLoadingRequest] = []
93
-
94
- for request in pendingRequests {
95
- guard !request.isFinished && !request.isCancelled else {
96
- completed.append(request)
97
- continue
98
- }
99
-
100
- let infoReady = fillContentInfo(request)
101
- let dataReady = fillData(request)
102
-
103
- if infoReady && dataReady {
104
- request.finishLoading()
105
- completed.append(request)
106
- }
107
- }
108
-
109
- pendingRequests.removeAll { req in completed.contains { $0 === req } }
110
-
111
- if !pendingRequests.isEmpty {
112
- ensureDownload()
113
- }
114
- }
115
-
116
- private func fillContentInfo(_ request: AVAssetResourceLoadingRequest) -> Bool {
117
- guard let infoRequest = request.contentInformationRequest else { return true }
118
- guard let info = cache.contentInfo(for: cacheKey) else { return false }
119
-
120
- if let utType = UTType(mimeType: info.contentType) {
121
- infoRequest.contentType = utType.identifier
122
- }
123
- infoRequest.contentLength = info.contentLength
124
- infoRequest.isByteRangeAccessSupported = info.isByteRangeAccessSupported
125
- return true
126
- }
127
-
128
- private func fillData(_ request: AVAssetResourceLoadingRequest) -> Bool {
129
- guard let dataRequest = request.dataRequest else { return true }
130
-
131
- let currentOffset = dataRequest.currentOffset
132
- let requestedEnd = requestEnd(for: dataRequest)
133
- let cachedBytes = cache.cachedBytes(for: cacheKey)
134
-
135
- if currentOffset < cachedBytes {
136
- let readable = min(requestedEnd, cachedBytes)
137
- let length = Int(readable - currentOffset)
138
- if length > 0, let data = cache.readData(for: cacheKey, offset: currentOffset, length: length) {
139
- dataRequest.respond(with: data)
140
- }
141
- }
142
-
143
- return dataRequest.currentOffset >= requestedEnd
144
- }
145
-
146
- private func requestEnd(for dataRequest: AVAssetResourceLoadingDataRequest) -> Int64 {
147
- if dataRequest.requestsAllDataToEndOfResource {
148
- return cache.contentInfo(for: cacheKey)?.contentLength ?? Int64.max
149
- }
150
- return dataRequest.requestedOffset + Int64(dataRequest.requestedLength)
151
- }
152
-
153
- // MARK: - Download
154
-
155
- private func ensureDownload() {
156
- guard activeTask == nil else { return }
157
-
158
- guard let dataRequest = pendingRequests.first(where: { $0.dataRequest != nil })?.dataRequest else { return }
159
-
160
- let neededOffset = dataRequest.currentOffset
161
- let cachedBytes = cache.cachedBytes(for: cacheKey)
162
-
163
- guard neededOffset >= cachedBytes else { return }
164
-
165
- downloadCursor = neededOffset
166
- downloadIsCacheable = (neededOffset == cachedBytes)
167
-
168
- var request = URLRequest(url: originalURL)
169
- headers?.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
170
- request.setValue("bytes=\(neededOffset)-", forHTTPHeaderField: "Range")
171
-
172
- activeTask = session.dataTask(with: request)
173
- activeTask?.resume()
174
- }
175
-
176
- // MARK: - URLSessionDataDelegate
177
-
178
- func urlSession(
179
- _ session: URLSession,
180
- dataTask: URLSessionDataTask,
181
- didReceive response: URLResponse,
182
- completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
183
- ) {
184
- if cache.contentInfo(for: cacheKey) == nil, let http = response as? HTTPURLResponse {
185
- let mime = http.mimeType ?? "audio/mpeg"
186
- var totalLength: Int64 = -1
187
-
188
- if http.statusCode == 206, let rangeHeader = http.value(forHTTPHeaderField: "Content-Range") {
189
- if let slashIdx = rangeHeader.lastIndex(of: "/") {
190
- totalLength = Int64(rangeHeader[rangeHeader.index(after: slashIdx)...]) ?? -1
191
- }
192
- } else {
193
- totalLength = response.expectedContentLength
194
- }
195
-
196
- let byteRange = http.statusCode == 206
197
- let info = AudioCache.ContentInfo(
198
- contentType: mime,
199
- contentLength: totalLength,
200
- isByteRangeAccessSupported: byteRange
201
- )
202
- cache.storeContentInfo(info, for: cacheKey, url: originalURL)
203
- }
204
-
205
- completionHandler(.allow)
206
- processRequests()
207
- }
208
-
209
- func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
210
- if downloadIsCacheable {
211
- cache.appendData(data, for: cacheKey)
212
- }
213
- downloadCursor += Int64(data.count)
214
-
215
- // Respond to pending data requests that fall within the just-downloaded range
216
- var completed: [AVAssetResourceLoadingRequest] = []
217
- for request in pendingRequests {
218
- guard !request.isFinished && !request.isCancelled else {
219
- completed.append(request)
220
- continue
221
- }
222
- let infoReady = fillContentInfo(request)
223
- let dataReady: Bool
224
-
225
- if downloadIsCacheable {
226
- dataReady = fillData(request)
227
- } else if let dataReq = request.dataRequest {
228
- let chunkStart = downloadCursor - Int64(data.count)
229
- let reqOffset = dataReq.currentOffset
230
- if reqOffset >= chunkStart && reqOffset < downloadCursor {
231
- let skip = Int(reqOffset - chunkStart)
232
- let subdata = data.subdata(in: skip..<data.count)
233
- dataReq.respond(with: subdata)
234
- }
235
- dataReady = dataReq.currentOffset >= requestEnd(for: dataReq)
236
- } else {
237
- dataReady = true
238
- }
239
-
240
- if infoReady && dataReady {
241
- request.finishLoading()
242
- completed.append(request)
243
- }
244
- }
245
-
246
- pendingRequests.removeAll { req in completed.contains { $0 === req } }
247
- }
248
-
249
- func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
250
- activeTask = nil
251
-
252
- if let error = error as? URLError, error.code == .cancelled { return }
253
-
254
- if let error = error {
255
- for request in pendingRequests where !request.isFinished && !request.isCancelled {
256
- request.finishLoading(with: error)
257
- }
258
- pendingRequests.removeAll()
259
- return
260
- }
261
-
262
- if downloadIsCacheable {
263
- cache.touchAccessDate(for: cacheKey)
264
- cache.evictIfNeeded()
265
- }
266
-
267
- processRequests()
268
-
269
- if !pendingRequests.isEmpty {
270
- ensureDownload()
271
- }
272
- }
273
- }