@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.
- package/README.md +45 -0
- package/android/build.gradle +32 -0
- package/android/src/main/AndroidManifest.xml +20 -0
- package/android/src/main/java/expo/modules/video/AudioFocusManager.kt +241 -0
- package/android/src/main/java/expo/modules/video/FullscreenPlayerActivity.kt +145 -0
- package/android/src/main/java/expo/modules/video/IntervalUpdateClock.kt +54 -0
- package/android/src/main/java/expo/modules/video/MediaMetadataRetriever.kt +89 -0
- package/android/src/main/java/expo/modules/video/PictureInPictureHelperFragment.kt +26 -0
- package/android/src/main/java/expo/modules/video/PlayerViewExtension.kt +36 -0
- package/android/src/main/java/expo/modules/video/VideoCache.kt +104 -0
- package/android/src/main/java/expo/modules/video/VideoExceptions.kt +34 -0
- package/android/src/main/java/expo/modules/video/VideoManager.kt +133 -0
- package/android/src/main/java/expo/modules/video/VideoModule.kt +414 -0
- package/android/src/main/java/expo/modules/video/VideoThumbnail.kt +20 -0
- package/android/src/main/java/expo/modules/video/VideoView.kt +367 -0
- package/android/src/main/java/expo/modules/video/delegates/IgnoreSameSet.kt +24 -0
- package/android/src/main/java/expo/modules/video/drawing/OutlineProvider.kt +217 -0
- package/android/src/main/java/expo/modules/video/enums/AudioMixingMode.kt +20 -0
- package/android/src/main/java/expo/modules/video/enums/ContentFit.kt +19 -0
- package/android/src/main/java/expo/modules/video/enums/ContentType.kt +22 -0
- package/android/src/main/java/expo/modules/video/enums/DRMType.kt +26 -0
- package/android/src/main/java/expo/modules/video/enums/PlayerStatus.kt +10 -0
- package/android/src/main/java/expo/modules/video/playbackService/ExpoVideoPlaybackService.kt +184 -0
- package/android/src/main/java/expo/modules/video/playbackService/PlaybackServiceConnection.kt +39 -0
- package/android/src/main/java/expo/modules/video/playbackService/VideoMediaSessionCallback.kt +47 -0
- package/android/src/main/java/expo/modules/video/player/FirstFrameEventGenerator.kt +93 -0
- package/android/src/main/java/expo/modules/video/player/PlayerEvent.kt +164 -0
- package/android/src/main/java/expo/modules/video/player/VideoPlayer.kt +460 -0
- package/android/src/main/java/expo/modules/video/player/VideoPlayerAudioTracks.kt +125 -0
- package/android/src/main/java/expo/modules/video/player/VideoPlayerListener.kt +32 -0
- package/android/src/main/java/expo/modules/video/player/VideoPlayerLoadControl.kt +525 -0
- package/android/src/main/java/expo/modules/video/player/VideoPlayerSubtitles.kt +125 -0
- package/android/src/main/java/expo/modules/video/records/BufferOptions.kt +15 -0
- package/android/src/main/java/expo/modules/video/records/DRMOptions.kt +25 -0
- package/android/src/main/java/expo/modules/video/records/PlaybackError.kt +19 -0
- package/android/src/main/java/expo/modules/video/records/Tracks.kt +81 -0
- package/android/src/main/java/expo/modules/video/records/VideoEventPayloads.kt +79 -0
- package/android/src/main/java/expo/modules/video/records/VideoMetadata.kt +12 -0
- package/android/src/main/java/expo/modules/video/records/VideoSize.kt +14 -0
- package/android/src/main/java/expo/modules/video/records/VideoSource.kt +104 -0
- package/android/src/main/java/expo/modules/video/records/VideoThumbnailOptions.kt +24 -0
- package/android/src/main/java/expo/modules/video/utils/DataSourceUtils.kt +75 -0
- package/android/src/main/java/expo/modules/video/utils/EventDispatcherUtils.kt +43 -0
- package/android/src/main/java/expo/modules/video/utils/MutableWeakReference.kt +15 -0
- package/android/src/main/java/expo/modules/video/utils/PictureInPictureUtils.kt +96 -0
- package/android/src/main/java/expo/modules/video/utils/YogaUtils.kt +20 -0
- package/android/src/main/res/drawable/seek_backwards_10s.xml +25 -0
- package/android/src/main/res/drawable/seek_backwards_15s.xml +25 -0
- package/android/src/main/res/drawable/seek_backwards_5s.xml +25 -0
- package/android/src/main/res/drawable/seek_forwards_10s.xml +30 -0
- package/android/src/main/res/drawable/seek_forwards_15s.xml +31 -0
- package/android/src/main/res/drawable/seek_forwards_5s.xml +30 -0
- package/android/src/main/res/layout/fullscreen_player_activity.xml +16 -0
- package/android/src/main/res/layout/surface_player_view.xml +7 -0
- package/android/src/main/res/layout/texture_player_view.xml +7 -0
- package/android/src/main/res/values/styles.xml +9 -0
- package/app.plugin.js +1 -0
- package/build/NativeVideoModule.d.ts +16 -0
- package/build/NativeVideoModule.d.ts.map +1 -0
- package/build/NativeVideoModule.js +3 -0
- package/build/NativeVideoModule.js.map +1 -0
- package/build/NativeVideoModule.web.d.ts +3 -0
- package/build/NativeVideoModule.web.d.ts.map +1 -0
- package/build/NativeVideoModule.web.js +2 -0
- package/build/NativeVideoModule.web.js.map +1 -0
- package/build/NativeVideoView.d.ts +4 -0
- package/build/NativeVideoView.d.ts.map +1 -0
- package/build/NativeVideoView.js +6 -0
- package/build/NativeVideoView.js.map +1 -0
- package/build/VideoModule.d.ts +38 -0
- package/build/VideoModule.d.ts.map +1 -0
- package/build/VideoModule.js +53 -0
- package/build/VideoModule.js.map +1 -0
- package/build/VideoPlayer.d.ts +15 -0
- package/build/VideoPlayer.d.ts.map +1 -0
- package/build/VideoPlayer.js +52 -0
- package/build/VideoPlayer.js.map +1 -0
- package/build/VideoPlayer.types.d.ts +532 -0
- package/build/VideoPlayer.types.d.ts.map +1 -0
- package/build/VideoPlayer.types.js +2 -0
- package/build/VideoPlayer.types.js.map +1 -0
- package/build/VideoPlayer.web.d.ts +75 -0
- package/build/VideoPlayer.web.d.ts.map +1 -0
- package/build/VideoPlayer.web.js +376 -0
- package/build/VideoPlayer.web.js.map +1 -0
- package/build/VideoPlayerEvents.types.d.ts +262 -0
- package/build/VideoPlayerEvents.types.d.ts.map +1 -0
- package/build/VideoPlayerEvents.types.js +2 -0
- package/build/VideoPlayerEvents.types.js.map +1 -0
- package/build/VideoThumbnail.d.ts +29 -0
- package/build/VideoThumbnail.d.ts.map +1 -0
- package/build/VideoThumbnail.js +3 -0
- package/build/VideoThumbnail.js.map +1 -0
- package/build/VideoView.d.ts +44 -0
- package/build/VideoView.d.ts.map +1 -0
- package/build/VideoView.js +76 -0
- package/build/VideoView.js.map +1 -0
- package/build/VideoView.types.d.ts +147 -0
- package/build/VideoView.types.d.ts.map +1 -0
- package/build/VideoView.types.js +2 -0
- package/build/VideoView.types.js.map +1 -0
- package/build/VideoView.web.d.ts +9 -0
- package/build/VideoView.web.d.ts.map +1 -0
- package/build/VideoView.web.js +180 -0
- package/build/VideoView.web.js.map +1 -0
- package/build/index.d.ts +9 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +7 -0
- package/build/index.js.map +1 -0
- package/build/resolveAssetSource.d.ts +3 -0
- package/build/resolveAssetSource.d.ts.map +1 -0
- package/build/resolveAssetSource.js +3 -0
- package/build/resolveAssetSource.js.map +1 -0
- package/build/resolveAssetSource.web.d.ts +4 -0
- package/build/resolveAssetSource.web.d.ts.map +1 -0
- package/build/resolveAssetSource.web.js +16 -0
- package/build/resolveAssetSource.web.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/Cache/CachableRequest.swift +44 -0
- package/ios/Cache/CachedResource.swift +97 -0
- package/ios/Cache/CachingHelpers.swift +92 -0
- package/ios/Cache/MediaFileHandle.swift +94 -0
- package/ios/Cache/MediaInfo.swift +147 -0
- package/ios/Cache/ResourceLoaderDelegate.swift +274 -0
- package/ios/Cache/SynchronizedHashTable.swift +23 -0
- package/ios/Cache/VideoCacheManager.swift +338 -0
- package/ios/ContentKeyDelegate.swift +214 -0
- package/ios/ContentKeyManager.swift +21 -0
- package/ios/Enums/AudioMixingMode.swift +37 -0
- package/ios/Enums/ContentType.swift +12 -0
- package/ios/Enums/DRMType.swift +20 -0
- package/ios/Enums/PlayerStatus.swift +10 -0
- package/ios/Enums/VideoContentFit.swift +39 -0
- package/ios/ExpoVideo.podspec +29 -0
- package/ios/NowPlayingManager.swift +296 -0
- package/ios/Records/BufferOptions.swift +12 -0
- package/ios/Records/DRMOptions.swift +24 -0
- package/ios/Records/PlaybackError.swift +10 -0
- package/ios/Records/Tracks.swift +176 -0
- package/ios/Records/VideoEventPayloads.swift +76 -0
- package/ios/Records/VideoMetadata.swift +16 -0
- package/ios/Records/VideoSize.swift +15 -0
- package/ios/Records/VideoSource.swift +25 -0
- package/ios/Thumbnails/VideoThumbnail.swift +27 -0
- package/ios/Thumbnails/VideoThumbnailGenerator.swift +68 -0
- package/ios/Thumbnails/VideoThumbnailOptions.swift +15 -0
- package/ios/VideoAsset.swift +123 -0
- package/ios/VideoExceptions.swift +53 -0
- package/ios/VideoItem.swift +11 -0
- package/ios/VideoManager.swift +140 -0
- package/ios/VideoModule.swift +383 -0
- package/ios/VideoPlayer/DangerousPropertiesStore.swift +19 -0
- package/ios/VideoPlayer.swift +435 -0
- package/ios/VideoPlayerAudioTracks.swift +72 -0
- package/ios/VideoPlayerItem.swift +97 -0
- package/ios/VideoPlayerObserver.swift +523 -0
- package/ios/VideoPlayerSubtitles.swift +71 -0
- package/ios/VideoSourceLoader.swift +89 -0
- package/ios/VideoSourceLoaderListener.swift +34 -0
- package/ios/VideoView.swift +224 -0
- package/package.json +59 -0
- package/plugin/build/tsconfig.tsbuildinfo +1 -0
- package/plugin/build/withExpoVideo.d.ts +7 -0
- package/plugin/build/withExpoVideo.js +38 -0
- package/src/NativeVideoModule.ts +20 -0
- package/src/NativeVideoModule.web.ts +1 -0
- package/src/NativeVideoView.ts +8 -0
- package/src/VideoModule.ts +59 -0
- package/src/VideoPlayer.tsx +67 -0
- package/src/VideoPlayer.types.ts +613 -0
- package/src/VideoPlayer.web.tsx +451 -0
- package/src/VideoPlayerEvents.types.ts +313 -0
- package/src/VideoThumbnail.ts +31 -0
- package/src/VideoView.tsx +86 -0
- package/src/VideoView.types.ts +165 -0
- package/src/VideoView.web.tsx +214 -0
- package/src/index.ts +46 -0
- package/src/resolveAssetSource.ts +2 -0
- package/src/resolveAssetSource.web.ts +17 -0
- package/src/ts-declarations/react-native-assets.d.ts +1 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import ExpoModulesCore
|
|
3
|
+
|
|
4
|
+
class MediaInfo: Codable {
|
|
5
|
+
var expectedContentLength: Int64
|
|
6
|
+
var supportsByteRangeAccess: Bool
|
|
7
|
+
var mimeType: String?
|
|
8
|
+
var headerFields: [String: String]?
|
|
9
|
+
var savePath: String
|
|
10
|
+
|
|
11
|
+
// Tuples can't be encoded/decoded, so we workaround that with an array
|
|
12
|
+
private var loadedDataRangesArr: [[Int]] = []
|
|
13
|
+
private(set) var loadedDataRanges: [(Int, Int)] {
|
|
14
|
+
get {
|
|
15
|
+
return loadedDataRangesArrayToTuple()
|
|
16
|
+
}
|
|
17
|
+
set {
|
|
18
|
+
loadedDataRangesArr = loadedDataRangesTupleToArray(newValue)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private enum CodingKeys: String, CodingKey {
|
|
23
|
+
case expectedContentLength, supportsByteRangeAccess, mimeType, loadedDataRangesArr, headerFields, savePath
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
init(expectedContentLength: Int64, mimeType: String?, supportsByteRangeAccess: Bool, headerFields: [String: String]?, savePath: String) {
|
|
27
|
+
self.mimeType = mimeType
|
|
28
|
+
self.supportsByteRangeAccess = supportsByteRangeAccess
|
|
29
|
+
self.expectedContentLength = expectedContentLength
|
|
30
|
+
self.headerFields = headerFields
|
|
31
|
+
self.savePath = savePath
|
|
32
|
+
self.loadedDataRanges = loadedDataRanges
|
|
33
|
+
|
|
34
|
+
if let url = URL(string: savePath) {
|
|
35
|
+
VideoCacheManager.shared.registerOpenFile(at: url)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
deinit {
|
|
40
|
+
if let url = URL(string: savePath) {
|
|
41
|
+
VideoCacheManager.shared.unregisterOpenFile(at: url)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
convenience init?(data: Data, dataPath: String) {
|
|
46
|
+
do {
|
|
47
|
+
let mediaInfo = try JSONDecoder().decode(MediaInfo.self, from: data)
|
|
48
|
+
self.init(
|
|
49
|
+
expectedContentLength: mediaInfo.expectedContentLength,
|
|
50
|
+
mimeType: mediaInfo.mimeType,
|
|
51
|
+
supportsByteRangeAccess: mediaInfo.supportsByteRangeAccess,
|
|
52
|
+
headerFields: mediaInfo.headerFields,
|
|
53
|
+
savePath: mediaInfo.savePath)
|
|
54
|
+
self.loadedDataRanges = mediaInfo.loadedDataRanges
|
|
55
|
+
} catch {
|
|
56
|
+
return nil
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
convenience init?(at path: String) {
|
|
61
|
+
guard FileManager.default.fileExists(atPath: path), let mediaInfoData = FileManager.default.contents(atPath: path) else {
|
|
62
|
+
return nil
|
|
63
|
+
}
|
|
64
|
+
self.init(data: mediaInfoData, dataPath: path)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
convenience init?(forResourceUrl url: URL) {
|
|
68
|
+
guard let filePath = VideoAsset.pathForUrl(url: url, fileExtension: url.pathExtension) else {
|
|
69
|
+
return nil
|
|
70
|
+
}
|
|
71
|
+
let mediaInfoPath = filePath + VideoCacheManager.mediaInfoSuffix
|
|
72
|
+
self.init(at: mediaInfoPath)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func addDataRange(newDataRange: (Int, Int)) {
|
|
76
|
+
var i = 0
|
|
77
|
+
var merged = [(Int, Int)]()
|
|
78
|
+
|
|
79
|
+
// Add all intervals before newInterval starts
|
|
80
|
+
while i < loadedDataRanges.count && loadedDataRanges[i].1 < newDataRange.0 {
|
|
81
|
+
merged.append(loadedDataRanges[i])
|
|
82
|
+
i += 1
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Merge all overlapping intervals to one new Interval
|
|
86
|
+
var newStart = newDataRange.0
|
|
87
|
+
var newEnd = newDataRange.1
|
|
88
|
+
while i < loadedDataRanges.count && loadedDataRanges[i].0 <= newDataRange.1 {
|
|
89
|
+
newStart = min(newStart, loadedDataRanges[i].0)
|
|
90
|
+
newEnd = max(newEnd, loadedDataRanges[i].1)
|
|
91
|
+
i += 1
|
|
92
|
+
}
|
|
93
|
+
merged.append((newStart, newEnd))
|
|
94
|
+
|
|
95
|
+
// Add remaining intervals
|
|
96
|
+
while i < loadedDataRanges.count {
|
|
97
|
+
merged.append(loadedDataRanges[i])
|
|
98
|
+
i += 1
|
|
99
|
+
}
|
|
100
|
+
loadedDataRanges = merged
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func encodeToData() -> Data? {
|
|
104
|
+
do {
|
|
105
|
+
return try JSONEncoder().encode(self)
|
|
106
|
+
} catch {
|
|
107
|
+
log.warn("Error encoding MediaInfo object: \(error)")
|
|
108
|
+
return nil
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Saves the mime type of a video fetched from the server into a file. This allows playing videos without an extension in the
|
|
113
|
+
// url.
|
|
114
|
+
func saveToFile() {
|
|
115
|
+
do {
|
|
116
|
+
if FileManager.default.fileExists(atPath: savePath) {
|
|
117
|
+
try FileManager.default.removeItem(atPath: savePath)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
FileManager.default.createFile(atPath: savePath, contents: self.encodeToData())
|
|
121
|
+
} catch {
|
|
122
|
+
log.warn("Failed to save media info at: \(savePath)")
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Public setter for loadedDataRanges
|
|
127
|
+
public func setLoadedDataRanges(_ ranges: [(Int, Int)]) {
|
|
128
|
+
self.loadedDataRanges = ranges
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private func loadedDataRangesArrayToTuple() -> [(Int, Int)] {
|
|
132
|
+
// The filter shouldn't be necessary, but we can't be too careful
|
|
133
|
+
let filteredDataRanges = loadedDataRangesArr.filter { rangeArray in
|
|
134
|
+
rangeArray.count == 2
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return filteredDataRanges.map { rangeArray in
|
|
138
|
+
(rangeArray[0], rangeArray[1])
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private func loadedDataRangesTupleToArray(_ loadedDataRanges: [(Int, Int)]) -> [[Int]] {
|
|
143
|
+
return loadedDataRanges.map { from, to in
|
|
144
|
+
return [from, to]
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import AVFoundation
|
|
3
|
+
import UIKit
|
|
4
|
+
import CoreServices
|
|
5
|
+
import ExpoModulesCore
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Class responsible for fulfilling data requests created by the AVAsset. There are two types of requests:
|
|
9
|
+
* - Initial request - The response contains most of the information about the data source such as support for content ranges, total size etc.
|
|
10
|
+
* this information is cached for offline playback support.
|
|
11
|
+
* - Data request - For each range request from the player the delegate will request and receive multiple chunks of data. We have to return a correct subrange
|
|
12
|
+
* of data and cache it. If a chunk of data is already available we will return it from cache.
|
|
13
|
+
*/
|
|
14
|
+
final class ResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDelegate, URLSessionDataDelegate, URLSessionTaskDelegate {
|
|
15
|
+
private let url: URL
|
|
16
|
+
private let saveFilePath: String
|
|
17
|
+
private let fileExtension: String
|
|
18
|
+
private let cachedResource: CachedResource
|
|
19
|
+
private let urlRequestHeaders: [String: String]?
|
|
20
|
+
internal var onError: ((Error) -> Void)?
|
|
21
|
+
|
|
22
|
+
private var cachableRequests: SynchronizedHashTable<CachableRequest> = SynchronizedHashTable()
|
|
23
|
+
private var session: URLSession?
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The default requestTimeoutInterval is 60, which is too long (UI should respond relatively quickly to network errors)
|
|
27
|
+
*/
|
|
28
|
+
private static let requestTimeoutInterval: Double = 5
|
|
29
|
+
|
|
30
|
+
// When playing from an url without an extension appends an extension to the path based on the response from the server
|
|
31
|
+
private var pathWithExtension: String {
|
|
32
|
+
let ext = mimeTypeToExtension(mimeType: cachedResource.mediaInfo?.mimeType)
|
|
33
|
+
if let ext, self.fileExtension.isEmpty {
|
|
34
|
+
return self.saveFilePath + ".\(ext)"
|
|
35
|
+
}
|
|
36
|
+
return self.saveFilePath
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
init(url: URL, saveFilePath: String, fileExtension: String, urlRequestHeaders: [String: String]?) {
|
|
40
|
+
self.url = url
|
|
41
|
+
self.saveFilePath = saveFilePath
|
|
42
|
+
self.fileExtension = fileExtension
|
|
43
|
+
self.urlRequestHeaders = urlRequestHeaders
|
|
44
|
+
cachedResource = CachedResource(dataFileUrl: saveFilePath, resourceUrl: url, dataPath: saveFilePath)
|
|
45
|
+
super.init()
|
|
46
|
+
self.session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
deinit {
|
|
50
|
+
session?.invalidateAndCancel()
|
|
51
|
+
session = nil
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// MARK: - AVAssetResourceLoaderDelegate
|
|
55
|
+
|
|
56
|
+
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
|
|
57
|
+
processLoadingRequest(loadingRequest: loadingRequest)
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
|
|
62
|
+
cachableRequest(by: loadingRequest)?.dataTask.cancel()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// MARK: - URLSessionDelegate
|
|
66
|
+
|
|
67
|
+
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
|
|
68
|
+
guard let currentRequest = dataTask.currentRequest,
|
|
69
|
+
let response = dataTask.response as? HTTPURLResponse,
|
|
70
|
+
let cachableRequest = cachableRequest(by: dataTask) else {
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let dataRequest = cachableRequest.dataRequest
|
|
75
|
+
let requestedOffset = dataRequest.requestedOffset
|
|
76
|
+
let currentOffset = dataRequest.currentOffset
|
|
77
|
+
let length = dataRequest.requestedLength
|
|
78
|
+
|
|
79
|
+
// If finding correct subdata failed, fallback to pure received data
|
|
80
|
+
let subdata = data.subdata(request: currentRequest, response: response) ?? data
|
|
81
|
+
|
|
82
|
+
// Append modified or original data
|
|
83
|
+
cachableRequest.onReceivedData(data: subdata)
|
|
84
|
+
|
|
85
|
+
if dataRequest.requestsAllDataToEndOfResource {
|
|
86
|
+
let currentDataResponseOffset = Int(currentOffset - requestedOffset)
|
|
87
|
+
let currentDataResponseLength = cachableRequest.receivedData.count - currentDataResponseOffset
|
|
88
|
+
let subdata = cachableRequest.receivedData.subdata(in: currentDataResponseOffset..<currentDataResponseOffset + currentDataResponseLength)
|
|
89
|
+
dataRequest.respond(with: subdata)
|
|
90
|
+
} else if currentOffset - requestedOffset <= cachableRequest.receivedData.count {
|
|
91
|
+
let rangeStart = Int(currentOffset - requestedOffset)
|
|
92
|
+
let rangeLength = min(cachableRequest.receivedData.count - rangeStart, length)
|
|
93
|
+
let subdata = cachableRequest.receivedData.subdata(in: rangeStart..<rangeStart + rangeLength)
|
|
94
|
+
dataRequest.respond(with: subdata)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
func urlSession(
|
|
99
|
+
_ session: URLSession,
|
|
100
|
+
dataTask: URLSessionDataTask,
|
|
101
|
+
didReceive response: URLResponse,
|
|
102
|
+
completionHandler: @escaping (URLSession.ResponseDisposition) -> Void
|
|
103
|
+
) {
|
|
104
|
+
if let cachedDataRequest = cachableRequest(by: dataTask) {
|
|
105
|
+
cachedDataRequest.response = response
|
|
106
|
+
if cachedDataRequest.loadingRequest.contentInformationRequest != nil {
|
|
107
|
+
fillInContentInformationRequest(forDataRequest: cachedDataRequest)
|
|
108
|
+
cachedDataRequest.loadingRequest.response = response
|
|
109
|
+
cachedDataRequest.loadingRequest.finishLoading()
|
|
110
|
+
cachedDataRequest.dataTask.cancel()
|
|
111
|
+
cachableRequests.remove(cachedDataRequest)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
completionHandler(.allow)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
118
|
+
guard let cachedDataRequest = cachableRequest(by: task) else {
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// The data shouldn't be corrupted and can be cached
|
|
123
|
+
if let error = error as? URLError, error.code == URLError.cancelled || error.code == URLError.networkConnectionLost {
|
|
124
|
+
cachedDataRequest.saveData(to: cachedResource)
|
|
125
|
+
} else if error == nil {
|
|
126
|
+
cachedDataRequest.saveData(to: cachedResource)
|
|
127
|
+
} else {
|
|
128
|
+
cachedDataRequest.loadingRequest.finishLoading(with: error)
|
|
129
|
+
}
|
|
130
|
+
cachedDataRequest.loadingRequest.finishLoading(with: error)
|
|
131
|
+
cachableRequests.remove(cachedDataRequest)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private func processLoadingRequest(loadingRequest: AVAssetResourceLoadingRequest) {
|
|
135
|
+
let (remainingRequest, dataReceived) = attemptToRespondFromCache(forRequest: loadingRequest)
|
|
136
|
+
|
|
137
|
+
// Cache fulfilled the entire request
|
|
138
|
+
if dataReceived != nil && remainingRequest == nil {
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
var request = remainingRequest ?? createUrlRequest()
|
|
143
|
+
|
|
144
|
+
// remainingRequest will have correct range header fields
|
|
145
|
+
if remainingRequest == nil {
|
|
146
|
+
addRangeHeaderFields(loadingRequest: loadingRequest, urlRequest: &request)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
guard let session else {
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let dataTask = session.dataTask(with: request)
|
|
154
|
+
|
|
155
|
+
// we can't do `if let loadingRequest = loadingRequest.dataRequest` as this would create new variable by copying
|
|
156
|
+
if loadingRequest.dataRequest != nil {
|
|
157
|
+
let cachableRequest = CachableRequest(loadingRequest: loadingRequest, dataTask: dataTask, dataRequest: loadingRequest.dataRequest!)
|
|
158
|
+
// We need to add the data that was received from cache in order to keep byte offsets consistent
|
|
159
|
+
if let dataReceived {
|
|
160
|
+
cachableRequest.onReceivedData(data: dataReceived)
|
|
161
|
+
}
|
|
162
|
+
cachableRequests.add(cachableRequest)
|
|
163
|
+
} else {
|
|
164
|
+
log.warn("ResourceLoaderDelegate has received a loading request without a data request")
|
|
165
|
+
}
|
|
166
|
+
dataTask.resume()
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private func fillInContentInformationRequest(forDataRequest request: CachableRequest?) {
|
|
170
|
+
guard let response = request?.response as? HTTPURLResponse else {
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
request?.loadingRequest.contentInformationRequest?.contentLength = response.expectedContentLength
|
|
175
|
+
request?.loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = true
|
|
176
|
+
|
|
177
|
+
if let mimeType = response.mimeType, isSupported(mimeType: mimeType) {
|
|
178
|
+
let rawUti = UTType(mimeType: mimeType)?.identifier
|
|
179
|
+
request?.loadingRequest.contentInformationRequest?.contentType = rawUti ?? response.mimeType
|
|
180
|
+
cachedResource.onResponseReceived(response: response)
|
|
181
|
+
} else {
|
|
182
|
+
// We can't control the AVPlayer.error property that will be set after the player fails to load the resource
|
|
183
|
+
// We have an additional field that can be used to return a more specific error
|
|
184
|
+
onError?(VideoCacheUnsupportedFormatException(response.mimeType ?? ""))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/// Attempts to load the request from cache, if just the beginning of the requested data is available, returns a URL request to fetch the rest of the data
|
|
189
|
+
private func attemptToRespondFromCache(forRequest loadingRequest: AVAssetResourceLoadingRequest) -> (request: URLRequest?, dataReceived: Data?) {
|
|
190
|
+
guard let dataRequest = loadingRequest.dataRequest else {
|
|
191
|
+
return (nil, nil)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
let from = dataRequest.requestedOffset
|
|
195
|
+
let to = from + Int64(dataRequest.requestedLength) - 1
|
|
196
|
+
|
|
197
|
+
// Try to return the whole data from the cache
|
|
198
|
+
if let cachedData = cachedResource.requestData(from: from, to: to) {
|
|
199
|
+
if loadingRequest.contentInformationRequest != nil {
|
|
200
|
+
cachedResource.fill(forLoadingRequest: loadingRequest)
|
|
201
|
+
}
|
|
202
|
+
loadingRequest.dataRequest?.respond(with: cachedData)
|
|
203
|
+
loadingRequest.finishLoading()
|
|
204
|
+
return (nil, cachedData)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Try to return the beginning of the data, and create a request for the remainder
|
|
208
|
+
if let partialData = cachedResource.requestBeginningOfData(from: from, to: to) {
|
|
209
|
+
if loadingRequest.contentInformationRequest != nil {
|
|
210
|
+
cachedResource.fill(forLoadingRequest: loadingRequest)
|
|
211
|
+
}
|
|
212
|
+
loadingRequest.dataRequest?.respond(with: partialData)
|
|
213
|
+
|
|
214
|
+
var request = createUrlRequest()
|
|
215
|
+
if loadingRequest.contentInformationRequest == nil {
|
|
216
|
+
if loadingRequest.dataRequest?.requestsAllDataToEndOfResource == true {
|
|
217
|
+
let requestedOffset = dataRequest.requestedOffset
|
|
218
|
+
request.setValue("bytes=\(Int(requestedOffset) + partialData.count)-", forHTTPHeaderField: "Range")
|
|
219
|
+
} else if let dataRequest = loadingRequest.dataRequest {
|
|
220
|
+
let requestedOffset = dataRequest.requestedOffset
|
|
221
|
+
let requestedLength = dataRequest.requestedLength
|
|
222
|
+
let from = Int(requestedOffset) + partialData.count
|
|
223
|
+
let to = from + requestedLength - partialData.count - 1
|
|
224
|
+
request.setValue("bytes=\(from)-\(to)", forHTTPHeaderField: "Range")
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return (request, partialData)
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return (nil, nil)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// The loading resource might want only a part of the video
|
|
234
|
+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range
|
|
235
|
+
private func addRangeHeaderFields(loadingRequest: AVAssetResourceLoadingRequest, urlRequest: inout URLRequest) {
|
|
236
|
+
guard let dataRequest = loadingRequest.dataRequest, loadingRequest.contentInformationRequest == nil else {
|
|
237
|
+
return
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if dataRequest.requestsAllDataToEndOfResource {
|
|
241
|
+
let requestedOffset = dataRequest.requestedOffset
|
|
242
|
+
urlRequest.setValue("bytes=\(requestedOffset)-", forHTTPHeaderField: "Range")
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
let requestedOffset = dataRequest.requestedOffset
|
|
247
|
+
let requestedLength = Int64(dataRequest.requestedLength)
|
|
248
|
+
urlRequest.setValue("bytes=\(requestedOffset)-\(requestedOffset + requestedLength - 1)", forHTTPHeaderField: "Range")
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private func isSupported(mimeType: String?) -> Bool {
|
|
252
|
+
return mimeType?.starts(with: "video/") ?? false
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private func createUrlRequest() -> URLRequest {
|
|
256
|
+
var request = URLRequest(url: url, cachePolicy: .useProtocolCachePolicy)
|
|
257
|
+
request.timeoutInterval = Self.requestTimeoutInterval
|
|
258
|
+
|
|
259
|
+
self.urlRequestHeaders?.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) }
|
|
260
|
+
return request
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
private func cachableRequest(by loadingRequest: AVAssetResourceLoadingRequest) -> CachableRequest? {
|
|
264
|
+
return cachableRequests.allObjects.first(where: {
|
|
265
|
+
$0.loadingRequest == loadingRequest
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private func cachableRequest(by task: URLSessionTask) -> CachableRequest? {
|
|
270
|
+
return cachableRequests.allObjects.first(where: {
|
|
271
|
+
$0.dataTask == task
|
|
272
|
+
})
|
|
273
|
+
}
|
|
274
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Copyright 2025-present 650 Industries. All rights reserved.
|
|
2
|
+
|
|
3
|
+
internal class SynchronizedHashTable<T: AnyObject> {
|
|
4
|
+
private let lock = NSLock()
|
|
5
|
+
private var hashTable: NSHashTable<T> = NSHashTable()
|
|
6
|
+
var allObjects: [T] {
|
|
7
|
+
lock.lock()
|
|
8
|
+
defer { lock.unlock() }
|
|
9
|
+
return Array(hashTable.allObjects)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
func add(_ object: T) {
|
|
13
|
+
lock.lock()
|
|
14
|
+
defer { lock.unlock() }
|
|
15
|
+
hashTable.add(object)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func remove(_ object: T) {
|
|
19
|
+
lock.lock()
|
|
20
|
+
defer { lock.unlock() }
|
|
21
|
+
hashTable.remove(object)
|
|
22
|
+
}
|
|
23
|
+
}
|