@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.
- 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 +107 -87
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/BrowseTree.kt +51 -20
- 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/CarPlay/RNTPCarPlaySceneDelegate.swift +43 -14
- 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 +39 -4
- 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 +37 -4
- 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 +16 -4
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/BrowseTree.d.ts +35 -5
- package/lib/typescript/src/interfaces/BrowseTree.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 +37 -4
- package/src/interfaces/BrowseTree.ts +40 -5
- package/src/interfaces/MediaItem.ts +4 -1
- package/src/interfaces/PlayerConfig.ts +22 -3
- 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 {
|
|
6
|
+
import type { MediaUrl } from './MediaItem';
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
/**
|
|
18
|
-
items:
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
}
|