@stepincto/expo-video 1.0.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.
Files changed (180) hide show
  1. package/README.md +45 -0
  2. package/android/build.gradle +32 -0
  3. package/android/src/main/AndroidManifest.xml +20 -0
  4. package/android/src/main/java/expo/modules/video/AudioFocusManager.kt +241 -0
  5. package/android/src/main/java/expo/modules/video/FullscreenPlayerActivity.kt +145 -0
  6. package/android/src/main/java/expo/modules/video/IntervalUpdateClock.kt +54 -0
  7. package/android/src/main/java/expo/modules/video/MediaMetadataRetriever.kt +89 -0
  8. package/android/src/main/java/expo/modules/video/PictureInPictureHelperFragment.kt +26 -0
  9. package/android/src/main/java/expo/modules/video/PlayerViewExtension.kt +36 -0
  10. package/android/src/main/java/expo/modules/video/VideoCache.kt +104 -0
  11. package/android/src/main/java/expo/modules/video/VideoExceptions.kt +34 -0
  12. package/android/src/main/java/expo/modules/video/VideoManager.kt +133 -0
  13. package/android/src/main/java/expo/modules/video/VideoModule.kt +414 -0
  14. package/android/src/main/java/expo/modules/video/VideoThumbnail.kt +20 -0
  15. package/android/src/main/java/expo/modules/video/VideoView.kt +367 -0
  16. package/android/src/main/java/expo/modules/video/delegates/IgnoreSameSet.kt +24 -0
  17. package/android/src/main/java/expo/modules/video/drawing/OutlineProvider.kt +217 -0
  18. package/android/src/main/java/expo/modules/video/enums/AudioMixingMode.kt +20 -0
  19. package/android/src/main/java/expo/modules/video/enums/ContentFit.kt +19 -0
  20. package/android/src/main/java/expo/modules/video/enums/ContentType.kt +22 -0
  21. package/android/src/main/java/expo/modules/video/enums/DRMType.kt +26 -0
  22. package/android/src/main/java/expo/modules/video/enums/PlayerStatus.kt +10 -0
  23. package/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt +184 -0
  24. package/android/src/main/java/expo/modules/video/playbackService/PlaybackServiceConnection.kt +39 -0
  25. package/android/src/main/java/expo/modules/video/playbackService/VideoMediaSessionCallback.kt +47 -0
  26. package/android/src/main/java/expo/modules/video/player/FirstFrameEventGenerator.kt +93 -0
  27. package/android/src/main/java/expo/modules/video/player/PlayerEvent.kt +164 -0
  28. package/android/src/main/java/expo/modules/video/player/VideoPlayer.kt +460 -0
  29. package/android/src/main/java/expo/modules/video/player/VideoPlayerAudioTracks.kt +125 -0
  30. package/android/src/main/java/expo/modules/video/player/VideoPlayerListener.kt +32 -0
  31. package/android/src/main/java/expo/modules/video/player/VideoPlayerLoadControl.kt +525 -0
  32. package/android/src/main/java/expo/modules/video/player/VideoPlayerSubtitles.kt +125 -0
  33. package/android/src/main/java/expo/modules/video/records/BufferOptions.kt +15 -0
  34. package/android/src/main/java/expo/modules/video/records/DRMOptions.kt +25 -0
  35. package/android/src/main/java/expo/modules/video/records/PlaybackError.kt +19 -0
  36. package/android/src/main/java/expo/modules/video/records/Tracks.kt +81 -0
  37. package/android/src/main/java/expo/modules/video/records/VideoEventPayloads.kt +79 -0
  38. package/android/src/main/java/expo/modules/video/records/VideoMetadata.kt +12 -0
  39. package/android/src/main/java/expo/modules/video/records/VideoSize.kt +14 -0
  40. package/android/src/main/java/expo/modules/video/records/VideoSource.kt +104 -0
  41. package/android/src/main/java/expo/modules/video/records/VideoThumbnailOptions.kt +24 -0
  42. package/android/src/main/java/expo/modules/video/utils/DataSourceUtils.kt +75 -0
  43. package/android/src/main/java/expo/modules/video/utils/EventDispatcherUtils.kt +43 -0
  44. package/android/src/main/java/expo/modules/video/utils/MutableWeakReference.kt +15 -0
  45. package/android/src/main/java/expo/modules/video/utils/PictureInPictureUtils.kt +96 -0
  46. package/android/src/main/java/expo/modules/video/utils/YogaUtils.kt +20 -0
  47. package/android/src/main/res/drawable/seek_backwards_10s.xml +25 -0
  48. package/android/src/main/res/drawable/seek_backwards_15s.xml +25 -0
  49. package/android/src/main/res/drawable/seek_backwards_5s.xml +25 -0
  50. package/android/src/main/res/drawable/seek_forwards_10s.xml +30 -0
  51. package/android/src/main/res/drawable/seek_forwards_15s.xml +31 -0
  52. package/android/src/main/res/drawable/seek_forwards_5s.xml +30 -0
  53. package/android/src/main/res/layout/fullscreen_player_activity.xml +16 -0
  54. package/android/src/main/res/layout/surface_player_view.xml +7 -0
  55. package/android/src/main/res/layout/texture_player_view.xml +7 -0
  56. package/android/src/main/res/values/styles.xml +9 -0
  57. package/app.plugin.js +1 -0
  58. package/build/NativeVideoModule.d.ts +16 -0
  59. package/build/NativeVideoModule.d.ts.map +1 -0
  60. package/build/NativeVideoModule.js +3 -0
  61. package/build/NativeVideoModule.js.map +1 -0
  62. package/build/NativeVideoModule.web.d.ts +3 -0
  63. package/build/NativeVideoModule.web.d.ts.map +1 -0
  64. package/build/NativeVideoModule.web.js +2 -0
  65. package/build/NativeVideoModule.web.js.map +1 -0
  66. package/build/NativeVideoView.d.ts +4 -0
  67. package/build/NativeVideoView.d.ts.map +1 -0
  68. package/build/NativeVideoView.js +6 -0
  69. package/build/NativeVideoView.js.map +1 -0
  70. package/build/VideoModule.d.ts +38 -0
  71. package/build/VideoModule.d.ts.map +1 -0
  72. package/build/VideoModule.js +53 -0
  73. package/build/VideoModule.js.map +1 -0
  74. package/build/VideoPlayer.d.ts +15 -0
  75. package/build/VideoPlayer.d.ts.map +1 -0
  76. package/build/VideoPlayer.js +52 -0
  77. package/build/VideoPlayer.js.map +1 -0
  78. package/build/VideoPlayer.types.d.ts +532 -0
  79. package/build/VideoPlayer.types.d.ts.map +1 -0
  80. package/build/VideoPlayer.types.js +2 -0
  81. package/build/VideoPlayer.types.js.map +1 -0
  82. package/build/VideoPlayer.web.d.ts +75 -0
  83. package/build/VideoPlayer.web.d.ts.map +1 -0
  84. package/build/VideoPlayer.web.js +376 -0
  85. package/build/VideoPlayer.web.js.map +1 -0
  86. package/build/VideoPlayerEvents.types.d.ts +262 -0
  87. package/build/VideoPlayerEvents.types.d.ts.map +1 -0
  88. package/build/VideoPlayerEvents.types.js +2 -0
  89. package/build/VideoPlayerEvents.types.js.map +1 -0
  90. package/build/VideoThumbnail.d.ts +29 -0
  91. package/build/VideoThumbnail.d.ts.map +1 -0
  92. package/build/VideoThumbnail.js +3 -0
  93. package/build/VideoThumbnail.js.map +1 -0
  94. package/build/VideoView.d.ts +44 -0
  95. package/build/VideoView.d.ts.map +1 -0
  96. package/build/VideoView.js +76 -0
  97. package/build/VideoView.js.map +1 -0
  98. package/build/VideoView.types.d.ts +147 -0
  99. package/build/VideoView.types.d.ts.map +1 -0
  100. package/build/VideoView.types.js +2 -0
  101. package/build/VideoView.types.js.map +1 -0
  102. package/build/VideoView.web.d.ts +9 -0
  103. package/build/VideoView.web.d.ts.map +1 -0
  104. package/build/VideoView.web.js +180 -0
  105. package/build/VideoView.web.js.map +1 -0
  106. package/build/index.d.ts +9 -0
  107. package/build/index.d.ts.map +1 -0
  108. package/build/index.js +7 -0
  109. package/build/index.js.map +1 -0
  110. package/build/resolveAssetSource.d.ts +3 -0
  111. package/build/resolveAssetSource.d.ts.map +1 -0
  112. package/build/resolveAssetSource.js +3 -0
  113. package/build/resolveAssetSource.js.map +1 -0
  114. package/build/resolveAssetSource.web.d.ts +4 -0
  115. package/build/resolveAssetSource.web.d.ts.map +1 -0
  116. package/build/resolveAssetSource.web.js +16 -0
  117. package/build/resolveAssetSource.web.js.map +1 -0
  118. package/expo-module.config.json +9 -0
  119. package/ios/Cache/CachableRequest.swift +44 -0
  120. package/ios/Cache/CachedResource.swift +97 -0
  121. package/ios/Cache/CachingHelpers.swift +92 -0
  122. package/ios/Cache/MediaFileHandle.swift +94 -0
  123. package/ios/Cache/MediaInfo.swift +147 -0
  124. package/ios/Cache/ResourceLoaderDelegate.swift +274 -0
  125. package/ios/Cache/SynchronizedHashTable.swift +23 -0
  126. package/ios/Cache/VideoCacheManager.swift +338 -0
  127. package/ios/ContentKeyDelegate.swift +214 -0
  128. package/ios/ContentKeyManager.swift +21 -0
  129. package/ios/Enums/AudioMixingMode.swift +37 -0
  130. package/ios/Enums/ContentType.swift +12 -0
  131. package/ios/Enums/DRMType.swift +20 -0
  132. package/ios/Enums/PlayerStatus.swift +10 -0
  133. package/ios/Enums/VideoContentFit.swift +39 -0
  134. package/ios/ExpoVideo.podspec +29 -0
  135. package/ios/NowPlayingManager.swift +296 -0
  136. package/ios/Records/BufferOptions.swift +12 -0
  137. package/ios/Records/DRMOptions.swift +24 -0
  138. package/ios/Records/PlaybackError.swift +10 -0
  139. package/ios/Records/Tracks.swift +176 -0
  140. package/ios/Records/VideoEventPayloads.swift +76 -0
  141. package/ios/Records/VideoMetadata.swift +16 -0
  142. package/ios/Records/VideoSize.swift +15 -0
  143. package/ios/Records/VideoSource.swift +25 -0
  144. package/ios/Thumbnails/VideoThumbnail.swift +27 -0
  145. package/ios/Thumbnails/VideoThumbnailGenerator.swift +68 -0
  146. package/ios/Thumbnails/VideoThumbnailOptions.swift +15 -0
  147. package/ios/VideoAsset.swift +123 -0
  148. package/ios/VideoExceptions.swift +53 -0
  149. package/ios/VideoItem.swift +11 -0
  150. package/ios/VideoManager.swift +140 -0
  151. package/ios/VideoModule.swift +383 -0
  152. package/ios/VideoPlayer/DangerousPropertiesStore.swift +19 -0
  153. package/ios/VideoPlayer.swift +435 -0
  154. package/ios/VideoPlayerAudioTracks.swift +72 -0
  155. package/ios/VideoPlayerItem.swift +97 -0
  156. package/ios/VideoPlayerObserver.swift +523 -0
  157. package/ios/VideoPlayerSubtitles.swift +71 -0
  158. package/ios/VideoSourceLoader.swift +89 -0
  159. package/ios/VideoSourceLoaderListener.swift +34 -0
  160. package/ios/VideoView.swift +224 -0
  161. package/package.json +59 -0
  162. package/plugin/build/tsconfig.tsbuildinfo +1 -0
  163. package/plugin/build/withExpoVideo.d.ts +7 -0
  164. package/plugin/build/withExpoVideo.js +38 -0
  165. package/src/NativeVideoModule.ts +20 -0
  166. package/src/NativeVideoModule.web.ts +1 -0
  167. package/src/NativeVideoView.ts +8 -0
  168. package/src/VideoModule.ts +59 -0
  169. package/src/VideoPlayer.tsx +67 -0
  170. package/src/VideoPlayer.types.ts +613 -0
  171. package/src/VideoPlayer.web.tsx +451 -0
  172. package/src/VideoPlayerEvents.types.ts +313 -0
  173. package/src/VideoThumbnail.ts +31 -0
  174. package/src/VideoView.tsx +86 -0
  175. package/src/VideoView.types.ts +165 -0
  176. package/src/VideoView.web.tsx +214 -0
  177. package/src/index.ts +46 -0
  178. package/src/resolveAssetSource.ts +2 -0
  179. package/src/resolveAssetSource.web.ts +17 -0
  180. package/src/ts-declarations/react-native-assets.d.ts +1 -0
@@ -0,0 +1,338 @@
1
+ import ExpoModulesCore
2
+
3
+ class VideoCacheManager {
4
+ static let defaultMaxCacheSize = 1_024_000_000 // 1GB
5
+ static let defaultAutoCleanCache = true
6
+ static let expoVideoCacheScheme = "expo-video-cache"
7
+ static let expoVideoCacheDirectory = "expo-video-cache"
8
+ static let mediaInfoSuffix = "&mediaInfo"
9
+
10
+ static let shared = VideoCacheManager()
11
+ private let defaults = UserDefaults.standard
12
+
13
+ private let maxCacheSizeKey = "\(VideoCacheManager.expoVideoCacheScheme)/maxCacheSize"
14
+
15
+ // Files currently being used/modified by the player - they will be skipped when clearing the cache
16
+ private var openFiles: Set<URL> = Set()
17
+
18
+ // All cache commands such as clean or adding new data should be run on this queue
19
+ let cacheQueue = DispatchQueue(label: "\(VideoCacheManager.expoVideoCacheScheme)-dispatch-queue")
20
+
21
+ var maxCacheSize: Int {
22
+ defaults.maybeInteger(forKey: maxCacheSizeKey) ?? Self.defaultMaxCacheSize
23
+ }
24
+
25
+ func registerOpenFile(at url: URL) {
26
+ openFiles.insert(url)
27
+ }
28
+
29
+ func unregisterOpenFile(at url: URL) {
30
+ openFiles.remove(url)
31
+ }
32
+
33
+ func setMaxCacheSize(newSize: Int) throws {
34
+ if VideoManager.shared.hasRegisteredPlayers {
35
+ throw VideoCacheException("Cannot change the cache size while there are active players")
36
+ }
37
+
38
+ defaults.setValue(newSize, forKey: maxCacheSizeKey)
39
+ ensureCacheSize()
40
+ }
41
+
42
+ func ensureCacheSize() {
43
+ cacheQueue.async { [weak self] in
44
+ guard let self else {
45
+ return
46
+ }
47
+
48
+ do {
49
+ try self.limitCacheSize(to: maxCacheSize)
50
+ } catch {
51
+ log.warn("Failed to auto clean expo-video cache")
52
+ }
53
+ }
54
+ }
55
+
56
+ func clearAllCache() async throws {
57
+ return try await withCheckedThrowingContinuation { continuation in
58
+ cacheQueue.async { [weak self] in
59
+ do {
60
+ try self?.deleteAllFilesInCacheDirectory()
61
+ continuation.resume()
62
+ } catch {
63
+ continuation.resume(throwing: error)
64
+ }
65
+ }
66
+ }
67
+ }
68
+
69
+ func ensureCacheIntegrity(forSavePath videoFilePath: String) {
70
+ let mediaInfoPath = videoFilePath + Self.mediaInfoSuffix
71
+ let videoFileExists = FileManager.default.fileExists(atPath: videoFilePath)
72
+ let mediaInfoExists = FileManager.default.fileExists(atPath: mediaInfoPath)
73
+
74
+ // If mediaInfo exists and the corresponding data file doesn't we need to remove to avoid false data in
75
+ // the `loadedDataRanges` field
76
+ if mediaInfoExists && !videoFileExists {
77
+ try? FileManager.default.removeItem(atPath: mediaInfoPath)
78
+ }
79
+ }
80
+
81
+ func getCacheDirectorySize() -> Int64 {
82
+ guard let folderUrl = getCacheDirectory() else {
83
+ return 0
84
+ }
85
+ let fileManager = FileManager.default
86
+ var totalSize: Int64 = 0
87
+
88
+ guard let enumerator = fileManager.enumerator(at: folderUrl, includingPropertiesForKeys: [.fileSizeKey], options: .skipsHiddenFiles) else {
89
+ return 0
90
+ }
91
+
92
+ for case let fileURL as URL in enumerator {
93
+ guard let fileAttributes = try? fileURL.resourceValues(forKeys: [.fileSizeKey]) else {
94
+ continue
95
+ }
96
+ if let fileSize = fileAttributes.fileSize {
97
+ totalSize += Int64(fileSize)
98
+ }
99
+ }
100
+
101
+ return totalSize
102
+ }
103
+
104
+ private func limitCacheSize(to maxSize: Int) throws {
105
+ let allFileURLs = getVideoFilesUrls()
106
+
107
+ var totalSize: Int64 = 0
108
+ var fileInfo = [(url: URL, size: Int64, accessDate: Date)]()
109
+
110
+ for url in allFileURLs {
111
+ let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
112
+ let fileSize = attributes[.size] as? Int64 ?? 0
113
+ let accessDate = try url.resourceValues(forKeys: [.contentAccessDateKey]).contentAccessDate ?? Date.distantPast
114
+ totalSize += fileSize
115
+ fileInfo.append((url: url, size: fileSize, accessDate: accessDate))
116
+ }
117
+
118
+ if totalSize <= maxSize {
119
+ return
120
+ }
121
+
122
+ let deletableFileInfo = fileInfo.filter { !fileIsOpen(url: $0.url) }
123
+ let sortedFileInfo = deletableFileInfo.sorted { $0.accessDate < $1.accessDate }
124
+
125
+ for fileInfo in sortedFileInfo {
126
+ if totalSize <= maxSize {
127
+ continue
128
+ }
129
+ try removeVideoAndMimeTypeFile(at: fileInfo.url)
130
+ totalSize -= fileInfo.size
131
+ }
132
+ }
133
+
134
+ private func deleteAllFilesInCacheDirectory() throws {
135
+ if VideoManager.shared.hasRegisteredPlayers {
136
+ throw VideoCacheException("Cannot clear cache while there are active players")
137
+ }
138
+
139
+ guard let cacheDirectory = getCacheDirectory() else {
140
+ return
141
+ }
142
+
143
+ let fileUrls = try FileManager.default.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil, options: [])
144
+
145
+ for fileUrl in fileUrls {
146
+ try removeVideoAndMimeTypeFile(at: fileUrl)
147
+ }
148
+ }
149
+
150
+ private func removeVideoAndMimeTypeFile(at fileUrl: URL) throws {
151
+ let mimeTypeFileUrl = URL(string: "\(fileUrl.relativeString)\(Self.mediaInfoSuffix)")
152
+ try FileManager.default.removeItem(at: fileUrl)
153
+ if let mimeTypeFileUrl, FileManager.default.fileExists(atPath: mimeTypeFileUrl.relativePath) {
154
+ try FileManager.default.removeItem(at: mimeTypeFileUrl)
155
+ }
156
+ }
157
+
158
+ private func getCacheDirectory() -> URL? {
159
+ let cacheDirs = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)
160
+ if let cacheDir = cacheDirs.first {
161
+ return cacheDir.appendingPathComponent(VideoCacheManager.expoVideoCacheDirectory)
162
+ }
163
+ return nil
164
+ }
165
+
166
+ private func getVideoFilesUrls() -> [URL] {
167
+ guard let videoCacheDir = getCacheDirectory() else {
168
+ print("Failed to get the video cache directory.")
169
+ return []
170
+ }
171
+ let fileUrls = (try? FileManager.default.contentsOfDirectory(
172
+ at: videoCacheDir,
173
+ includingPropertiesForKeys: [.contentAccessDateKey, .contentModificationDateKey],
174
+ options: .skipsHiddenFiles)
175
+ ) ?? []
176
+ return fileUrls.filter { !$0.absoluteString.hasSuffix(Self.mediaInfoSuffix) }
177
+ }
178
+
179
+ private func fileIsOpen(url: URL) -> Bool {
180
+ return openFiles.contains(url) || openFiles.contains { $0.relativePath == url.relativePath }
181
+ }
182
+ }
183
+
184
+ // MARK: - Custom Caching Methods
185
+
186
+ extension VideoCacheManager {
187
+ static func preCacheVideoAsync(from urlString: String) async throws -> Bool {
188
+ // Check if already cached (same logic as isVideoCachedAsync)
189
+ if let url = URL(string: urlString) {
190
+ let fileExtension = url.pathExtension
191
+ if let saveFilePath = VideoAsset.pathForUrl(url: url, fileExtension: fileExtension) {
192
+ let mediaInfoPath = saveFilePath + VideoCacheManager.mediaInfoSuffix
193
+ if let mediaInfo = MediaInfo(at: mediaInfoPath) {
194
+ let expectedLength = Int(mediaInfo.expectedContentLength)
195
+ let ranges = mediaInfo.loadedDataRanges
196
+ if ranges.count == 1, ranges[0].0 == 0, ranges[0].1 == expectedLength - 1 {
197
+ print("[expo-video] File already fully cached, skipping download.")
198
+ return true
199
+ }
200
+ }
201
+ }
202
+ }
203
+ guard let url = URL(string: urlString) else {
204
+ throw NSError(domain: "VideoCacheManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL string"])
205
+ }
206
+ let fileExtension = url.pathExtension
207
+ guard let saveFilePath = VideoAsset.pathForUrl(url: url, fileExtension: fileExtension) else {
208
+ throw NSError(domain: "VideoCacheManager", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to create cache file path"])
209
+ }
210
+ let fileUrl = URL(fileURLWithPath: saveFilePath)
211
+ let mediaInfoPath = saveFilePath + VideoCacheManager.mediaInfoSuffix
212
+
213
+ let (data, response) = try await URLSession.shared.data(from: url)
214
+ try data.write(to: fileUrl, options: .atomic)
215
+
216
+ let expectedLength = Int64(data.count)
217
+ let mimeType = response.mimeType
218
+ let supportsByteRangeAccess = true
219
+ let headerFields: [String: String]? = nil
220
+ let mediaInfo = MediaInfo(expectedContentLength: expectedLength, mimeType: mimeType, supportsByteRangeAccess: supportsByteRangeAccess, headerFields: headerFields, savePath: mediaInfoPath)
221
+ mediaInfo.setLoadedDataRanges([(0, data.count - 1)])
222
+ mediaInfo.saveToFile()
223
+
224
+ // Print current cache size
225
+ let cacheSize = VideoCacheManager.shared.getCacheDirectorySize()
226
+ print("[expo-video] Cache size after caching: \(cacheSize) bytes")
227
+ return false
228
+ }
229
+
230
+ static func preCacheVideoPartialAsync(from urlString: String, chunkSize: Int = 1_048_576) async throws -> Bool {
231
+ // Check if already cached (same logic as isVideoCachedAsync)
232
+ if let url = URL(string: urlString) {
233
+ let fileExtension = url.pathExtension
234
+ if let saveFilePath = VideoAsset.pathForUrl(url: url, fileExtension: fileExtension) {
235
+ let mediaInfoPath = saveFilePath + VideoCacheManager.mediaInfoSuffix
236
+ if let mediaInfo = MediaInfo(at: mediaInfoPath) {
237
+ let expectedLength = Int(mediaInfo.expectedContentLength)
238
+ let ranges = mediaInfo.loadedDataRanges
239
+ if ranges.count == 1, ranges[0].0 == 0, ranges[0].1 == expectedLength - 1 {
240
+ print("[expo-video] File already fully cached, skipping download.")
241
+ return true
242
+ }
243
+ }
244
+ }
245
+ }
246
+ guard let url = URL(string: urlString) else {
247
+ throw NSError(domain: "VideoCacheManager", code: 1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL string"])
248
+ }
249
+ let fileExtension = url.pathExtension
250
+ guard let saveFilePath = VideoAsset.pathForUrl(url: url, fileExtension: fileExtension) else {
251
+ throw NSError(domain: "VideoCacheManager", code: 2, userInfo: [NSLocalizedDescriptionKey: "Failed to create cache file path"])
252
+ }
253
+ let fileUrl = URL(fileURLWithPath: saveFilePath)
254
+ let mediaInfoPath = saveFilePath + VideoCacheManager.mediaInfoSuffix
255
+
256
+ // Ensure the cache directory exists (same as VideoAsset)
257
+ if let cacheDir = try? FileManager.default.url(for: .cachesDirectory, in: .userDomainMask, appropriateFor: nil, create: true) {
258
+ let videoCacheDir = cacheDir.appendingPathComponent(VideoCacheManager.expoVideoCacheScheme, isDirectory: true)
259
+ try? FileManager.default.createDirectory(at: videoCacheDir, withIntermediateDirectories: true)
260
+ }
261
+
262
+ // Ensure the file exists before opening for writing
263
+ if !FileManager.default.fileExists(atPath: fileUrl.path) {
264
+ FileManager.default.createFile(atPath: fileUrl.path, contents: nil, attributes: nil)
265
+ }
266
+ let fileHandle = try FileHandle(forWritingTo: fileUrl)
267
+ defer { try? fileHandle.close() }
268
+
269
+ // Restore these variable declarations:
270
+ var expectedLength: Int64 = 0
271
+ var mimeType: String? = nil
272
+ var supportsByteRangeAccess = true
273
+ var headerFields: [String: String]? = nil
274
+ var loadedRanges: [(Int, Int)] = []
275
+
276
+ // Make a HEAD request to get expectedLength and headers
277
+ var headRequest = URLRequest(url: url)
278
+ headRequest.httpMethod = "HEAD"
279
+ let (_, headResponse) = try await URLSession.shared.data(for: headRequest)
280
+ if let httpResponse = headResponse as? HTTPURLResponse {
281
+ if let contentLength = httpResponse.value(forHTTPHeaderField: "Content-Length"), let length = Int64(contentLength) {
282
+ expectedLength = length
283
+ }
284
+ mimeType = httpResponse.mimeType
285
+ supportsByteRangeAccess = httpResponse.value(forHTTPHeaderField: "Accept-Ranges") == "bytes"
286
+ headerFields = httpResponse.allHeaderFields as? [String: String]
287
+ }
288
+
289
+ var offset = 0
290
+ while Int64(offset) < expectedLength {
291
+ let end = min(offset + chunkSize - 1, Int(expectedLength) - 1)
292
+ var request = URLRequest(url: url)
293
+ request.setValue("bytes=\(offset)-\(end)", forHTTPHeaderField: "Range")
294
+ let (chunkData, _) = try await URLSession.shared.data(for: request)
295
+ try fileHandle.seek(toOffset: UInt64(offset))
296
+ try fileHandle.write(contentsOf: chunkData)
297
+ loadedRanges.append((offset, offset + chunkData.count - 1))
298
+ offset += chunkData.count
299
+
300
+ let mediaInfo = MediaInfo(expectedContentLength: expectedLength, mimeType: mimeType, supportsByteRangeAccess: supportsByteRangeAccess, headerFields: headerFields, savePath: mediaInfoPath)
301
+ mediaInfo.setLoadedDataRanges(loadedRanges)
302
+ mediaInfo.saveToFile()
303
+ let cacheSize = VideoCacheManager.shared.getCacheDirectorySize()
304
+ print("[expo-video] Cache size after chunk: \(cacheSize) bytes")
305
+ }
306
+ // Merge all ranges before final save
307
+ if !loadedRanges.isEmpty {
308
+ loadedRanges.sort { $0.0 < $1.0 }
309
+ var merged: [(Int, Int)] = []
310
+ for range in loadedRanges {
311
+ if let last = merged.last, last.1 + 1 >= range.0 {
312
+ merged[merged.count - 1] = (last.0, max(last.1, range.1))
313
+ } else {
314
+ merged.append(range)
315
+ }
316
+ }
317
+ loadedRanges = merged
318
+ }
319
+ // Final save
320
+ let mediaInfo = MediaInfo(expectedContentLength: expectedLength, mimeType: mimeType, supportsByteRangeAccess: supportsByteRangeAccess, headerFields: headerFields, savePath: mediaInfoPath)
321
+ mediaInfo.setLoadedDataRanges(loadedRanges)
322
+ mediaInfo.saveToFile()
323
+ let cacheSize = VideoCacheManager.shared.getCacheDirectorySize()
324
+ print("[expo-video] Final cache size: \(cacheSize) bytes")
325
+ print("[expo-video] MediaInfo debug: path=\(mediaInfoPath), loadedDataRanges=\(loadedRanges), expectedLength=\(expectedLength)")
326
+ return false
327
+ }
328
+ }
329
+
330
+ private extension UserDefaults {
331
+ func exists(forKey key: String) -> Bool {
332
+ return Self.standard.object(forKey: key) != nil
333
+ }
334
+
335
+ func maybeInteger(forKey key: String) -> Int? {
336
+ Self.standard.exists(forKey: key) ? Self.standard.integer(forKey: key) : nil
337
+ }
338
+ }
@@ -0,0 +1,214 @@
1
+ // Copyright 2024-present 650 Industries. All rights reserved.
2
+
3
+ import AVFoundation
4
+ import ExpoModulesCore
5
+
6
+ internal class ContentKeyDelegate: NSObject, AVContentKeySessionDelegate {
7
+ // Video source that is currently being loaded. Used for retrieving information like license and certificate urls
8
+ var videoSource: VideoSource?
9
+
10
+ // MARK: - AVContentKeySessionDelegate
11
+
12
+ func contentKeySession(_ session: AVContentKeySession, didProvide keyRequest: AVContentKeyRequest) {
13
+ handleStreamingContentKeyRequest(keyRequest: keyRequest)
14
+ }
15
+
16
+ func contentKeySession(_ session: AVContentKeySession, didProvideRenewingContentKeyRequest keyRequest: AVContentKeyRequest) {
17
+ handleStreamingContentKeyRequest(keyRequest: keyRequest)
18
+ }
19
+
20
+ func contentKeySession(
21
+ _ session: AVContentKeySession,
22
+ shouldRetry keyRequest: AVContentKeyRequest,
23
+ reason retryReason: AVContentKeyRequest.RetryReason
24
+ ) -> Bool {
25
+ var shouldRetry = false
26
+
27
+ switch retryReason {
28
+ case AVContentKeyRequest.RetryReason.timedOut:
29
+ shouldRetry = true
30
+ case AVContentKeyRequest.RetryReason.receivedResponseWithExpiredLease:
31
+ shouldRetry = true
32
+ case AVContentKeyRequest.RetryReason.receivedObsoleteContentKey:
33
+ shouldRetry = true
34
+ default:
35
+ break
36
+ }
37
+
38
+ return shouldRetry
39
+ }
40
+
41
+ func contentKeySession(_ session: AVContentKeySession, contentKeyRequest keyRequest: AVContentKeyRequest, didFailWithError err: Error) {
42
+ log.error(err)
43
+ }
44
+
45
+ // MARK: - Private
46
+
47
+ func handleStreamingContentKeyRequest(keyRequest: AVContentKeyRequest) {
48
+ do {
49
+ try provideOnlineKey(keyRequest: keyRequest)
50
+ } catch {
51
+ keyRequest.processContentKeyResponseError(error)
52
+ }
53
+ }
54
+
55
+ private func provideOnlineKey(keyRequest: AVContentKeyRequest) throws {
56
+ guard
57
+ let assetIdString = findAssetIdString(keyRequest: keyRequest, videoSource: videoSource),
58
+ let assetIdData = assetIdString.data(using: .utf8)
59
+ else {
60
+ throw DRMLoadException("Failed to find the asset id for request: \(String(describing: keyRequest.identifier))")
61
+ }
62
+
63
+ let applicationCertificate = try self.requestApplicationCertificate(keyRequest: keyRequest)
64
+
65
+ let completionHandler = { [weak self] (spcData: Data?, error: Error?) in
66
+ guard let self else {
67
+ keyRequest.processContentKeyResponseError(DRMLoadException("Couldn't find a reference to the key delegate in the online key completion handler."))
68
+ return
69
+ }
70
+
71
+ if let error {
72
+ keyRequest.processContentKeyResponseError(error)
73
+ return
74
+ }
75
+
76
+ guard let spcData else {
77
+ return
78
+ }
79
+
80
+ do {
81
+ let ckcData = try self.requestContentKeyFromKeySecurityModule(spcData: spcData, assetID: assetIdString, keyRequest: keyRequest)
82
+ let keyResponse = AVContentKeyResponse(fairPlayStreamingKeyResponseData: ckcData)
83
+ keyRequest.processContentKeyResponse(keyResponse)
84
+ } catch {
85
+ keyRequest.processContentKeyResponseError(error)
86
+ }
87
+ }
88
+
89
+ keyRequest.makeStreamingContentKeyRequestData(
90
+ forApp: applicationCertificate,
91
+ contentIdentifier: assetIdData,
92
+ options: [AVContentKeyRequestProtocolVersionsKey: [1]],
93
+ completionHandler: completionHandler
94
+ )
95
+ }
96
+
97
+ private func requestApplicationCertificate(keyRequest: AVContentKeyRequest) throws -> Data {
98
+ if let certificateData = videoSource?.drm?.base64CertificateData {
99
+ return try requestCertificateFrom(base64String: certificateData)
100
+ }
101
+
102
+ guard let url = videoSource?.drm?.certificateUrl else {
103
+ throw DRMLoadException("The certificate uri and data are null")
104
+ }
105
+ return try requestCertificateFrom(url: url)
106
+ }
107
+
108
+ private func requestCertificateFrom(url: URL) throws -> Data {
109
+ let urlRequest = URLRequest(url: url)
110
+ let (data, response, error) = URLSession.shared.synchronousDataTask(with: urlRequest)
111
+
112
+ guard error == nil else {
113
+ let errorDescription = error?.localizedDescription ?? "unknown error"
114
+ throw DRMLoadException("Failed to load the application certificate from \(url.absoluteString): \(errorDescription)")
115
+ }
116
+
117
+ if let httpResponse = response as? HTTPURLResponse {
118
+ guard httpResponse.statusCode == 200 else {
119
+ throw DRMLoadException("Fetching the application certificate failed with status: \(httpResponse.statusCode)")
120
+ }
121
+ }
122
+ guard let data else {
123
+ throw DRMLoadException("Application certificate data received from \(url.absoluteString) is empty")
124
+ }
125
+
126
+ guard SecCertificateCreateWithData(nil, data as CFData) != nil else {
127
+ throw DRMLoadException("The application certificate received from the server is invalid")
128
+ }
129
+
130
+ return data
131
+ }
132
+
133
+ private func requestCertificateFrom(base64String: String) throws -> Data {
134
+ guard let certificateData = Data(base64Encoded: base64String, options: .ignoreUnknownCharacters) else {
135
+ throw DRMLoadException("Failed to load the application certificate from the provided base64 string")
136
+ }
137
+ return certificateData
138
+ }
139
+
140
+ private func requestContentKeyFromKeySecurityModule(spcData: Data, assetID: String, keyRequest: AVContentKeyRequest) throws -> Data {
141
+ let ckcData: Data? = nil
142
+
143
+ guard let licenseServerUri = videoSource?.drm?.licenseServer else {
144
+ throw DRMLoadException("LicenseServer uri hasn't been provided")
145
+ }
146
+ guard let licenseServerUrl = URL(string: licenseServerUri) else {
147
+ throw DRMLoadException("LicenseServer uri is invalid")
148
+ }
149
+
150
+ var ckcRequest = URLRequest(url: licenseServerUrl)
151
+ ckcRequest.httpMethod = "POST"
152
+ ckcRequest.httpBody = spcData
153
+
154
+ if let headers = videoSource?.drm?.headers {
155
+ for item in headers {
156
+ guard let value = item.value as? String else {
157
+ continue
158
+ }
159
+ ckcRequest.setValue(value, forHTTPHeaderField: item.key)
160
+ }
161
+ }
162
+
163
+ let (data, response, error) = URLSession.shared.synchronousDataTask(with: ckcRequest)
164
+
165
+ guard error == nil else {
166
+ throw DRMLoadException("Fetching the content key has failed with error: \(String(describing: error?.localizedDescription))")
167
+ }
168
+
169
+ if let httpResponse = response as? HTTPURLResponse {
170
+ guard httpResponse.statusCode == 200 else {
171
+ throw DRMLoadException("Fetching the content key has failed with status: \(httpResponse.statusCode)")
172
+ }
173
+ }
174
+ guard let data else {
175
+ throw DRMLoadException("Fetched content key data is empty")
176
+ }
177
+ return data
178
+ }
179
+
180
+ private func findAssetIdString(keyRequest: AVContentKeyRequest) -> String? {
181
+ let url = keyRequest.identifier as? String
182
+ return url?.replacingOccurrences(of: "skd://", with: "")
183
+ }
184
+
185
+ private func findAssetIdString(keyRequest: AVContentKeyRequest, videoSource: VideoSource?) -> String? {
186
+ return videoSource?.drm?.contentId ?? findAssetIdString(keyRequest: keyRequest)
187
+ }
188
+ }
189
+
190
+ // MARK: - Utils
191
+
192
+ // https://stackoverflow.com/questions/26784315/can-i-somehow-do-a-synchronous-http-request-via-nsurlsession-in-swift
193
+ private extension URLSession {
194
+ func synchronousDataTask(with urlRequest: URLRequest) -> (data: Data?, response: URLResponse?, error: Error?) {
195
+ var data: Data?
196
+ var response: URLResponse?
197
+ var error: Error?
198
+
199
+ let semaphore = DispatchSemaphore(value: 0)
200
+
201
+ let dataTask = self.dataTask(with: urlRequest) {
202
+ data = $0
203
+ response = $1
204
+ error = $2
205
+
206
+ semaphore.signal()
207
+ }
208
+ dataTask.resume()
209
+
210
+ _ = semaphore.wait(timeout: .distantFuture)
211
+
212
+ return (data, response, error)
213
+ }
214
+ }
@@ -0,0 +1,21 @@
1
+ // Copyright 2024-present 650 Industries. All rights reserved.
2
+
3
+ import AVFoundation
4
+
5
+ internal class ContentKeyManager {
6
+ static let contentKeyDelegateQueue = DispatchQueue(label: "dev.expo.video.ExpoVideo.ContentKeyDelegateQueue")
7
+ let contentKeySession: AVContentKeySession
8
+ let contentKeyDelegate: ContentKeyDelegate
9
+
10
+ init() {
11
+ contentKeySession = AVContentKeySession(keySystem: .fairPlayStreaming)
12
+ contentKeyDelegate = ContentKeyDelegate()
13
+
14
+ contentKeySession.setDelegate(contentKeyDelegate, queue: ContentKeyManager.contentKeyDelegateQueue)
15
+ }
16
+
17
+ func addContentKeyRequest(videoSource: VideoSource, asset: AVContentKeyRecipient) {
18
+ contentKeyDelegate.videoSource = videoSource
19
+ contentKeySession.addContentKeyRecipient(asset)
20
+ }
21
+ }
@@ -0,0 +1,37 @@
1
+ // Copyright 2024-present 650 Industries. All rights reserved.
2
+
3
+ import ExpoModulesCore
4
+ import CoreMedia
5
+
6
+ internal enum AudioMixingMode: String, Enumerable {
7
+ case mixWithOthers
8
+ case duckOthers
9
+ case doNotMix
10
+ case auto
11
+
12
+ func priority() -> Int {
13
+ switch self {
14
+ case .doNotMix:
15
+ return 3
16
+ case .auto:
17
+ return 2
18
+ case .duckOthers:
19
+ return 1
20
+ case .mixWithOthers:
21
+ return 0
22
+ }
23
+ }
24
+
25
+ func toSessionCategoryOption() -> AVAudioSession.CategoryOptions? {
26
+ switch self {
27
+ case .duckOthers:
28
+ return .duckOthers
29
+ case .mixWithOthers:
30
+ return .mixWithOthers
31
+ case .doNotMix:
32
+ return nil
33
+ case .auto:
34
+ return nil
35
+ }
36
+ }
37
+ }
@@ -0,0 +1,12 @@
1
+ // Copyright 2025-present 650 Industries. All rights reserved.
2
+
3
+ import ExpoModulesCore
4
+ import CoreMedia
5
+
6
+ internal enum ContentType: String, Enumerable {
7
+ case auto
8
+ case progressive
9
+ case hls
10
+ case dash
11
+ case smoothStreaming
12
+ }
@@ -0,0 +1,20 @@
1
+ // Copyright 2024-present 650 Industries. All rights reserved.
2
+
3
+ import ExpoModulesCore
4
+
5
+ internal enum DRMType: String, Enumerable {
6
+ case clearkey
7
+ case fairplay
8
+ case playready
9
+ case widevine
10
+
11
+ func isSupported() -> Bool {
12
+ return self == .fairplay
13
+ }
14
+
15
+ internal func assertIsSupported() throws {
16
+ if !isSupported() {
17
+ throw DRMUnsupportedException(self)
18
+ }
19
+ }
20
+ }
@@ -0,0 +1,10 @@
1
+ // Copyright 2024-present 650 Industries. All rights reserved.
2
+
3
+ import ExpoModulesCore
4
+
5
+ internal enum PlayerStatus: String, Enumerable {
6
+ case idle
7
+ case loading
8
+ case readyToPlay
9
+ case error
10
+ }
@@ -0,0 +1,39 @@
1
+ import ExpoModulesCore
2
+
3
+ internal enum VideoContentFit: String, Enumerable {
4
+ /**
5
+ The video is scaled to maintain its aspect ratio while fitting within the container's box.
6
+ The entire video is made to fill the box, while preserving its aspect ratio,
7
+ so the video will be "letterboxed" if its aspect ratio does not match the aspect ratio of the box.
8
+ */
9
+ case contain
10
+
11
+ /**
12
+ The video is sized to maintain its aspect ratio while filling the element's entire content box.
13
+ If the video's aspect ratio does not match the aspect ratio of its box, then the object will be clipped to fit.
14
+ */
15
+ case cover
16
+
17
+ /**
18
+ The video is sized to fill the element's content box. The entire object will completely fill the box.
19
+ If the video's aspect ratio does not match the aspect ratio of its box, then the video will be stretched to fit.
20
+ */
21
+ case fill
22
+
23
+ // TODO: Add `none` and `scaleDown`
24
+
25
+ /**
26
+ `VideoContentFit` cases can be directly translated to the native `AVLayerVideoGravity`
27
+ except `scaleDown` that needs to be handled differently at the later step of rendering.
28
+ */
29
+ func toVideoGravity() -> AVLayerVideoGravity {
30
+ switch self {
31
+ case .contain:
32
+ return .resizeAspect
33
+ case .cover:
34
+ return .resizeAspectFill
35
+ case .fill:
36
+ return .resize
37
+ }
38
+ }
39
+ }