react-native-hls-cache 0.1.0 → 0.2.0
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/README.md +36 -196
- package/android/src/main/java/com/margelo/nitro/hlscache/HlsCache.kt +0 -8
- package/ios/DataSource.swift +1 -39
- package/ios/HlsCache.swift +1 -30
- package/ios/VideoProxyServer.swift +1 -1
- package/lib/module/index.js +0 -27
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/HlsCache.nitro.d.ts +0 -3
- package/lib/typescript/src/HlsCache.nitro.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +0 -15
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/nitrogen/generated/android/c++/JHybridHlsCacheSpec.cpp +0 -13
- package/nitrogen/generated/android/c++/JHybridHlsCacheSpec.hpp +0 -3
- package/nitrogen/generated/android/kotlin/com/margelo/nitro/hlscache/HybridHlsCacheSpec.kt +0 -12
- package/nitrogen/generated/ios/c++/HybridHlsCacheSpecSwift.hpp +0 -20
- package/nitrogen/generated/ios/swift/HybridHlsCacheSpec.swift +0 -3
- package/nitrogen/generated/ios/swift/HybridHlsCacheSpec_cxx.swift +0 -41
- package/nitrogen/generated/shared/c++/HybridHlsCacheSpec.cpp +0 -3
- package/nitrogen/generated/shared/c++/HybridHlsCacheSpec.hpp +0 -3
- package/package.json +1 -1
- package/src/HlsCache.nitro.ts +0 -3
- package/src/index.tsx +0 -27
- package/ios/PrefetchManager.swift +0 -235
package/README.md
CHANGED
|
@@ -1,233 +1,73 @@
|
|
|
1
1
|
# react-native-hls-cache
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
- **iOS** — full proxy implementation (Swift, Network framework)
|
|
6
|
-
- **Android** — no-op; ExoPlayer/Media3 handle caching natively
|
|
7
|
-
- **Bridge** — [Nitro Modules](https://nitro.margelo.com/) (type-safe FFI)
|
|
8
|
-
|
|
9
|
-
---
|
|
3
|
+
HSL
|
|
10
4
|
|
|
11
5
|
## Installation
|
|
12
6
|
|
|
7
|
+
|
|
13
8
|
```sh
|
|
14
9
|
npm install react-native-hls-cache react-native-nitro-modules
|
|
15
|
-
# or
|
|
16
|
-
yarn add react-native-hls-cache react-native-nitro-modules
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
> `react-native-nitro-modules` is a required peer dependency.
|
|
20
|
-
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
## Quick Start
|
|
24
|
-
|
|
25
|
-
```tsx
|
|
26
|
-
import { startServer, convertUrl } from 'react-native-hls-cache';
|
|
27
|
-
import { useVideoPlayer, VideoView } from 'expo-video';
|
|
28
|
-
import { useEffect } from 'react';
|
|
29
10
|
|
|
30
|
-
|
|
31
|
-
useEffect(() => {
|
|
32
|
-
// Start the proxy once on app launch
|
|
33
|
-
startServer();
|
|
34
|
-
}, []);
|
|
35
|
-
|
|
36
|
-
const remoteUrl = 'https://stream.mux.com/abc123.m3u8';
|
|
37
|
-
|
|
38
|
-
const player = useVideoPlayer(
|
|
39
|
-
convertUrl(remoteUrl), // rewrites to http://127.0.0.1:9000/proxy?url=...
|
|
40
|
-
(p) => { p.loop = true; }
|
|
41
|
-
);
|
|
42
|
-
|
|
43
|
-
return <VideoView player={player} style={{ flex: 1 }} />;
|
|
44
|
-
}
|
|
11
|
+
> `react-native-nitro-modules` is required as this library relies on [Nitro Modules](https://nitro.margelo.com/).
|
|
45
12
|
```
|
|
46
13
|
|
|
47
|
-
---
|
|
48
14
|
|
|
49
|
-
##
|
|
15
|
+
## Usage
|
|
50
16
|
|
|
51
|
-
### `startServer(port?, maxCacheSize?, headOnlyCache?)`
|
|
52
17
|
|
|
53
|
-
|
|
18
|
+
```js
|
|
19
|
+
import { multiply } from 'react-native-hls-cache';
|
|
54
20
|
|
|
55
|
-
|
|
56
|
-
|---|---|---|---|
|
|
57
|
-
| `port` | `number` | `9000` | Local TCP port to bind |
|
|
58
|
-
| `maxCacheSize` | `number` | `1073741824` | Max disk cache in bytes (1 GB) |
|
|
59
|
-
| `headOnlyCache` | `boolean` | `false` | Only cache the first 3 segments per stream. Recommended for vertical video feeds |
|
|
21
|
+
// ...
|
|
60
22
|
|
|
61
|
-
|
|
62
|
-
startServer(); // defaults
|
|
63
|
-
startServer(9000, 512 * 1024 * 1024); // 512 MB cache
|
|
64
|
-
startServer(9000, undefined, true); // head-only mode for feeds
|
|
23
|
+
const result = multiply(3, 7);
|
|
65
24
|
```
|
|
66
25
|
|
|
67
|
-
- Synchronous and fast (< 1ms) — does not block the JS thread
|
|
68
|
-
- Safe to call multiple times; no-op if already running on the same port
|
|
69
|
-
- Throws with code `409` if a server is already running on a different port
|
|
70
|
-
- No-op on Android / Web
|
|
71
|
-
|
|
72
|
-
---
|
|
73
|
-
|
|
74
|
-
### `convertUrl(url, isCacheable?)`
|
|
75
|
-
|
|
76
|
-
Rewrites a remote HLS URL to route through the local proxy.
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
const localUrl = convertUrl('https://cdn.example.com/video.m3u8');
|
|
80
|
-
// → 'http://127.0.0.1:9000/proxy?url=https%3A%2F%2Fcdn.example.com%2Fvideo.m3u8'
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
| Parameter | Type | Default | Description |
|
|
84
|
-
|---|---|---|---|
|
|
85
|
-
| `url` | `string` | — | Remote HLS URL |
|
|
86
|
-
| `isCacheable` | `boolean` | `true` | Pass `false` to bypass the proxy and return the original URL |
|
|
87
|
-
|
|
88
|
-
- Returns the original URL unchanged on Android / Web, or if the server is not running
|
|
89
|
-
- Synchronous — safe to call inline during render
|
|
90
|
-
|
|
91
|
-
---
|
|
92
|
-
|
|
93
|
-
### `clearCache()`
|
|
94
|
-
|
|
95
|
-
Deletes all cached files from disk.
|
|
96
|
-
|
|
97
|
-
```ts
|
|
98
|
-
await clearCache();
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
- Runs on a background thread — does not block the JS thread
|
|
102
|
-
- No-op on Android / Web
|
|
103
|
-
|
|
104
|
-
---
|
|
105
|
-
|
|
106
|
-
### `prefetch(url, segmentCount?)`
|
|
107
|
-
|
|
108
|
-
Downloads an HLS stream into cache before the user plays it. Returns a `taskId` for cancellation.
|
|
109
|
-
|
|
110
|
-
```ts
|
|
111
|
-
const taskId = prefetch('https://cdn.example.com/video.m3u8');
|
|
112
|
-
const taskId = prefetch('https://cdn.example.com/video.m3u8', 5); // cache 5 segments
|
|
113
|
-
```
|
|
114
|
-
|
|
115
|
-
| Parameter | Type | Default | Description |
|
|
116
|
-
|---|---|---|---|
|
|
117
|
-
| `url` | `string` | — | Remote HLS URL |
|
|
118
|
-
| `segmentCount` | `number` | `3` | Number of segments to prefetch |
|
|
119
|
-
|
|
120
|
-
**What it does:**
|
|
121
|
-
1. Downloads the master playlist
|
|
122
|
-
2. Picks the lowest-bandwidth media playlist (lightweight for background work)
|
|
123
|
-
3. Downloads the first `segmentCount` segments into the disk cache
|
|
124
|
-
4. When the player requests those segments via the proxy → instant cache hit
|
|
125
26
|
|
|
126
|
-
|
|
127
|
-
- Returns `''` on Android / Web (no-op)
|
|
128
|
-
|
|
129
|
-
---
|
|
27
|
+
## Contributing
|
|
130
28
|
|
|
131
|
-
|
|
29
|
+
- [Development workflow](CONTRIBUTING.md#development-workflow)
|
|
30
|
+
- [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
|
|
31
|
+
- [Code of conduct](CODE_OF_CONDUCT.md)
|
|
132
32
|
|
|
133
|
-
|
|
33
|
+
## License
|
|
134
34
|
|
|
135
|
-
|
|
136
|
-
const taskId = prefetch(url);
|
|
137
|
-
// later...
|
|
138
|
-
cancelPrefetch(taskId);
|
|
139
|
-
```
|
|
35
|
+
MIT
|
|
140
36
|
|
|
141
37
|
---
|
|
142
38
|
|
|
143
|
-
|
|
39
|
+
Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
|
|
144
40
|
|
|
145
|
-
Cancels all active prefetch tasks.
|
|
146
41
|
|
|
147
|
-
```ts
|
|
148
|
-
cancelAllPrefetch();
|
|
149
|
-
```
|
|
150
42
|
|
|
151
|
-
|
|
43
|
+
Looking at the current API (startServer, convertUrl, clearCache) and the internals, here's what would genuinely add value:
|
|
152
44
|
|
|
153
|
-
|
|
45
|
+
High Value
|
|
46
|
+
stopServer()
|
|
47
|
+
There's no way to stop the proxy from JS. Needed for cleanup on app background/logout.
|
|
154
48
|
|
|
155
|
-
|
|
49
|
+
getCacheStats() → { sizeBytes, fileCount, hitRate }
|
|
50
|
+
Developers need this to show cache info in settings screens and debug eviction behavior. The storage layer already has all the data.
|
|
156
51
|
|
|
157
|
-
|
|
158
|
-
|
|
52
|
+
invalidateUrl(url)
|
|
53
|
+
Right now clearCache() is all-or-nothing. Evicting a single video (e.g. when content is updated server-side) is a very common need.
|
|
159
54
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
startServer(9000, 512 * 1024 * 1024, true);
|
|
163
|
-
return () => cancelAllPrefetch();
|
|
164
|
-
}, []);
|
|
55
|
+
prefetch(url)
|
|
56
|
+
Download a video into cache before the user plays it. The NetworkDownloader + DataSource pipeline is already there — this would just drive it without a proxy connection. Big UX win for predictable playback.
|
|
165
57
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const taskId = prefetch(videoUrl);
|
|
170
|
-
return () => cancelPrefetch(taskId);
|
|
171
|
-
}
|
|
172
|
-
}, [shouldPreload, videoUrl]);
|
|
58
|
+
Medium Value
|
|
59
|
+
isRunning: boolean property
|
|
60
|
+
JS callers currently have no way to check server state without trying to start it and catching an error.
|
|
173
61
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
});
|
|
177
|
-
```
|
|
62
|
+
WiFi-only caching option
|
|
63
|
+
A wifiOnly flag on startServer — skip disk writes on cellular to avoid burning mobile data.
|
|
178
64
|
|
|
179
|
-
|
|
65
|
+
Cache event callbacks onCacheHit / onCacheMiss
|
|
66
|
+
Useful for analytics (measuring actual cache efficiency in production).
|
|
180
67
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
const url = convertUrl(streamUrl, !isLive && !isDRM);
|
|
184
|
-
```
|
|
68
|
+
Lower Priority
|
|
69
|
+
Configurable cache directory — some apps have specific storage requirements (e.g. store in Documents for user-visible files vs Caches).
|
|
185
70
|
|
|
186
|
-
|
|
71
|
+
Resume interrupted downloads — if a segment write was interrupted (app killed mid-download), the current code deletes it and re-fetches. A partial-resume strategy would save bandwidth for large segments.
|
|
187
72
|
|
|
188
|
-
|
|
189
|
-
async function onLogout() {
|
|
190
|
-
cancelAllPrefetch();
|
|
191
|
-
await clearCache();
|
|
192
|
-
}
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
---
|
|
196
|
-
|
|
197
|
-
## How It Works
|
|
198
|
-
|
|
199
|
-
```
|
|
200
|
-
Media Player
|
|
201
|
-
→ HTTP request to localhost:9000/proxy?url=<encoded>
|
|
202
|
-
→ VideoProxyServer (NWListener, TCP)
|
|
203
|
-
→ ClientConnectionHandler (per-connection HTTP handler)
|
|
204
|
-
→ DataSource (cache-first router)
|
|
205
|
-
├── Cache hit → memory-mapped read from disk → instant response
|
|
206
|
-
└── Cache miss → NetworkDownloader (priority queue, 32 concurrent)
|
|
207
|
-
→ stream to player + write to disk simultaneously
|
|
208
|
-
```
|
|
209
|
-
|
|
210
|
-
**Cache storage** — files are keyed by SHA256 hash of the URL (+ byte range for fMP4 segments), stored in the system Caches directory under `ExpoVideoCache/`. LRU eviction runs 5 seconds after server start.
|
|
211
|
-
|
|
212
|
-
**Manifest rewriting** — `.m3u8` responses are intercepted and all segment URLs are rewritten to route through the proxy, so every subsequent segment request is also cacheable.
|
|
213
|
-
|
|
214
|
-
**Priority queue** — `.m3u8` manifests and small requests bypass the concurrency semaphore for instant playback startup. Heavy segment downloads are queued with a limit of 32 concurrent connections.
|
|
215
|
-
|
|
216
|
-
---
|
|
217
|
-
|
|
218
|
-
## Requirements
|
|
219
|
-
|
|
220
|
-
- React Native 0.73+
|
|
221
|
-
- iOS 15.1+
|
|
222
|
-
- Android — no native requirements (no-op)
|
|
223
|
-
|
|
224
|
-
---
|
|
225
|
-
|
|
226
|
-
## Contributing
|
|
227
|
-
|
|
228
|
-
- [Development workflow](CONTRIBUTING.md#development-workflow)
|
|
229
|
-
- [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request)
|
|
230
|
-
|
|
231
|
-
## License
|
|
232
|
-
|
|
233
|
-
MIT
|
|
73
|
+
The three I'd ship first: stopServer, getCacheStats, and prefetch — they cover the most common real-world gaps without requiring deep architectural changes.
|
|
@@ -21,12 +21,4 @@ class HlsCache : HybridHlsCacheSpec() {
|
|
|
21
21
|
override fun clearCache(): Promise<Unit> {
|
|
22
22
|
return Promise.resolved(Unit)
|
|
23
23
|
}
|
|
24
|
-
|
|
25
|
-
override fun prefetch(url: String, segmentCount: Double?): String {
|
|
26
|
-
return ""
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
override fun cancelPrefetch(taskId: String): Unit {}
|
|
30
|
-
|
|
31
|
-
override fun cancelAllPrefetch(): Unit {}
|
|
32
24
|
}
|
package/ios/DataSource.swift
CHANGED
|
@@ -100,14 +100,7 @@ internal final class DataSource: NetworkDownloaderDelegate {
|
|
|
100
100
|
}
|
|
101
101
|
return
|
|
102
102
|
}
|
|
103
|
-
|
|
104
|
-
// Range-keyed file not found — fall back to the full file that prefetch may have
|
|
105
|
-
// stored without a range suffix. Slice the requested bytes out of it on the fly.
|
|
106
|
-
if range != nil && !isManifest && storage.exists(for: url.absoluteString) {
|
|
107
|
-
DataSource.diskQueue.async { self.serveRangeFromFullFile() }
|
|
108
|
-
return
|
|
109
|
-
}
|
|
110
|
-
|
|
103
|
+
|
|
111
104
|
if isManifest {
|
|
112
105
|
downloadManifest()
|
|
113
106
|
} else {
|
|
@@ -179,37 +172,6 @@ internal final class DataSource: NetworkDownloaderDelegate {
|
|
|
179
172
|
delegate?.didComplete(error: nil)
|
|
180
173
|
}
|
|
181
174
|
|
|
182
|
-
/// Serves a byte-range slice from a full file that was cached by prefetch (no range suffix).
|
|
183
|
-
private func serveRangeFromFullFile() {
|
|
184
|
-
let path = storage.getFilePath(for: url.absoluteString)
|
|
185
|
-
guard let fullData = try? Data(contentsOf: path, options: .mappedIfSafe),
|
|
186
|
-
let r = range else {
|
|
187
|
-
// Full file unreadable — fall through to network.
|
|
188
|
-
startStreamDownload()
|
|
189
|
-
return
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
let fileSize = fullData.count
|
|
193
|
-
let lower = r.lowerBound
|
|
194
|
-
let upper = r.upperBound == Int.max ? fileSize : min(r.upperBound, fileSize)
|
|
195
|
-
|
|
196
|
-
guard lower < fileSize else {
|
|
197
|
-
delegate?.didComplete(error: NSError(domain: "RangeError", code: 416))
|
|
198
|
-
return
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
let sliced = Data(fullData[lower..<upper])
|
|
202
|
-
let headers: [String: String] = [
|
|
203
|
-
"Content-Type": getMimeType(url: url),
|
|
204
|
-
"Content-Length": "\(sliced.count)",
|
|
205
|
-
"Accept-Ranges": "bytes",
|
|
206
|
-
"Content-Range": "bytes \(lower)-\(upper - 1)/\(fileSize)"
|
|
207
|
-
]
|
|
208
|
-
delegate?.didReceiveHeaders(headers: headers, status: 206)
|
|
209
|
-
delegate?.didReceiveData(data: sliced)
|
|
210
|
-
delegate?.didComplete(error: nil)
|
|
211
|
-
}
|
|
212
|
-
|
|
213
175
|
// MARK: - Network Path (Cache Miss)
|
|
214
176
|
|
|
215
177
|
private func startStreamDownload() {
|
package/ios/HlsCache.swift
CHANGED
|
@@ -76,6 +76,7 @@ public class HybridHlsCache: HybridHlsCacheSpec {
|
|
|
76
76
|
|
|
77
77
|
/// Heavy async method — file I/O runs on a background thread via Promise.async.
|
|
78
78
|
public func clearCache() throws -> Promise<Void> {
|
|
79
|
+
// Capture server ref synchronously (JS thread) before jumping to background.
|
|
79
80
|
stateLock.lock()
|
|
80
81
|
let server = proxyServer
|
|
81
82
|
stateLock.unlock()
|
|
@@ -88,34 +89,4 @@ public class HybridHlsCache: HybridHlsCacheSpec {
|
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
}
|
|
91
|
-
|
|
92
|
-
/// Prefetches an HLS stream into cache. Returns a taskId for cancellation.
|
|
93
|
-
/// Runs entirely in the background — JS thread returns immediately.
|
|
94
|
-
public func prefetch(url: String, segmentCount: Double?) throws -> String {
|
|
95
|
-
guard let parsedURL = URL(string: url) else {
|
|
96
|
-
throw NSError(
|
|
97
|
-
domain: "HlsCache",
|
|
98
|
-
code: 400,
|
|
99
|
-
userInfo: [NSLocalizedDescriptionKey: "Invalid URL: \(url)"]
|
|
100
|
-
)
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
stateLock.lock()
|
|
104
|
-
let server = proxyServer
|
|
105
|
-
stateLock.unlock()
|
|
106
|
-
|
|
107
|
-
// Reuse the server's storage if running, otherwise create a temporary one.
|
|
108
|
-
let storage = server?.storage ?? VideoCacheStorage(maxCacheSize: 1_073_741_824)
|
|
109
|
-
let count = segmentCount.map { Int($0) } ?? 3
|
|
110
|
-
|
|
111
|
-
return PrefetchManager.shared.prefetch(url: parsedURL, storage: storage, segmentCount: count)
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
public func cancelPrefetch(taskId: String) throws -> Void {
|
|
115
|
-
PrefetchManager.shared.cancel(taskId: taskId)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
public func cancelAllPrefetch() throws -> Void {
|
|
119
|
-
PrefetchManager.shared.cancelAll()
|
|
120
|
-
}
|
|
121
92
|
}
|
|
@@ -25,7 +25,7 @@ internal final class VideoProxyServer: ProxyConnectionDelegate {
|
|
|
25
25
|
private var listener: NWListener?
|
|
26
26
|
|
|
27
27
|
/// Disk-backed storage used for caching video data.
|
|
28
|
-
|
|
28
|
+
private let storage: VideoCacheStorage
|
|
29
29
|
|
|
30
30
|
/// The local port on which the server listens for incoming connections.
|
|
31
31
|
internal let port: Int
|
package/lib/module/index.js
CHANGED
|
@@ -41,31 +41,4 @@ export function clearCache() {
|
|
|
41
41
|
if (Platform.OS !== 'ios') return Promise.resolve();
|
|
42
42
|
return getInstance().clearCache();
|
|
43
43
|
}
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Prefetches an HLS stream into the local cache in the background.
|
|
47
|
-
* Downloads the manifest + first `segmentCount` segments (default: 3).
|
|
48
|
-
*
|
|
49
|
-
* @returns A taskId that can be passed to `cancelPrefetch` to stop the download.
|
|
50
|
-
*/
|
|
51
|
-
export function prefetch(url, segmentCount) {
|
|
52
|
-
if (Platform.OS !== 'ios') return '';
|
|
53
|
-
return getInstance().prefetch(url, segmentCount);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Cancels a specific prefetch task by its taskId.
|
|
58
|
-
*/
|
|
59
|
-
export function cancelPrefetch(taskId) {
|
|
60
|
-
if (Platform.OS !== 'ios') return;
|
|
61
|
-
getInstance().cancelPrefetch(taskId);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Cancels all active prefetch tasks.
|
|
66
|
-
*/
|
|
67
|
-
export function cancelAllPrefetch() {
|
|
68
|
-
if (Platform.OS !== 'ios') return;
|
|
69
|
-
getInstance().cancelAllPrefetch();
|
|
70
|
-
}
|
|
71
44
|
//# sourceMappingURL=index.js.map
|
package/lib/module/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"names":["NitroModules","Platform","_instance","getInstance","createHybridObject","startServer","port","maxCacheSize","headOnlyCache","OS","convertUrl","url","isCacheable","clearCache","Promise","resolve"
|
|
1
|
+
{"version":3,"names":["NitroModules","Platform","_instance","getInstance","createHybridObject","startServer","port","maxCacheSize","headOnlyCache","OS","convertUrl","url","isCacheable","clearCache","Promise","resolve"],"sourceRoot":"../../src","sources":["index.tsx"],"mappings":";;AAAA,SAASA,YAAY,QAAQ,4BAA4B;AACzD,SAASC,QAAQ,QAAQ,cAAc;AAGvC,IAAIC,SAA0B,GAAG,IAAI;AAErC,SAASC,WAAWA,CAAA,EAAa;EAC/B,IAAID,SAAS,IAAI,IAAI,EAAE;IACrBA,SAAS,GAAGF,YAAY,CAACI,kBAAkB,CAAW,UAAU,CAAC;EACnE;EACA,OAAOF,SAAS;AAClB;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASG,WAAWA,CACzBC,IAAa,EACbC,YAAqB,EACrBC,aAAuB,EACjB;EACN,IAAIP,QAAQ,CAACQ,EAAE,KAAK,KAAK,EAAE;EAC3BN,WAAW,CAAC,CAAC,CAACE,WAAW,CAACC,IAAI,EAAEC,YAAY,EAAEC,aAAa,CAAC;AAC9D;;AAEA;AACA;AACA;AACA;AACA;AACA;AACA;AACA,OAAO,SAASE,UAAUA,CAACC,GAAW,EAAEC,WAAqB,EAAU;EACrE,IAAIX,QAAQ,CAACQ,EAAE,KAAK,KAAK,EAAE,OAAOE,GAAG;EACrC,OAAOR,WAAW,CAAC,CAAC,CAACO,UAAU,CAACC,GAAG,EAAEC,WAAW,CAAC;AACnD;;AAEA;AACA;AACA;AACA,OAAO,SAASC,UAAUA,CAAA,EAAkB;EAC1C,IAAIZ,QAAQ,CAACQ,EAAE,KAAK,KAAK,EAAE,OAAOK,OAAO,CAACC,OAAO,CAAC,CAAC;EACnD,OAAOZ,WAAW,CAAC,CAAC,CAACU,UAAU,CAAC,CAAC;AACnC","ignoreList":[]}
|
|
@@ -6,8 +6,5 @@ export interface HlsCache extends HybridObject<{
|
|
|
6
6
|
startServer(port?: number, maxCacheSize?: number, headOnlyCache?: boolean): void;
|
|
7
7
|
convertUrl(url: string, isCacheable?: boolean): string;
|
|
8
8
|
clearCache(): Promise<void>;
|
|
9
|
-
prefetch(url: string, segmentCount?: number): string;
|
|
10
|
-
cancelPrefetch(taskId: string): void;
|
|
11
|
-
cancelAllPrefetch(): void;
|
|
12
9
|
}
|
|
13
10
|
//# sourceMappingURL=HlsCache.nitro.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"HlsCache.nitro.d.ts","sourceRoot":"","sources":["../../../src/HlsCache.nitro.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAE/D,MAAM,WAAW,QACf,SAAQ,YAAY,CAAC;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,QAAQ,CAAA;CAAE,CAAC;IACzD,WAAW,CACT,IAAI,CAAC,EAAE,MAAM,EACb,YAAY,CAAC,EAAE,MAAM,EACrB,aAAa,CAAC,EAAE,OAAO,GACtB,IAAI,CAAC;IACR,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IACvD,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;
|
|
1
|
+
{"version":3,"file":"HlsCache.nitro.d.ts","sourceRoot":"","sources":["../../../src/HlsCache.nitro.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAE/D,MAAM,WAAW,QACf,SAAQ,YAAY,CAAC;IAAE,GAAG,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,QAAQ,CAAA;CAAE,CAAC;IACzD,WAAW,CACT,IAAI,CAAC,EAAE,MAAM,EACb,YAAY,CAAC,EAAE,MAAM,EACrB,aAAa,CAAC,EAAE,OAAO,GACtB,IAAI,CAAC;IACR,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IACvD,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC7B"}
|
|
@@ -18,19 +18,4 @@ export declare function convertUrl(url: string, isCacheable?: boolean): string;
|
|
|
18
18
|
* Purges all cached video files from disk.
|
|
19
19
|
*/
|
|
20
20
|
export declare function clearCache(): Promise<void>;
|
|
21
|
-
/**
|
|
22
|
-
* Prefetches an HLS stream into the local cache in the background.
|
|
23
|
-
* Downloads the manifest + first `segmentCount` segments (default: 3).
|
|
24
|
-
*
|
|
25
|
-
* @returns A taskId that can be passed to `cancelPrefetch` to stop the download.
|
|
26
|
-
*/
|
|
27
|
-
export declare function prefetch(url: string, segmentCount?: number): string;
|
|
28
|
-
/**
|
|
29
|
-
* Cancels a specific prefetch task by its taskId.
|
|
30
|
-
*/
|
|
31
|
-
export declare function cancelPrefetch(taskId: string): void;
|
|
32
|
-
/**
|
|
33
|
-
* Cancels all active prefetch tasks.
|
|
34
|
-
*/
|
|
35
|
-
export declare function cancelAllPrefetch(): void;
|
|
36
21
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AAaA;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,IAAI,CAAC,EAAE,MAAM,EACb,YAAY,CAAC,EAAE,MAAM,EACrB,aAAa,CAAC,EAAE,OAAO,GACtB,IAAI,CAGN;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,CAGrE;AAED;;GAEG;AACH,wBAAgB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAG1C
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/index.tsx"],"names":[],"mappings":"AAaA;;;;;;GAMG;AACH,wBAAgB,WAAW,CACzB,IAAI,CAAC,EAAE,MAAM,EACb,YAAY,CAAC,EAAE,MAAM,EACrB,aAAa,CAAC,EAAE,OAAO,GACtB,IAAI,CAGN;AAED;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,CAGrE;AAED;;GAEG;AACH,wBAAgB,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAG1C"}
|
|
@@ -72,18 +72,5 @@ namespace margelo::nitro::hlscache {
|
|
|
72
72
|
return __promise;
|
|
73
73
|
}();
|
|
74
74
|
}
|
|
75
|
-
std::string JHybridHlsCacheSpec::prefetch(const std::string& url, std::optional<double> segmentCount) {
|
|
76
|
-
static const auto method = _javaPart->javaClassStatic()->getMethod<jni::local_ref<jni::JString>(jni::alias_ref<jni::JString> /* url */, jni::alias_ref<jni::JDouble> /* segmentCount */)>("prefetch");
|
|
77
|
-
auto __result = method(_javaPart, jni::make_jstring(url), segmentCount.has_value() ? jni::JDouble::valueOf(segmentCount.value()) : nullptr);
|
|
78
|
-
return __result->toStdString();
|
|
79
|
-
}
|
|
80
|
-
void JHybridHlsCacheSpec::cancelPrefetch(const std::string& taskId) {
|
|
81
|
-
static const auto method = _javaPart->javaClassStatic()->getMethod<void(jni::alias_ref<jni::JString> /* taskId */)>("cancelPrefetch");
|
|
82
|
-
method(_javaPart, jni::make_jstring(taskId));
|
|
83
|
-
}
|
|
84
|
-
void JHybridHlsCacheSpec::cancelAllPrefetch() {
|
|
85
|
-
static const auto method = _javaPart->javaClassStatic()->getMethod<void()>("cancelAllPrefetch");
|
|
86
|
-
method(_javaPart);
|
|
87
|
-
}
|
|
88
75
|
|
|
89
76
|
} // namespace margelo::nitro::hlscache
|
|
@@ -57,9 +57,6 @@ namespace margelo::nitro::hlscache {
|
|
|
57
57
|
void startServer(std::optional<double> port, std::optional<double> maxCacheSize, std::optional<bool> headOnlyCache) override;
|
|
58
58
|
std::string convertUrl(const std::string& url, std::optional<bool> isCacheable) override;
|
|
59
59
|
std::shared_ptr<Promise<void>> clearCache() override;
|
|
60
|
-
std::string prefetch(const std::string& url, std::optional<double> segmentCount) override;
|
|
61
|
-
void cancelPrefetch(const std::string& taskId) override;
|
|
62
|
-
void cancelAllPrefetch() override;
|
|
63
60
|
|
|
64
61
|
private:
|
|
65
62
|
jni::global_ref<JHybridHlsCacheSpec::JavaPart> _javaPart;
|
|
@@ -40,18 +40,6 @@ abstract class HybridHlsCacheSpec: HybridObject() {
|
|
|
40
40
|
@DoNotStrip
|
|
41
41
|
@Keep
|
|
42
42
|
abstract fun clearCache(): Promise<Unit>
|
|
43
|
-
|
|
44
|
-
@DoNotStrip
|
|
45
|
-
@Keep
|
|
46
|
-
abstract fun prefetch(url: String, segmentCount: Double?): String
|
|
47
|
-
|
|
48
|
-
@DoNotStrip
|
|
49
|
-
@Keep
|
|
50
|
-
abstract fun cancelPrefetch(taskId: String): Unit
|
|
51
|
-
|
|
52
|
-
@DoNotStrip
|
|
53
|
-
@Keep
|
|
54
|
-
abstract fun cancelAllPrefetch(): Unit
|
|
55
43
|
|
|
56
44
|
// Default implementation of `HybridObject.toString()`
|
|
57
45
|
override fun toString(): String {
|
|
@@ -90,26 +90,6 @@ namespace margelo::nitro::hlscache {
|
|
|
90
90
|
auto __value = std::move(__result.value());
|
|
91
91
|
return __value;
|
|
92
92
|
}
|
|
93
|
-
inline std::string prefetch(const std::string& url, std::optional<double> segmentCount) override {
|
|
94
|
-
auto __result = _swiftPart.prefetch(url, segmentCount);
|
|
95
|
-
if (__result.hasError()) [[unlikely]] {
|
|
96
|
-
std::rethrow_exception(__result.error());
|
|
97
|
-
}
|
|
98
|
-
auto __value = std::move(__result.value());
|
|
99
|
-
return __value;
|
|
100
|
-
}
|
|
101
|
-
inline void cancelPrefetch(const std::string& taskId) override {
|
|
102
|
-
auto __result = _swiftPart.cancelPrefetch(taskId);
|
|
103
|
-
if (__result.hasError()) [[unlikely]] {
|
|
104
|
-
std::rethrow_exception(__result.error());
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
inline void cancelAllPrefetch() override {
|
|
108
|
-
auto __result = _swiftPart.cancelAllPrefetch();
|
|
109
|
-
if (__result.hasError()) [[unlikely]] {
|
|
110
|
-
std::rethrow_exception(__result.error());
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
93
|
|
|
114
94
|
private:
|
|
115
95
|
HlsCache::HybridHlsCacheSpec_cxx _swiftPart;
|
|
@@ -16,9 +16,6 @@ public protocol HybridHlsCacheSpec_protocol: HybridObject {
|
|
|
16
16
|
func startServer(port: Double?, maxCacheSize: Double?, headOnlyCache: Bool?) throws -> Void
|
|
17
17
|
func convertUrl(url: String, isCacheable: Bool?) throws -> String
|
|
18
18
|
func clearCache() throws -> Promise<Void>
|
|
19
|
-
func prefetch(url: String, segmentCount: Double?) throws -> String
|
|
20
|
-
func cancelPrefetch(taskId: String) throws -> Void
|
|
21
|
-
func cancelAllPrefetch() throws -> Void
|
|
22
19
|
}
|
|
23
20
|
|
|
24
21
|
public extension HybridHlsCacheSpec_protocol {
|
|
@@ -193,45 +193,4 @@ open class HybridHlsCacheSpec_cxx {
|
|
|
193
193
|
return bridge.create_Result_std__shared_ptr_Promise_void___(__exceptionPtr)
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
|
-
|
|
197
|
-
@inline(__always)
|
|
198
|
-
public final func prefetch(url: std.string, segmentCount: bridge.std__optional_double_) -> bridge.Result_std__string_ {
|
|
199
|
-
do {
|
|
200
|
-
let __result = try self.__implementation.prefetch(url: String(url), segmentCount: { () -> Double? in
|
|
201
|
-
if bridge.has_value_std__optional_double_(segmentCount) {
|
|
202
|
-
let __unwrapped = bridge.get_std__optional_double_(segmentCount)
|
|
203
|
-
return __unwrapped
|
|
204
|
-
} else {
|
|
205
|
-
return nil
|
|
206
|
-
}
|
|
207
|
-
}())
|
|
208
|
-
let __resultCpp = std.string(__result)
|
|
209
|
-
return bridge.create_Result_std__string_(__resultCpp)
|
|
210
|
-
} catch (let __error) {
|
|
211
|
-
let __exceptionPtr = __error.toCpp()
|
|
212
|
-
return bridge.create_Result_std__string_(__exceptionPtr)
|
|
213
|
-
}
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
@inline(__always)
|
|
217
|
-
public final func cancelPrefetch(taskId: std.string) -> bridge.Result_void_ {
|
|
218
|
-
do {
|
|
219
|
-
try self.__implementation.cancelPrefetch(taskId: String(taskId))
|
|
220
|
-
return bridge.create_Result_void_()
|
|
221
|
-
} catch (let __error) {
|
|
222
|
-
let __exceptionPtr = __error.toCpp()
|
|
223
|
-
return bridge.create_Result_void_(__exceptionPtr)
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
@inline(__always)
|
|
228
|
-
public final func cancelAllPrefetch() -> bridge.Result_void_ {
|
|
229
|
-
do {
|
|
230
|
-
try self.__implementation.cancelAllPrefetch()
|
|
231
|
-
return bridge.create_Result_void_()
|
|
232
|
-
} catch (let __error) {
|
|
233
|
-
let __exceptionPtr = __error.toCpp()
|
|
234
|
-
return bridge.create_Result_void_(__exceptionPtr)
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
196
|
}
|
|
@@ -17,9 +17,6 @@ namespace margelo::nitro::hlscache {
|
|
|
17
17
|
prototype.registerHybridMethod("startServer", &HybridHlsCacheSpec::startServer);
|
|
18
18
|
prototype.registerHybridMethod("convertUrl", &HybridHlsCacheSpec::convertUrl);
|
|
19
19
|
prototype.registerHybridMethod("clearCache", &HybridHlsCacheSpec::clearCache);
|
|
20
|
-
prototype.registerHybridMethod("prefetch", &HybridHlsCacheSpec::prefetch);
|
|
21
|
-
prototype.registerHybridMethod("cancelPrefetch", &HybridHlsCacheSpec::cancelPrefetch);
|
|
22
|
-
prototype.registerHybridMethod("cancelAllPrefetch", &HybridHlsCacheSpec::cancelAllPrefetch);
|
|
23
20
|
});
|
|
24
21
|
}
|
|
25
22
|
|
|
@@ -53,9 +53,6 @@ namespace margelo::nitro::hlscache {
|
|
|
53
53
|
virtual void startServer(std::optional<double> port, std::optional<double> maxCacheSize, std::optional<bool> headOnlyCache) = 0;
|
|
54
54
|
virtual std::string convertUrl(const std::string& url, std::optional<bool> isCacheable) = 0;
|
|
55
55
|
virtual std::shared_ptr<Promise<void>> clearCache() = 0;
|
|
56
|
-
virtual std::string prefetch(const std::string& url, std::optional<double> segmentCount) = 0;
|
|
57
|
-
virtual void cancelPrefetch(const std::string& taskId) = 0;
|
|
58
|
-
virtual void cancelAllPrefetch() = 0;
|
|
59
56
|
|
|
60
57
|
protected:
|
|
61
58
|
// Hybrid Setup
|
package/package.json
CHANGED
package/src/HlsCache.nitro.ts
CHANGED
package/src/index.tsx
CHANGED
|
@@ -46,30 +46,3 @@ export function clearCache(): Promise<void> {
|
|
|
46
46
|
if (Platform.OS !== 'ios') return Promise.resolve();
|
|
47
47
|
return getInstance().clearCache();
|
|
48
48
|
}
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* Prefetches an HLS stream into the local cache in the background.
|
|
52
|
-
* Downloads the manifest + first `segmentCount` segments (default: 3).
|
|
53
|
-
*
|
|
54
|
-
* @returns A taskId that can be passed to `cancelPrefetch` to stop the download.
|
|
55
|
-
*/
|
|
56
|
-
export function prefetch(url: string, segmentCount?: number): string {
|
|
57
|
-
if (Platform.OS !== 'ios') return '';
|
|
58
|
-
return getInstance().prefetch(url, segmentCount);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* Cancels a specific prefetch task by its taskId.
|
|
63
|
-
*/
|
|
64
|
-
export function cancelPrefetch(taskId: string): void {
|
|
65
|
-
if (Platform.OS !== 'ios') return;
|
|
66
|
-
getInstance().cancelPrefetch(taskId);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* Cancels all active prefetch tasks.
|
|
71
|
-
*/
|
|
72
|
-
export function cancelAllPrefetch(): void {
|
|
73
|
-
if (Platform.OS !== 'ios') return;
|
|
74
|
-
getInstance().cancelAllPrefetch();
|
|
75
|
-
}
|
|
@@ -1,235 +0,0 @@
|
|
|
1
|
-
import Foundation
|
|
2
|
-
|
|
3
|
-
/// Manages background prefetch tasks for HLS streams.
|
|
4
|
-
///
|
|
5
|
-
/// Each prefetch task downloads the manifest + first N segments into the disk cache
|
|
6
|
-
/// so they are instantly served from cache when the video player requests them.
|
|
7
|
-
internal final class PrefetchManager {
|
|
8
|
-
|
|
9
|
-
static let shared = PrefetchManager()
|
|
10
|
-
|
|
11
|
-
private let lock = NSLock()
|
|
12
|
-
private var activeTasks: [String: PrefetchTask] = [:]
|
|
13
|
-
|
|
14
|
-
/// Starts prefetching an HLS stream. Returns a taskId that can be used to cancel.
|
|
15
|
-
func prefetch(url: URL, storage: VideoCacheStorage, segmentCount: Int) -> String {
|
|
16
|
-
let taskId = UUID().uuidString
|
|
17
|
-
let task = PrefetchTask(url: url, storage: storage, segmentCount: segmentCount)
|
|
18
|
-
|
|
19
|
-
lock.lock()
|
|
20
|
-
activeTasks[taskId] = task
|
|
21
|
-
lock.unlock()
|
|
22
|
-
|
|
23
|
-
task.start { [weak self] in
|
|
24
|
-
self?.lock.lock()
|
|
25
|
-
self?.activeTasks.removeValue(forKey: taskId)
|
|
26
|
-
self?.lock.unlock()
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
return taskId
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
func cancel(taskId: String) {
|
|
33
|
-
lock.lock()
|
|
34
|
-
let task = activeTasks.removeValue(forKey: taskId)
|
|
35
|
-
lock.unlock()
|
|
36
|
-
task?.cancel()
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
func cancelAll() {
|
|
40
|
-
lock.lock()
|
|
41
|
-
let tasks = Array(activeTasks.values)
|
|
42
|
-
activeTasks.removeAll()
|
|
43
|
-
lock.unlock()
|
|
44
|
-
tasks.forEach { $0.cancel() }
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
// MARK: - PrefetchTask
|
|
49
|
-
|
|
50
|
-
private final class PrefetchTask {
|
|
51
|
-
|
|
52
|
-
private let url: URL
|
|
53
|
-
private let storage: VideoCacheStorage
|
|
54
|
-
private let segmentCount: Int
|
|
55
|
-
|
|
56
|
-
private let lock = NSLock()
|
|
57
|
-
private var networkTasks: [NetworkTask] = []
|
|
58
|
-
private var isCancelled = false
|
|
59
|
-
|
|
60
|
-
init(url: URL, storage: VideoCacheStorage, segmentCount: Int) {
|
|
61
|
-
self.url = url
|
|
62
|
-
self.storage = storage
|
|
63
|
-
self.segmentCount = segmentCount
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
func start(onComplete: @escaping () -> Void) {
|
|
67
|
-
fetchManifest(url: url, onComplete: onComplete)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
func cancel() {
|
|
71
|
-
lock.lock()
|
|
72
|
-
isCancelled = true
|
|
73
|
-
let tasks = networkTasks
|
|
74
|
-
networkTasks.removeAll()
|
|
75
|
-
lock.unlock()
|
|
76
|
-
tasks.forEach { $0.cancel() }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// MARK: - Private
|
|
80
|
-
|
|
81
|
-
private func fetchManifest(url: URL, onComplete: @escaping () -> Void) {
|
|
82
|
-
let task = NetworkDownloader.shared.downloadManifest(url: url) { [weak self] data, error in
|
|
83
|
-
guard let self = self, !self.isCancelled else { onComplete(); return }
|
|
84
|
-
guard let data = data, let content = String(data: data, encoding: .utf8) else {
|
|
85
|
-
onComplete(); return
|
|
86
|
-
}
|
|
87
|
-
self.storage.save(data: data, for: url.absoluteString)
|
|
88
|
-
self.processManifest(content: content, baseURL: url, onComplete: onComplete)
|
|
89
|
-
}
|
|
90
|
-
addNetworkTask(task)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
private func processManifest(content: String, baseURL: URL, onComplete: @escaping () -> Void) {
|
|
94
|
-
if content.contains("#EXT-X-STREAM-INF") {
|
|
95
|
-
// Master playlist — cache ALL variant manifests so any quality AVPlayer picks is available.
|
|
96
|
-
let variantURLs = parseAllVariantURLs(content: content, baseURL: baseURL)
|
|
97
|
-
guard !variantURLs.isEmpty else { onComplete(); return }
|
|
98
|
-
|
|
99
|
-
let group = DispatchGroup()
|
|
100
|
-
for variantURL in variantURLs {
|
|
101
|
-
guard !isCancelled else { break }
|
|
102
|
-
group.enter()
|
|
103
|
-
fetchManifest(url: variantURL, onComplete: { group.leave() })
|
|
104
|
-
}
|
|
105
|
-
group.notify(queue: .global(qos: .background)) { onComplete() }
|
|
106
|
-
} else {
|
|
107
|
-
// Media playlist — download init segment (if any) + first N segments.
|
|
108
|
-
var urls: [URL] = []
|
|
109
|
-
if let initURL = parseInitSegmentURL(content: content, baseURL: baseURL) {
|
|
110
|
-
urls.append(initURL)
|
|
111
|
-
}
|
|
112
|
-
urls.append(contentsOf: parseSegmentURLs(content: content, baseURL: baseURL))
|
|
113
|
-
downloadSegments(urls: urls, onComplete: onComplete)
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
private func downloadSegments(urls: [URL], onComplete: @escaping () -> Void) {
|
|
118
|
-
guard !urls.isEmpty else { onComplete(); return }
|
|
119
|
-
|
|
120
|
-
let group = DispatchGroup()
|
|
121
|
-
|
|
122
|
-
for url in urls {
|
|
123
|
-
guard !isCancelled else { break }
|
|
124
|
-
if storage.exists(for: url.absoluteString) { continue }
|
|
125
|
-
|
|
126
|
-
group.enter()
|
|
127
|
-
let delegate = SegmentAccumulator(storage: storage, url: url) {
|
|
128
|
-
group.leave()
|
|
129
|
-
}
|
|
130
|
-
let task = NetworkDownloader.shared.download(url: url, range: nil, delegate: delegate)
|
|
131
|
-
addNetworkTask(task)
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
group.notify(queue: .global(qos: .background)) { [weak self] in
|
|
135
|
-
_ = self // retain until done
|
|
136
|
-
onComplete()
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
private func addNetworkTask(_ task: NetworkTask) {
|
|
141
|
-
lock.lock()
|
|
142
|
-
networkTasks.append(task)
|
|
143
|
-
lock.unlock()
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
// MARK: - HLS Parsing
|
|
147
|
-
|
|
148
|
-
/// Returns ALL variant/rendition playlist URLs from a master playlist.
|
|
149
|
-
/// Caching every variant ensures AVPlayer can pick any quality while offline.
|
|
150
|
-
private func parseAllVariantURLs(content: String, baseURL: URL) -> [URL] {
|
|
151
|
-
var urls: [URL] = []
|
|
152
|
-
let lines = content.components(separatedBy: .newlines)
|
|
153
|
-
var i = 0
|
|
154
|
-
while i < lines.count {
|
|
155
|
-
let line = lines[i].trimmingCharacters(in: .whitespaces)
|
|
156
|
-
if line.hasPrefix("#EXT-X-STREAM-INF") {
|
|
157
|
-
let nextIndex = i + 1
|
|
158
|
-
if nextIndex < lines.count {
|
|
159
|
-
let urlLine = lines[nextIndex].trimmingCharacters(in: .whitespaces)
|
|
160
|
-
if !urlLine.isEmpty, !urlLine.hasPrefix("#"),
|
|
161
|
-
let resolved = URL(string: urlLine, relativeTo: baseURL)?.absoluteURL {
|
|
162
|
-
urls.append(resolved)
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
i += 1
|
|
167
|
-
}
|
|
168
|
-
return urls
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/// Parses the `#EXT-X-MAP` init segment URI from a media playlist.
|
|
172
|
-
/// fMP4 streams require the init segment for every quality level.
|
|
173
|
-
private func parseInitSegmentURL(content: String, baseURL: URL) -> URL? {
|
|
174
|
-
for line in content.components(separatedBy: .newlines) {
|
|
175
|
-
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
176
|
-
guard trimmed.hasPrefix("#EXT-X-MAP:") else { continue }
|
|
177
|
-
if let uriStart = trimmed.range(of: "URI=\""),
|
|
178
|
-
let uriEnd = trimmed.range(of: "\"", range: uriStart.upperBound..<trimmed.endIndex) {
|
|
179
|
-
let uri = String(trimmed[uriStart.upperBound..<uriEnd.lowerBound])
|
|
180
|
-
return URL(string: uri, relativeTo: baseURL)?.absoluteURL
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return nil
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/// Parses up to `segmentCount` media segment URLs from a media playlist.
|
|
187
|
-
private func parseSegmentURLs(content: String, baseURL: URL) -> [URL] {
|
|
188
|
-
var urls: [URL] = []
|
|
189
|
-
for line in content.components(separatedBy: .newlines) {
|
|
190
|
-
guard urls.count < segmentCount else { break }
|
|
191
|
-
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
192
|
-
guard !trimmed.isEmpty, !trimmed.hasPrefix("#") else { continue }
|
|
193
|
-
if let resolved = URL(string: trimmed, relativeTo: baseURL)?.absoluteURL {
|
|
194
|
-
urls.append(resolved)
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return urls
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
// MARK: - SegmentAccumulator
|
|
202
|
-
|
|
203
|
-
/// Accumulates streaming segment data and saves it to cache on completion.
|
|
204
|
-
private final class SegmentAccumulator: NetworkDownloaderDelegate {
|
|
205
|
-
|
|
206
|
-
private let storage: VideoCacheStorage
|
|
207
|
-
private let url: URL
|
|
208
|
-
private let onComplete: () -> Void
|
|
209
|
-
private var buffer = Data()
|
|
210
|
-
private let lock = NSLock()
|
|
211
|
-
|
|
212
|
-
init(storage: VideoCacheStorage, url: URL, onComplete: @escaping () -> Void) {
|
|
213
|
-
self.storage = storage
|
|
214
|
-
self.url = url
|
|
215
|
-
self.onComplete = onComplete
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
func didReceiveResponse(task: NetworkTask, response: URLResponse) {}
|
|
219
|
-
|
|
220
|
-
func didReceiveData(task: NetworkTask, data: Data) {
|
|
221
|
-
lock.lock()
|
|
222
|
-
buffer.append(data)
|
|
223
|
-
lock.unlock()
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
func didComplete(task: NetworkTask, error: Error?) {
|
|
227
|
-
if error == nil {
|
|
228
|
-
lock.lock()
|
|
229
|
-
let data = buffer
|
|
230
|
-
lock.unlock()
|
|
231
|
-
storage.save(data: data, for: url.absoluteString)
|
|
232
|
-
}
|
|
233
|
-
onComplete()
|
|
234
|
-
}
|
|
235
|
-
}
|