@stepincto/expo-video 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"VideoModule.d.ts","sourceRoot":"","sources":["../src/VideoModule.ts"],"names":[],"mappings":"AAEA;;;;;;GAMG;AACH,wBAAgB,2BAA2B,IAAI,OAAO,CAErD;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,IAAI,OAAO,CAAC,IAAI,CAAC,CAEpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEvE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAED,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAEtE;AAED,wBAAsB,yBAAyB,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAEjG;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAEhE"}
1
+ {"version":3,"file":"VideoModule.d.ts","sourceRoot":"","sources":["../src/VideoModule.ts"],"names":[],"mappings":"AAIA;;;;;;GAMG;AACH,wBAAgB,2BAA2B,IAAI,OAAO,CAErD;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,IAAI,OAAO,CAAC,IAAI,CAAC,CAEpD;AAED;;;;;;;;GAQG;AACH,wBAAgB,sBAAsB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEvE;AAED;;;;;GAKG;AACH,wBAAgB,wBAAwB,IAAI,MAAM,CAEjD;AAOD,wBAAsB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAsBtE;AAED,wBAAsB,yBAAyB,CAAC,GAAG,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAEjG;AAED,wBAAgB,kBAAkB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAEhE"}
@@ -1,4 +1,5 @@
1
1
  import NativeVideoModule from './NativeVideoModule';
2
+ const globalCacheOperations = new Map(); // stores the promises that are currently caching files
2
3
  /**
3
4
  * Returns whether the current device supports Picture in Picture (PiP) mode.
4
5
  *
@@ -41,8 +42,30 @@ export function setVideoCacheSizeAsync(sizeBytes) {
41
42
  export function getCurrentVideoCacheSize() {
42
43
  return NativeVideoModule.getCurrentVideoCacheSize();
43
44
  }
45
+ // export async function preCacheVideoAsync(url: string): Promise<boolean> {
46
+ // return NativeVideoModule.preCacheVideoAsync(url);
47
+ // }
48
+ // Modify the existing function
44
49
  export async function preCacheVideoAsync(url) {
45
- return NativeVideoModule.preCacheVideoAsync(url);
50
+ // If already caching, return the existing promise
51
+ if (globalCacheOperations.has(url)) {
52
+ return await globalCacheOperations.get(url);
53
+ }
54
+ // Start new caching operation with proper error handling
55
+ const cachingPromise = NativeVideoModule.preCacheVideoAsync(url).catch((error) => {
56
+ // Ensure cleanup happens even on native errors
57
+ globalCacheOperations.delete(url);
58
+ throw error;
59
+ });
60
+ globalCacheOperations.set(url, cachingPromise);
61
+ try {
62
+ const result = await cachingPromise;
63
+ return result;
64
+ }
65
+ finally {
66
+ // Always cleanup, even if the promise was already cleaned up in catch
67
+ globalCacheOperations.delete(url);
68
+ }
46
69
  }
47
70
  export async function preCacheVideoPartialAsync(url, chunkSize) {
48
71
  return NativeVideoModule.preCacheVideoPartialAsync(url, chunkSize);
@@ -1 +1 @@
1
- {"version":3,"file":"VideoModule.js","sourceRoot":"","sources":["../src/VideoModule.ts"],"names":[],"mappings":"AAAA,OAAO,iBAAiB,MAAM,qBAAqB,CAAC;AAEpD;;;;;;GAMG;AACH,MAAM,UAAU,2BAA2B;IACzC,OAAO,iBAAiB,CAAC,2BAA2B,EAAE,CAAC;AACzD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB;IAClC,OAAO,iBAAiB,CAAC,oBAAoB,EAAE,CAAC;AAClD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,sBAAsB,CAAC,SAAiB;IACtD,OAAO,iBAAiB,CAAC,sBAAsB,CAAC,SAAS,CAAC,CAAC;AAC7D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,wBAAwB;IACtC,OAAO,iBAAiB,CAAC,wBAAwB,EAAE,CAAC;AACtD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,GAAW;IAClD,OAAO,iBAAiB,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC;AACnD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAAC,GAAW,EAAE,SAAkB;IAC7E,OAAO,iBAAiB,CAAC,yBAAyB,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;AACrE,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,GAAW;IAC5C,OAAO,iBAAiB,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC;AACnD,CAAC","sourcesContent":["import NativeVideoModule from './NativeVideoModule';\n\n/**\n * Returns whether the current device supports Picture in Picture (PiP) mode.\n *\n * @returns A `boolean` which is `true` if the device supports PiP mode, and `false` otherwise.\n * @platform android\n * @platform ios\n */\nexport function isPictureInPictureSupported(): boolean {\n return NativeVideoModule.isPictureInPictureSupported();\n}\n\n/**\n * Clears all video cache.\n * > This function can be called only if there are no existing `VideoPlayer` instances.\n *\n * @returns A promise that fulfills after the cache has been cleaned.\n * @platform android\n * @platform ios\n */\nexport function clearVideoCacheAsync(): Promise<void> {\n return NativeVideoModule.clearVideoCacheAsync();\n}\n\n/**\n * Sets desired video cache size in bytes. The default video cache size is 1GB. Value set by this function is persistent.\n * The cache size is not guaranteed to be exact and the actual cache size may be slightly larger. The cache is evicted on a least-recently-used basis.\n * > This function can be called only if there are no existing `VideoPlayer` instances.\n *\n * @returns A promise that fulfills after the cache size has been set.\n * @platform android\n * @platform ios\n */\nexport function setVideoCacheSizeAsync(sizeBytes: number): Promise<void> {\n return NativeVideoModule.setVideoCacheSizeAsync(sizeBytes);\n}\n\n/**\n * Returns the space currently occupied by the video cache in bytes.\n *\n * @platform android\n * @platform ios\n */\nexport function getCurrentVideoCacheSize(): number {\n return NativeVideoModule.getCurrentVideoCacheSize();\n}\n\nexport async function preCacheVideoAsync(url: string): Promise<boolean> {\n return NativeVideoModule.preCacheVideoAsync(url);\n}\n\nexport async function preCacheVideoPartialAsync(url: string, chunkSize?: number): Promise<boolean> {\n return NativeVideoModule.preCacheVideoPartialAsync(url, chunkSize);\n}\n\nexport function isVideoCachedAsync(url: string): Promise<boolean> {\n return NativeVideoModule.isVideoCachedAsync(url);\n}\n"]}
1
+ {"version":3,"file":"VideoModule.js","sourceRoot":"","sources":["../src/VideoModule.ts"],"names":[],"mappings":"AAAA,OAAO,iBAAiB,MAAM,qBAAqB,CAAC;AAEpD,MAAM,qBAAqB,GAAG,IAAI,GAAG,EAA4B,CAAC,CAAC,uDAAuD;AAE1H;;;;;;GAMG;AACH,MAAM,UAAU,2BAA2B;IACzC,OAAO,iBAAiB,CAAC,2BAA2B,EAAE,CAAC;AACzD,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB;IAClC,OAAO,iBAAiB,CAAC,oBAAoB,EAAE,CAAC;AAClD,CAAC;AAED;;;;;;;;GAQG;AACH,MAAM,UAAU,sBAAsB,CAAC,SAAiB;IACtD,OAAO,iBAAiB,CAAC,sBAAsB,CAAC,SAAS,CAAC,CAAC;AAC7D,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,wBAAwB;IACtC,OAAO,iBAAiB,CAAC,wBAAwB,EAAE,CAAC;AACtD,CAAC;AAED,4EAA4E;AAC5E,sDAAsD;AACtD,IAAI;AAEJ,+BAA+B;AAC/B,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,GAAW;IAClD,kDAAkD;IAClD,IAAI,qBAAqB,CAAC,GAAG,CAAC,GAAG,CAAC,EAAE,CAAC;QACnC,OAAO,MAAM,qBAAqB,CAAC,GAAG,CAAC,GAAG,CAAE,CAAC;IAC/C,CAAC;IAED,yDAAyD;IACzD,MAAM,cAAc,GAAG,iBAAiB,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,KAAK,EAAE,EAAE;QAC/E,+CAA+C;QAC/C,qBAAqB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QAClC,MAAM,KAAK,CAAC;IACd,CAAC,CAAC,CAAC;IAEH,qBAAqB,CAAC,GAAG,CAAC,GAAG,EAAE,cAAc,CAAC,CAAC;IAE/C,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,MAAM,cAAc,CAAC;QACpC,OAAO,MAAM,CAAC;IAChB,CAAC;YAAS,CAAC;QACT,sEAAsE;QACtE,qBAAqB,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;IACpC,CAAC;AACH,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAAC,GAAW,EAAE,SAAkB;IAC7E,OAAO,iBAAiB,CAAC,yBAAyB,CAAC,GAAG,EAAE,SAAS,CAAC,CAAC;AACrE,CAAC;AAED,MAAM,UAAU,kBAAkB,CAAC,GAAW;IAC5C,OAAO,iBAAiB,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC;AACnD,CAAC","sourcesContent":["import NativeVideoModule from './NativeVideoModule';\n\nconst globalCacheOperations = new Map<string, Promise<boolean>>(); // stores the promises that are currently caching files\n\n/**\n * Returns whether the current device supports Picture in Picture (PiP) mode.\n *\n * @returns A `boolean` which is `true` if the device supports PiP mode, and `false` otherwise.\n * @platform android\n * @platform ios\n */\nexport function isPictureInPictureSupported(): boolean {\n return NativeVideoModule.isPictureInPictureSupported();\n}\n\n/**\n * Clears all video cache.\n * > This function can be called only if there are no existing `VideoPlayer` instances.\n *\n * @returns A promise that fulfills after the cache has been cleaned.\n * @platform android\n * @platform ios\n */\nexport function clearVideoCacheAsync(): Promise<void> {\n return NativeVideoModule.clearVideoCacheAsync();\n}\n\n/**\n * Sets desired video cache size in bytes. The default video cache size is 1GB. Value set by this function is persistent.\n * The cache size is not guaranteed to be exact and the actual cache size may be slightly larger. The cache is evicted on a least-recently-used basis.\n * > This function can be called only if there are no existing `VideoPlayer` instances.\n *\n * @returns A promise that fulfills after the cache size has been set.\n * @platform android\n * @platform ios\n */\nexport function setVideoCacheSizeAsync(sizeBytes: number): Promise<void> {\n return NativeVideoModule.setVideoCacheSizeAsync(sizeBytes);\n}\n\n/**\n * Returns the space currently occupied by the video cache in bytes.\n *\n * @platform android\n * @platform ios\n */\nexport function getCurrentVideoCacheSize(): number {\n return NativeVideoModule.getCurrentVideoCacheSize();\n}\n\n// export async function preCacheVideoAsync(url: string): Promise<boolean> {\n// return NativeVideoModule.preCacheVideoAsync(url);\n// }\n\n// Modify the existing function\nexport async function preCacheVideoAsync(url: string): Promise<boolean> {\n // If already caching, return the existing promise\n if (globalCacheOperations.has(url)) {\n return await globalCacheOperations.get(url)!;\n }\n\n // Start new caching operation with proper error handling\n const cachingPromise = NativeVideoModule.preCacheVideoAsync(url).catch((error) => {\n // Ensure cleanup happens even on native errors\n globalCacheOperations.delete(url);\n throw error;\n });\n \n globalCacheOperations.set(url, cachingPromise);\n\n try {\n const result = await cachingPromise;\n return result;\n } finally {\n // Always cleanup, even if the promise was already cleaned up in catch\n globalCacheOperations.delete(url);\n }\n}\n\nexport async function preCacheVideoPartialAsync(url: string, chunkSize?: number): Promise<boolean> {\n return NativeVideoModule.preCacheVideoPartialAsync(url, chunkSize);\n}\n\nexport function isVideoCachedAsync(url: string): Promise<boolean> {\n return NativeVideoModule.isVideoCachedAsync(url);\n}\n"]}
@@ -17,8 +17,6 @@ class CachableRequest: Equatable, Hashable {
17
17
  var response: URLResponse?
18
18
  private(set) var receivedData = Data()
19
19
  private let dataOffset: Int64
20
- private static var fileLocks: [String: NSLock] = [:]
21
- private static let fileLocksQueue = DispatchQueue(label: "expo-video-cache-file-locks")
22
20
 
23
21
  init(loadingRequest: AVAssetResourceLoadingRequest, dataTask: URLSessionDataTask, dataRequest: AVAssetResourceLoadingDataRequest) {
24
22
  self.loadingRequest = loadingRequest
@@ -32,45 +30,8 @@ class CachableRequest: Equatable, Hashable {
32
30
  }
33
31
 
34
32
  func saveData(to cachedResource: CachedResource) {
35
- // 1. Check if file is fully cached
36
- if let mediaInfo = cachedResource.mediaInfo {
37
- let expectedLength = Int(mediaInfo.expectedContentLength)
38
- let ranges = mediaInfo.loadedDataRanges
39
- if ranges.count == 1, ranges[0].0 == 0, ranges[0].1 == expectedLength - 1 {
40
- // Already fully cached, do not write
41
- return
42
- }
43
- }
44
-
45
- // 2. Acquire per-file lock (non-blocking)
46
- let filePath = cachedResource.dataPath
47
- let lock: NSLock = Self.fileLocksQueue.sync {
48
- if let l = Self.fileLocks[filePath] { return l }
49
- let l = NSLock()
50
- Self.fileLocks[filePath] = l
51
- return l
52
- }
53
- if !lock.try() {
54
- // Another writer is active, skip this write
55
- return
56
- }
57
-
58
- // 3. Perform async write with final range check inside the task
59
- let offset = dataOffset
60
- let length = receivedData.count
61
- let end = offset + Int64(length) - 1
62
-
63
- Task.detached(priority: .userInitiated) { [receivedData] in
64
- // Final check inside the async task to prevent race conditions
65
- if cachedResource.canRespondWithData(from: offset, to: end) {
66
- // This range is already cached, skip writing
67
- lock.unlock()
68
- return
69
- }
70
-
71
- await cachedResource.writeData(data: receivedData, offset: offset)
72
- lock.unlock()
73
- }
33
+ // Disabled: Player is now read-only with respect to the cache.
34
+ // Do nothing.
74
35
  }
75
36
 
76
37
  static func == (lhs: CachableRequest, rhs: CachableRequest) -> Bool {
@@ -10,7 +10,7 @@ import ExpoModulesCore
10
10
  */
11
11
  class CachedResource {
12
12
  private let url: URL
13
- internal let dataPath: String
13
+ private let dataPath: String
14
14
  private let fileHandle: MediaFileHandle
15
15
  private(set) var mediaInfo: MediaInfo?
16
16
 
@@ -26,16 +26,17 @@ internal class MediaFileHandle {
26
26
  return attributes?[.size] as? Int ?? 0
27
27
  }
28
28
 
29
- private var fileUrl: URL? {
30
- URL(string: filePath)
29
+ private var fileUrl: URL {
30
+ URL(fileURLWithPath: filePath)
31
31
  }
32
32
 
33
33
  init(filePath: String) {
34
34
  self.filePath = filePath
35
35
 
36
- if let fileUrl {
37
- VideoCacheManager.shared.registerOpenFile(at: fileUrl)
38
- }
36
+ // Use fileURLWithPath for proper file URL creation
37
+ let url = fileUrl
38
+ VideoCacheManager.shared.registerOpenFile(at: url)
39
+ print("[MediaFileHandle] Registering file URL: \(url.path)")
39
40
 
40
41
  if !FileManager.default.fileExists(atPath: filePath) {
41
42
  FileManager.default.createFile(atPath: filePath, contents: nil, attributes: nil)
@@ -43,9 +44,10 @@ internal class MediaFileHandle {
43
44
  }
44
45
 
45
46
  deinit {
46
- if let fileUrl {
47
- VideoCacheManager.shared.unregisterOpenFile(at: fileUrl)
48
- }
47
+ // Use fileURLWithPath for proper file URL creation
48
+ let url = fileUrl
49
+ VideoCacheManager.shared.unregisterOpenFile(at: url)
50
+ print("[MediaFileHandle] Unregistering file URL: \(url.path)")
49
51
  guard FileManager.default.fileExists(atPath: filePath) else {
50
52
  return
51
53
  }
@@ -7,6 +7,7 @@ class MediaInfo: Codable {
7
7
  var mimeType: String?
8
8
  var headerFields: [String: String]?
9
9
  var savePath: String
10
+ private var shouldAutoUnregister: Bool = true
10
11
 
11
12
  // Tuples can't be encoded/decoded, so we workaround that with an array
12
13
  private var loadedDataRangesArr: [[Int]] = []
@@ -21,24 +22,57 @@ class MediaInfo: Codable {
21
22
 
22
23
  private enum CodingKeys: String, CodingKey {
23
24
  case expectedContentLength, supportsByteRangeAccess, mimeType, loadedDataRangesArr, headerFields, savePath
25
+ // Note: shouldAutoUnregister is not encoded/decoded as it's runtime-only
26
+ }
27
+
28
+ // Custom decoding to handle shouldAutoUnregister
29
+ required init(from decoder: Decoder) throws {
30
+ let container = try decoder.container(keyedBy: CodingKeys.self)
31
+ expectedContentLength = try container.decode(Int64.self, forKey: .expectedContentLength)
32
+ supportsByteRangeAccess = try container.decode(Bool.self, forKey: .supportsByteRangeAccess)
33
+ mimeType = try container.decodeIfPresent(String.self, forKey: .mimeType)
34
+ loadedDataRangesArr = try container.decode([[Int]].self, forKey: .loadedDataRangesArr)
35
+ headerFields = try container.decodeIfPresent([String: String].self, forKey: .headerFields)
36
+ savePath = try container.decode(String.self, forKey: .savePath)
37
+ shouldAutoUnregister = true // Default to true for decoded objects
38
+ }
39
+
40
+ // Custom encoding to exclude shouldAutoUnregister
41
+ func encode(to encoder: Encoder) throws {
42
+ var container = encoder.container(keyedBy: CodingKeys.self)
43
+ try container.encode(expectedContentLength, forKey: .expectedContentLength)
44
+ try container.encode(supportsByteRangeAccess, forKey: .supportsByteRangeAccess)
45
+ try container.encodeIfPresent(mimeType, forKey: .mimeType)
46
+ try container.encode(loadedDataRangesArr, forKey: .loadedDataRangesArr)
47
+ try container.encodeIfPresent(headerFields, forKey: .headerFields)
48
+ try container.encode(savePath, forKey: .savePath)
24
49
  }
25
50
 
26
- init(expectedContentLength: Int64, mimeType: String?, supportsByteRangeAccess: Bool, headerFields: [String: String]?, savePath: String) {
51
+ init(expectedContentLength: Int64, mimeType: String?, supportsByteRangeAccess: Bool, headerFields: [String: String]?, savePath: String, autoRegister: Bool = true) {
27
52
  self.mimeType = mimeType
28
53
  self.supportsByteRangeAccess = supportsByteRangeAccess
29
54
  self.expectedContentLength = expectedContentLength
30
55
  self.headerFields = headerFields
31
56
  self.savePath = savePath
57
+ self.shouldAutoUnregister = autoRegister
32
58
  self.loadedDataRanges = loadedDataRanges
33
59
 
34
- if let url = URL(string: savePath) {
35
- VideoCacheManager.shared.registerOpenFile(at: url)
60
+ // Only auto-register if requested (prevents double registration)
61
+ if autoRegister {
62
+ // Use fileURLWithPath for proper file URL creation instead of URL(string:)
63
+ let fileUrl = URL(fileURLWithPath: savePath)
64
+ VideoCacheManager.shared.registerOpenFile(at: fileUrl)
65
+ print("[MediaInfo] Registering file URL: \(fileUrl.path)")
36
66
  }
37
67
  }
38
68
 
39
69
  deinit {
40
- if let url = URL(string: savePath) {
41
- VideoCacheManager.shared.unregisterOpenFile(at: url)
70
+ // Only unregister if we auto-registered (prevents double unregistration)
71
+ if shouldAutoUnregister {
72
+ // Use fileURLWithPath for proper file URL creation instead of URL(string:)
73
+ let fileUrl = URL(fileURLWithPath: savePath)
74
+ VideoCacheManager.shared.unregisterOpenFile(at: fileUrl)
75
+ print("[MediaInfo] Unregistering file URL: \(fileUrl.path)")
42
76
  }
43
77
  }
44
78
 
@@ -50,7 +84,8 @@ class MediaInfo: Codable {
50
84
  mimeType: mediaInfo.mimeType,
51
85
  supportsByteRangeAccess: mediaInfo.supportsByteRangeAccess,
52
86
  headerFields: mediaInfo.headerFields,
53
- savePath: mediaInfo.savePath)
87
+ savePath: mediaInfo.savePath,
88
+ autoRegister: true) // Default behavior for loaded MediaInfo
54
89
  self.loadedDataRanges = mediaInfo.loadedDataRanges
55
90
  } catch {
56
91
  return nil
@@ -13,7 +13,17 @@ class VideoCacheManager {
13
13
  private let maxCacheSizeKey = "\(VideoCacheManager.expoVideoCacheScheme)/maxCacheSize"
14
14
 
15
15
  // Files currently being used/modified by the player - they will be skipped when clearing the cache
16
- private var openFiles: Set<URL> = Set()
16
+ private let openFilesQueue = DispatchQueue(label: "\(VideoCacheManager.expoVideoCacheScheme)-openFiles-queue", attributes: .concurrent)
17
+ private var _openFiles: Set<URL> = Set()
18
+
19
+ private var openFiles: Set<URL> {
20
+ get {
21
+ return openFilesQueue.sync { _openFiles }
22
+ }
23
+ set {
24
+ openFilesQueue.async(flags: .barrier) { self._openFiles = newValue }
25
+ }
26
+ }
17
27
 
18
28
  // All cache commands such as clean or adding new data should be run on this queue
19
29
  let cacheQueue = DispatchQueue(label: "\(VideoCacheManager.expoVideoCacheScheme)-dispatch-queue")
@@ -23,11 +33,29 @@ class VideoCacheManager {
23
33
  }
24
34
 
25
35
  func registerOpenFile(at url: URL) {
26
- openFiles.insert(url)
36
+ guard url.isFileURL else {
37
+ print("[VideoCacheManager] Warning: Attempting to register non-file URL: \(url)")
38
+ return
39
+ }
40
+
41
+ openFilesQueue.async(flags: .barrier) { [weak self] in
42
+ guard let self = self else { return }
43
+ self._openFiles.insert(url)
44
+ print("[VideoCacheManager] Registered file: \(url.path)")
45
+ }
27
46
  }
28
47
 
29
48
  func unregisterOpenFile(at url: URL) {
30
- openFiles.remove(url)
49
+ guard url.isFileURL else {
50
+ print("[VideoCacheManager] Warning: Attempting to unregister non-file URL: \(url)")
51
+ return
52
+ }
53
+
54
+ openFilesQueue.async(flags: .barrier) { [weak self] in
55
+ guard let self = self else { return }
56
+ self._openFiles.remove(url)
57
+ print("[VideoCacheManager] Unregistered file: \(url.path)")
58
+ }
31
59
  }
32
60
 
33
61
  func setMaxCacheSize(newSize: Int) throws {
@@ -177,7 +205,9 @@ class VideoCacheManager {
177
205
  }
178
206
 
179
207
  private func fileIsOpen(url: URL) -> Bool {
180
- return openFiles.contains(url) || openFiles.contains { $0.relativePath == url.relativePath }
208
+ return openFilesQueue.sync {
209
+ return _openFiles.contains(url) || _openFiles.contains { $0.relativePath == url.relativePath }
210
+ }
181
211
  }
182
212
  }
183
213
 
@@ -210,6 +240,14 @@ extension VideoCacheManager {
210
240
  let fileUrl = URL(fileURLWithPath: saveFilePath)
211
241
  let mediaInfoPath = saveFilePath + VideoCacheManager.mediaInfoSuffix
212
242
 
243
+ // CRITICAL FIX: Register file as open BEFORE any operations to prevent cache cleanup race condition
244
+ VideoCacheManager.shared.registerOpenFile(at: fileUrl)
245
+
246
+ defer {
247
+ // Always unregister when done, even if there's an error
248
+ VideoCacheManager.shared.unregisterOpenFile(at: fileUrl)
249
+ }
250
+
213
251
  // Ensure parent directory exists before writing file
214
252
  let parentDir = fileUrl.deletingLastPathComponent()
215
253
  do {
@@ -226,14 +264,23 @@ extension VideoCacheManager {
226
264
  let mimeType = response.mimeType
227
265
  let supportsByteRangeAccess = true
228
266
  let headerFields: [String: String]? = nil
229
- let mediaInfo = MediaInfo(expectedContentLength: expectedLength, mimeType: mimeType, supportsByteRangeAccess: supportsByteRangeAccess, headerFields: headerFields, savePath: mediaInfoPath)
267
+
268
+ // Create MediaInfo without auto-registration (we already registered above)
269
+ let mediaInfo = MediaInfo(
270
+ expectedContentLength: expectedLength,
271
+ mimeType: mimeType,
272
+ supportsByteRangeAccess: supportsByteRangeAccess,
273
+ headerFields: headerFields,
274
+ savePath: mediaInfoPath,
275
+ autoRegister: false
276
+ )
230
277
  mediaInfo.setLoadedDataRanges([(0, data.count - 1)])
231
278
  mediaInfo.saveToFile()
232
279
 
233
280
  // Print current cache size
234
281
  let cacheSize = VideoCacheManager.shared.getCacheDirectorySize()
235
282
  print("[expo-video] Cache size after caching: \(cacheSize) bytes")
236
- return false
283
+ return true
237
284
  }
238
285
 
239
286
  static func preCacheVideoPartialAsync(from urlString: String, chunkSize: Int = 1_048_576) async throws -> Bool {
@@ -72,8 +72,11 @@ internal class VideoAsset: AVURLAsset, @unchecked Sendable {
72
72
  guard useCaching else {
73
73
  return
74
74
  }
75
- if let saveFilePath, let cachedFileUrl = URL(string: saveFilePath) {
75
+ if let saveFilePath {
76
+ // Use fileURLWithPath for proper file URL creation instead of URL(string:)
77
+ let cachedFileUrl = URL(fileURLWithPath: saveFilePath)
76
78
  VideoCacheManager.shared.unregisterOpenFile(at: cachedFileUrl)
79
+ print("[VideoAsset] Unregistering file URL: \(cachedFileUrl.path)")
77
80
  }
78
81
  VideoCacheManager.shared.ensureCacheSize()
79
82
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stepincto/expo-video",
3
3
  "title": "Expo Video",
4
- "version": "1.0.4",
4
+ "version": "1.0.5",
5
5
  "originalUpstreamVersion": "2.2.2",
6
6
  "description": "A cross-platform, performant video component for React Native and Expo with Web support",
7
7
  "main": "build/index.js",
@@ -1,5 +1,7 @@
1
1
  import NativeVideoModule from './NativeVideoModule';
2
2
 
3
+ const globalCacheOperations = new Map<string, Promise<boolean>>(); // stores the promises that are currently caching files
4
+
3
5
  /**
4
6
  * Returns whether the current device supports Picture in Picture (PiP) mode.
5
7
  *
@@ -46,8 +48,33 @@ export function getCurrentVideoCacheSize(): number {
46
48
  return NativeVideoModule.getCurrentVideoCacheSize();
47
49
  }
48
50
 
51
+ // export async function preCacheVideoAsync(url: string): Promise<boolean> {
52
+ // return NativeVideoModule.preCacheVideoAsync(url);
53
+ // }
54
+
55
+ // Modify the existing function
49
56
  export async function preCacheVideoAsync(url: string): Promise<boolean> {
50
- return NativeVideoModule.preCacheVideoAsync(url);
57
+ // If already caching, return the existing promise
58
+ if (globalCacheOperations.has(url)) {
59
+ return await globalCacheOperations.get(url)!;
60
+ }
61
+
62
+ // Start new caching operation with proper error handling
63
+ const cachingPromise = NativeVideoModule.preCacheVideoAsync(url).catch((error) => {
64
+ // Ensure cleanup happens even on native errors
65
+ globalCacheOperations.delete(url);
66
+ throw error;
67
+ });
68
+
69
+ globalCacheOperations.set(url, cachingPromise);
70
+
71
+ try {
72
+ const result = await cachingPromise;
73
+ return result;
74
+ } finally {
75
+ // Always cleanup, even if the promise was already cleaned up in catch
76
+ globalCacheOperations.delete(url);
77
+ }
51
78
  }
52
79
 
53
80
  export async function preCacheVideoPartialAsync(url: string, chunkSize?: number): Promise<boolean> {