capacitor-native-video-compressor 1.0.3 → 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.
- package/README.md +88 -5
- package/android/src/main/java/com/atomic/videocompressor/NativeVideoCompressorPlugin.java +88 -5
- package/dist/docs.json +58 -0
- package/dist/esm/definitions.d.ts +35 -0
- package/dist/esm/definitions.js +6 -1
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/web.d.ts +16 -1
- package/dist/esm/web.js +135 -23
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +142 -23
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +142 -23
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/NativeVideoCompressorPlugin/NativeVideoCompressorPlugin.swift +295 -76
- package/package.json +1 -1
|
@@ -6,123 +6,342 @@ import UIKit
|
|
|
6
6
|
@objc(NativeVideoCompressorPlugin)
|
|
7
7
|
public class NativeVideoCompressorPlugin: CAPPlugin, CAPBridgedPlugin {
|
|
8
8
|
|
|
9
|
-
// --- BẮT ĐẦU PHẦN ĐĂNG KÝ PLUGIN (Thay thế cho file .m cũ) ---
|
|
10
9
|
public let identifier = "NativeVideoCompressorPlugin"
|
|
11
10
|
public let jsName = "NativeVideoCompressor"
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
// Every method callable from JS must be listed here. `initialize` was
|
|
12
|
+
// missing, so calling it threw "not implemented" on device — harmless
|
|
13
|
+
// (the web build needs it, native doesn't) but it buried a real signal in
|
|
14
|
+
// a console error every single compress.
|
|
15
|
+
public let pluginMethods:[CAPPluginMethod] = [
|
|
16
|
+
CAPPluginMethod(name: "compressVideo", returnType: CAPPluginReturnPromise),
|
|
17
|
+
CAPPluginMethod(name: "cancel", returnType: CAPPluginReturnPromise),
|
|
18
|
+
CAPPluginMethod(name: "initialize", returnType: CAPPluginReturnPromise)
|
|
14
19
|
]
|
|
15
|
-
|
|
16
|
-
|
|
20
|
+
|
|
21
|
+
/// The job `compressVideo` is running right now, for `cancel(jobId:)`.
|
|
22
|
+
private var currentJobId: String?
|
|
23
|
+
private var isCancelled = false
|
|
24
|
+
/// Held only so `cancel` can stop it; the compress owns it otherwise.
|
|
25
|
+
private var currentReader: AVAssetReader?
|
|
26
|
+
/// Guards the three fields above — `cancel` arrives on the main thread
|
|
27
|
+
/// while the reader is being driven from the compression queues.
|
|
28
|
+
private let stateLock = NSLock()
|
|
29
|
+
|
|
30
|
+
/// Nothing to do natively; AVFoundation needs no warm-up. Answered rather
|
|
31
|
+
/// than left unimplemented so a caller can await it unconditionally.
|
|
32
|
+
@objc func initialize(_ call: CAPPluginCall) {
|
|
33
|
+
call.resolve(["success": true, "message": "No initialization needed on iOS"])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Stop the running compress.
|
|
38
|
+
*
|
|
39
|
+
* `cancelReading()` is all it takes: the frame loops below already check
|
|
40
|
+
* `reader.status == .reading` every pass, so a cancelled reader sends them
|
|
41
|
+
* down the `else` branch — `markAsFinished()`, `group.leave()` — and the
|
|
42
|
+
* existing completion handler runs. No new exit path, no polling.
|
|
43
|
+
*/
|
|
44
|
+
@objc func cancel(_ call: CAPPluginCall) {
|
|
45
|
+
stateLock.lock()
|
|
46
|
+
let running = currentJobId
|
|
47
|
+
let requested = call.getString("jobId")
|
|
48
|
+
// A cancel for a job that isn't the one running would otherwise kill
|
|
49
|
+
// an unrelated caller's compress — there is only ever one.
|
|
50
|
+
guard let running = running, requested == nil || requested == running else {
|
|
51
|
+
stateLock.unlock()
|
|
52
|
+
call.resolve(["cancelled": false])
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
isCancelled = true
|
|
56
|
+
let reader = currentReader
|
|
57
|
+
stateLock.unlock()
|
|
58
|
+
|
|
59
|
+
reader?.cancelReading()
|
|
60
|
+
call.resolve(["cancelled": true])
|
|
61
|
+
}
|
|
17
62
|
|
|
18
63
|
@objc func compressVideo(_ call: CAPPluginCall) {
|
|
19
|
-
// 1. Lấy đường dẫn từ Javascript truyền vào
|
|
20
64
|
guard let sourcePath = call.getString("sourcePath") else {
|
|
21
65
|
call.reject("Missing sourcePath")
|
|
22
66
|
return
|
|
23
67
|
}
|
|
24
|
-
|
|
25
|
-
|
|
68
|
+
|
|
69
|
+
stateLock.lock()
|
|
70
|
+
currentJobId = call.getString("jobId") ?? "default"
|
|
71
|
+
isCancelled = false
|
|
72
|
+
stateLock.unlock()
|
|
73
|
+
|
|
26
74
|
let videoURL = sourcePath.hasPrefix("file://") ? URL(string: sourcePath)! : URL(fileURLWithPath: sourcePath)
|
|
27
|
-
|
|
28
|
-
// Tạo file đầu ra lưu ở thư mục Temp
|
|
29
75
|
let outputURL = FileManager.default.temporaryDirectory.appendingPathComponent("compressed_\(UUID().uuidString).mp4")
|
|
30
76
|
|
|
31
|
-
// Lấy thông số chất lượng truyền từ giao diện (Mặc định là MEDIUM nếu không có)
|
|
32
77
|
let qualityStr = call.getString("quality") ?? "MEDIUM"
|
|
33
|
-
var presetName: String
|
|
34
78
|
|
|
35
|
-
|
|
79
|
+
// 1. Định nghĩa Kích thước và Bitrate (Quyết định dung lượng file)
|
|
80
|
+
var targetWidth: CGFloat
|
|
81
|
+
var targetHeight: CGFloat
|
|
82
|
+
var targetBitrate: Int // Bitrate càng thấp file càng nhẹ
|
|
83
|
+
|
|
84
|
+
print("====== VIDEO COMPRESSION QUALITY: \(qualityStr) ======")
|
|
36
85
|
|
|
37
86
|
switch qualityStr {
|
|
38
87
|
case "VERY_HIGH":
|
|
39
|
-
|
|
88
|
+
targetWidth = 1920; targetHeight = 1080; targetBitrate = 4_500_000 // ~4.5 Mbps
|
|
40
89
|
case "HIGH":
|
|
41
|
-
|
|
90
|
+
targetWidth = 1280; targetHeight = 720; targetBitrate = 2_500_000 // ~2.5 Mbps
|
|
42
91
|
case "MEDIUM":
|
|
43
|
-
|
|
92
|
+
targetWidth = 960; targetHeight = 540; targetBitrate = 1_500_000 // ~1.5 Mbps (Giảm dung lượng cực tốt)
|
|
44
93
|
case "LOW":
|
|
45
|
-
|
|
46
|
-
case "360P"
|
|
47
|
-
|
|
94
|
+
targetWidth = 640; targetHeight = 480; targetBitrate = 1_000_000 // ~1.0 Mbps
|
|
95
|
+
case "360P":
|
|
96
|
+
targetWidth = 640; targetHeight = 360; targetBitrate = 700_000 // ~0.7 Mbps
|
|
48
97
|
case "VERY_LOW":
|
|
49
|
-
|
|
98
|
+
targetWidth = 426; targetHeight = 240; targetBitrate = 400_000 // ~0.4 Mbps
|
|
50
99
|
default:
|
|
51
|
-
|
|
100
|
+
targetWidth = 960; targetHeight = 540; targetBitrate = 1_500_000
|
|
52
101
|
}
|
|
53
102
|
|
|
54
103
|
let asset = AVAsset(url: videoURL)
|
|
55
104
|
|
|
56
|
-
// 3. Khởi tạo bộ nén phần cứng với preset tương ứng
|
|
57
|
-
guard let exportSession = AVAssetExportSession(asset: asset, presetName: presetName) else {
|
|
58
|
-
call.reject("Không thể khởi tạo bộ nén iOS")
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
exportSession.outputURL = outputURL
|
|
63
|
-
exportSession.outputFileType = .mp4
|
|
64
|
-
exportSession.shouldOptimizeForNetworkUse = true
|
|
65
|
-
|
|
66
|
-
// Báo cho giao diện biết là đã bắt đầu nén
|
|
67
105
|
self.notifyListeners("onProgress", data:["status": "started"])
|
|
68
106
|
|
|
69
|
-
self.isCompressing = true
|
|
70
|
-
let startTime = Date()
|
|
71
|
-
self.checkProgress(session: exportSession)
|
|
72
|
-
|
|
73
|
-
// Đăng ký Background Task để ứng dụng tiếp tục nén khi thu nhỏ
|
|
74
107
|
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
|
|
75
|
-
|
|
76
|
-
// Cần gọi UIBackgroundTaskIdentifier trên Main Thread (nếu an toàn) hoặc dùng trực tiếp (beginBackgroundTask thread-safe)
|
|
77
108
|
backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "VideoCompression") {
|
|
78
|
-
// Callback khi hết thời gian chạy ngầm do OS cấp
|
|
79
109
|
UIApplication.shared.endBackgroundTask(backgroundTask)
|
|
80
110
|
backgroundTask = .invalid
|
|
81
111
|
}
|
|
82
112
|
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
113
|
+
// 2. Gọi hàm nén Custom bằng AVAssetWriter
|
|
114
|
+
self.compressWithAVAssetWriter(
|
|
115
|
+
asset: asset,
|
|
116
|
+
outputURL: outputURL,
|
|
117
|
+
targetWidth: targetWidth,
|
|
118
|
+
targetHeight: targetHeight,
|
|
119
|
+
targetBitrate: targetBitrate,
|
|
120
|
+
call: call,
|
|
121
|
+
backgroundTask: backgroundTask
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// MARK: - Core Compression Logic (AVAssetWriter)
|
|
126
|
+
private func compressWithAVAssetWriter(
|
|
127
|
+
asset: AVAsset,
|
|
128
|
+
outputURL: URL,
|
|
129
|
+
targetWidth: CGFloat,
|
|
130
|
+
targetHeight: CGFloat,
|
|
131
|
+
targetBitrate: Int,
|
|
132
|
+
call: CAPPluginCall,
|
|
133
|
+
backgroundTask: UIBackgroundTaskIdentifier
|
|
134
|
+
) {
|
|
135
|
+
guard let videoTrack = asset.tracks(withMediaType: .video).first else {
|
|
136
|
+
clearJobState()
|
|
137
|
+
call.reject("Không tìm thấy track video")
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
let audioTrack = asset.tracks(withMediaType: .audio).first
|
|
141
|
+
|
|
142
|
+
do {
|
|
143
|
+
let reader = try AVAssetReader(asset: asset)
|
|
144
|
+
let writer = try AVAssetWriter(url: outputURL, fileType: .mp4)
|
|
145
|
+
writer.shouldOptimizeForNetworkUse = true // Tối ưu cho Web/Network
|
|
146
|
+
|
|
147
|
+
stateLock.lock()
|
|
148
|
+
currentReader = reader
|
|
149
|
+
// A cancel that landed between compressVideo and here has nothing
|
|
150
|
+
// to call cancelReading() on, so honour it before any work starts.
|
|
151
|
+
let cancelledEarly = isCancelled
|
|
152
|
+
stateLock.unlock()
|
|
153
|
+
|
|
154
|
+
if cancelledEarly {
|
|
155
|
+
finishCancelled(outputURL: outputURL, call: call, backgroundTask: backgroundTask)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- XỬ LÝ KÍCH THƯỚC VÀ CHIỀU XOAY (ORIENTATION) ---
|
|
160
|
+
let naturalSize = videoTrack.naturalSize
|
|
161
|
+
let transform = videoTrack.preferredTransform
|
|
162
|
+
|
|
163
|
+
// Tính toán chiều thực tế khi hiển thị
|
|
164
|
+
var visualSize = naturalSize.applying(transform)
|
|
165
|
+
visualSize.width = abs(visualSize.width)
|
|
166
|
+
visualSize.height = abs(visualSize.height)
|
|
167
|
+
|
|
168
|
+
let isVisualPortrait = visualSize.height > visualSize.width
|
|
169
|
+
let boundingWidth = isVisualPortrait ? min(targetWidth, targetHeight) : max(targetWidth, targetHeight)
|
|
170
|
+
let boundingHeight = isVisualPortrait ? max(targetWidth, targetHeight) : min(targetWidth, targetHeight)
|
|
88
171
|
|
|
89
|
-
|
|
90
|
-
|
|
172
|
+
let isNaturalPortrait = naturalSize.height > naturalSize.width
|
|
173
|
+
let outputWidth = isNaturalPortrait ? min(boundingWidth, boundingHeight) : max(boundingWidth, boundingHeight)
|
|
174
|
+
let outputHeight = isNaturalPortrait ? max(boundingWidth, boundingHeight) : min(boundingWidth, boundingHeight)
|
|
175
|
+
|
|
176
|
+
// --- CẤU HÌNH VIDEO ---
|
|
177
|
+
let videoOutputSettings: [String: Any] = [
|
|
178
|
+
kCVPixelBufferPixelFormatTypeKey as String: Int(kCVPixelFormatType_32BGRA)
|
|
179
|
+
]
|
|
180
|
+
let videoOutput = AVAssetReaderTrackOutput(track: videoTrack, outputSettings: videoOutputSettings)
|
|
181
|
+
if reader.canAdd(videoOutput) { reader.add(videoOutput) }
|
|
182
|
+
|
|
183
|
+
let videoInputSettings:[String: Any] = [
|
|
184
|
+
AVVideoCodecKey: AVVideoCodecType.h264,
|
|
185
|
+
AVVideoWidthKey: Int(outputWidth),
|
|
186
|
+
AVVideoHeightKey: Int(outputHeight),
|
|
187
|
+
AVVideoCompressionPropertiesKey:[
|
|
188
|
+
AVVideoAverageBitRateKey: targetBitrate, // ÉP BITRATE TẠI ĐÂY
|
|
189
|
+
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel
|
|
190
|
+
]
|
|
191
|
+
]
|
|
192
|
+
let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoInputSettings)
|
|
193
|
+
videoInput.transform = transform // Giữ nguyên chiều xoay gốc
|
|
194
|
+
if writer.canAdd(videoInput) { writer.add(videoInput) }
|
|
195
|
+
|
|
196
|
+
// --- CẤU HÌNH AUDIO ---
|
|
197
|
+
var audioOutput: AVAssetReaderTrackOutput?
|
|
198
|
+
var audioInput: AVAssetWriterInput?
|
|
199
|
+
|
|
200
|
+
if let audioTrack = audioTrack {
|
|
201
|
+
let audioOutputSettings:[String: Any] = [AVFormatIDKey: kAudioFormatLinearPCM]
|
|
202
|
+
audioOutput = AVAssetReaderTrackOutput(track: audioTrack, outputSettings: audioOutputSettings)
|
|
203
|
+
if reader.canAdd(audioOutput!) { reader.add(audioOutput!) }
|
|
204
|
+
|
|
205
|
+
let audioInputSettings:[String: Any] = [
|
|
206
|
+
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
|
207
|
+
AVNumberOfChannelsKey: 2,
|
|
208
|
+
AVSampleRateKey: 44100,
|
|
209
|
+
AVEncoderBitRateKey: 128000 // Audio nén về 128kbps
|
|
210
|
+
]
|
|
211
|
+
audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioInputSettings)
|
|
212
|
+
if writer.canAdd(audioInput!) { writer.add(audioInput!) }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// --- BẮT ĐẦU NÉN ---
|
|
216
|
+
writer.startWriting()
|
|
217
|
+
reader.startReading()
|
|
218
|
+
writer.startSession(atSourceTime: .zero)
|
|
219
|
+
|
|
220
|
+
let group = DispatchGroup()
|
|
221
|
+
let videoQueue = DispatchQueue(label: "videoCompressQueue")
|
|
222
|
+
let audioQueue = DispatchQueue(label: "audioCompressQueue")
|
|
223
|
+
|
|
224
|
+
let duration = CMTimeGetSeconds(asset.duration)
|
|
225
|
+
var lastReportedProgress: Int = 0
|
|
226
|
+
|
|
227
|
+
// Xử lý Video Frame
|
|
228
|
+
group.enter()
|
|
229
|
+
videoInput.requestMediaDataWhenReady(on: videoQueue) {
|
|
230
|
+
while videoInput.isReadyForMoreMediaData {
|
|
231
|
+
autoreleasepool {
|
|
232
|
+
if reader.status == .reading, let buffer = videoOutput.copyNextSampleBuffer() {
|
|
233
|
+
videoInput.append(buffer)
|
|
234
|
+
|
|
235
|
+
// Tính toán Progress
|
|
236
|
+
if duration > 0 && !duration.isNaN {
|
|
237
|
+
let pts = CMSampleBufferGetPresentationTimeStamp(buffer)
|
|
238
|
+
let currentSeconds = CMTimeGetSeconds(pts)
|
|
239
|
+
let progress = Int((currentSeconds / duration) * 100)
|
|
240
|
+
|
|
241
|
+
if progress > lastReportedProgress && progress <= 100 {
|
|
242
|
+
lastReportedProgress = progress
|
|
243
|
+
DispatchQueue.main.async {
|
|
244
|
+
self.notifyListeners("onProgress", data:[
|
|
245
|
+
"status": "progress",
|
|
246
|
+
"percent": Double(progress)
|
|
247
|
+
])
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
} else {
|
|
252
|
+
videoInput.markAsFinished()
|
|
253
|
+
group.leave()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Xử lý Audio Frame
|
|
260
|
+
if let aInput = audioInput, let aOutput = audioOutput {
|
|
261
|
+
group.enter()
|
|
262
|
+
aInput.requestMediaDataWhenReady(on: audioQueue) {
|
|
263
|
+
while aInput.isReadyForMoreMediaData {
|
|
264
|
+
autoreleasepool {
|
|
265
|
+
if reader.status == .reading, let buffer = aOutput.copyNextSampleBuffer() {
|
|
266
|
+
aInput.append(buffer)
|
|
267
|
+
} else {
|
|
268
|
+
aInput.markAsFinished()
|
|
269
|
+
group.leave()
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// --- KẾT THÚC ---
|
|
277
|
+
group.notify(queue: .main) {
|
|
91
278
|
if backgroundTask != .invalid {
|
|
92
279
|
UIApplication.shared.endBackgroundTask(backgroundTask)
|
|
93
|
-
backgroundTask = .invalid
|
|
94
280
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
call.reject("Đã hủy nén")
|
|
107
|
-
|
|
108
|
-
|
|
281
|
+
|
|
282
|
+
self.stateLock.lock()
|
|
283
|
+
let wasCancelled = self.isCancelled
|
|
284
|
+
self.currentReader = nil
|
|
285
|
+
self.currentJobId = nil
|
|
286
|
+
self.isCancelled = false
|
|
287
|
+
self.stateLock.unlock()
|
|
288
|
+
|
|
289
|
+
if wasCancelled {
|
|
290
|
+
writer.cancelWriting()
|
|
291
|
+
try? FileManager.default.removeItem(at: outputURL)
|
|
292
|
+
call.reject("Đã hủy nén", NativeVideoCompressorPlugin.cancelledCode)
|
|
293
|
+
return
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if reader.status == .completed {
|
|
297
|
+
writer.finishWriting {
|
|
298
|
+
DispatchQueue.main.async {
|
|
299
|
+
if writer.status == .completed {
|
|
300
|
+
call.resolve([
|
|
301
|
+
"success": true,
|
|
302
|
+
"destPath": outputURL.path
|
|
303
|
+
])
|
|
304
|
+
} else {
|
|
305
|
+
call.reject("Lỗi khi ghi file: \(writer.error?.localizedDescription ?? "Unknown")")
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
writer.cancelWriting()
|
|
311
|
+
try? FileManager.default.removeItem(at: outputURL)
|
|
312
|
+
call.reject("Lỗi khi đọc file: \(reader.error?.localizedDescription ?? "Unknown")")
|
|
109
313
|
}
|
|
110
314
|
}
|
|
315
|
+
|
|
316
|
+
} catch {
|
|
317
|
+
clearJobState()
|
|
318
|
+
call.reject("Lỗi khởi tạo bộ nén: \(error.localizedDescription)")
|
|
111
319
|
}
|
|
112
320
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
if self.isCompressing && (session.status == .exporting || session.status == .waiting || session.status == .unknown) {
|
|
123
|
-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
|
124
|
-
self?.checkProgress(session: session)
|
|
125
|
-
}
|
|
321
|
+
|
|
322
|
+
/// Cancelled before the reader started — nothing to stop, just clean up.
|
|
323
|
+
private func finishCancelled(
|
|
324
|
+
outputURL: URL,
|
|
325
|
+
call: CAPPluginCall,
|
|
326
|
+
backgroundTask: UIBackgroundTaskIdentifier
|
|
327
|
+
) {
|
|
328
|
+
if backgroundTask != .invalid {
|
|
329
|
+
UIApplication.shared.endBackgroundTask(backgroundTask)
|
|
126
330
|
}
|
|
331
|
+
clearJobState()
|
|
332
|
+
try? FileManager.default.removeItem(at: outputURL)
|
|
333
|
+
call.reject("Đã hủy nén", NativeVideoCompressorPlugin.cancelledCode)
|
|
127
334
|
}
|
|
128
|
-
|
|
335
|
+
|
|
336
|
+
private func clearJobState() {
|
|
337
|
+
stateLock.lock()
|
|
338
|
+
currentReader = nil
|
|
339
|
+
currentJobId = nil
|
|
340
|
+
isCancelled = false
|
|
341
|
+
stateLock.unlock()
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/// Matches the `CANCELLED` constant the JS side exports, so one
|
|
345
|
+
/// `error.code` check covers web, iOS and Android alike.
|
|
346
|
+
private static let cancelledCode = "CANCELLED"
|
|
347
|
+
}
|
package/package.json
CHANGED