stream-chat-react-native 9.0.0-beta.3 → 9.0.0-beta.30
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/android/build.gradle +30 -0
- package/android/src/main/java/com/streamchatreactnative/StreamChatReactNative.java +0 -153
- package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativeModule.java +2 -15
- package/android/src/main/java/com/streamchatreactnative/StreamChatReactNativePackage.java +32 -0
- package/android/src/main/java/com/streamchatreactnative/shared/StreamVideoThumbnailGenerator.kt +159 -0
- package/android/src/newarch/com/streamchatreactnative/StreamVideoThumbnailModule.kt +50 -0
- package/android/src/oldarch/com/streamchatreactnative/StreamChatReactNative.java +1 -1
- package/ios/StreamChatReactNative.mm +16 -148
- package/ios/shared/StreamVideoThumbnail.h +14 -0
- package/ios/shared/StreamVideoThumbnail.mm +56 -0
- package/ios/shared/StreamVideoThumbnailGenerator.swift +338 -0
- package/package.json +10 -3
- package/react-native.config.js +13 -0
- package/src/handlers/compressImage.ts +0 -1
- package/src/native/NativeStreamChatReactNative.ts +0 -1
- package/src/native/NativeStreamVideoThumbnail.ts +14 -0
- package/src/native/index.tsx +0 -2
- package/src/native/types.ts +2 -0
- package/src/native/videoThumbnail.ts +8 -0
- package/src/optionalDependencies/Audio.ts +1 -1
- package/src/optionalDependencies/Sound.tsx +349 -43
- package/src/optionalDependencies/generateThumbnail.ts +8 -0
- package/src/optionalDependencies/getPhotos.ts +28 -12
- package/src/optionalDependencies/pickImage.ts +34 -9
- package/src/optionalDependencies/takePhoto.ts +7 -0
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
#import "StreamChatReactNative.h"
|
|
2
2
|
#import <React/RCTLog.h>
|
|
3
|
-
#import <AssetsLibrary/AssetsLibrary.h>
|
|
4
|
-
#import <MobileCoreServices/MobileCoreServices.h>
|
|
5
3
|
|
|
6
4
|
#if __has_include(<React/RCTLog.h>)
|
|
7
5
|
#import <React/RCTLog.h>
|
|
@@ -19,12 +17,12 @@ NSString *moduleName = @"StreamChatReactNative";
|
|
|
19
17
|
|
|
20
18
|
RCT_EXPORT_MODULE()
|
|
21
19
|
|
|
22
|
-
RCT_REMAP_METHOD(createResizedImage, uri:(NSString *)uri width:(double)width height:(double)height format:(NSString *)format quality:(double)quality mode:(NSString *)mode onlyScaleDown:(BOOL)onlyScaleDown rotation:(nonnull NSNumber *)rotation outputPath:(NSString *)outputPath
|
|
20
|
+
RCT_REMAP_METHOD(createResizedImage, uri:(NSString *)uri width:(double)width height:(double)height format:(NSString *)format quality:(double)quality mode:(NSString *)mode onlyScaleDown:(BOOL)onlyScaleDown rotation:(nonnull NSNumber *)rotation outputPath:(NSString *)outputPath resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
|
|
23
21
|
{
|
|
24
|
-
[self createResizedImage:uri width:width height:height format:format quality:quality mode:mode onlyScaleDown:onlyScaleDown rotation:rotation outputPath:outputPath
|
|
22
|
+
[self createResizedImage:uri width:width height:height format:format quality:quality mode:mode onlyScaleDown:onlyScaleDown rotation:rotation outputPath:outputPath resolve:resolve reject:reject];
|
|
25
23
|
}
|
|
26
24
|
|
|
27
|
-
- (void)createResizedImage:(NSString *)uri width:(double)width height:(double)height format:(NSString *)format quality:(double)quality mode:(NSString *)mode onlyScaleDown:(BOOL)onlyScaleDown rotation:(nonnull NSNumber *)rotation outputPath:(NSString *)outputPath
|
|
25
|
+
- (void)createResizedImage:(NSString *)uri width:(double)width height:(double)height format:(NSString *)format quality:(double)quality mode:(NSString *)mode onlyScaleDown:(BOOL)onlyScaleDown rotation:(nonnull NSNumber *)rotation outputPath:(NSString *)outputPath resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject {
|
|
28
26
|
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
|
29
27
|
@try {
|
|
30
28
|
CGSize newSize = CGSizeMake(width, height);
|
|
@@ -57,7 +55,7 @@ RCT_REMAP_METHOD(createResizedImage, uri:(NSString *)uri width:(double)width hei
|
|
|
57
55
|
reject([NSString stringWithFormat: @"%ld", (long)error.code], error.description, nil);
|
|
58
56
|
return;
|
|
59
57
|
}
|
|
60
|
-
NSDictionary * response = transformImage(image,
|
|
58
|
+
NSDictionary * response = transformImage(image, [rotation integerValue], newSize, fullPath, format, (int)quality, @{@"mode": mode, @"onlyScaleDown": [NSNumber numberWithBool:onlyScaleDown]});
|
|
61
59
|
resolve(response);
|
|
62
60
|
}];
|
|
63
61
|
} @catch (NSException *exception) {
|
|
@@ -69,65 +67,21 @@ RCT_REMAP_METHOD(createResizedImage, uri:(NSString *)uri width:(double)width hei
|
|
|
69
67
|
|
|
70
68
|
|
|
71
69
|
|
|
72
|
-
bool saveImage(NSString * fullPath, UIImage * image, NSString * format, float quality
|
|
70
|
+
bool saveImage(NSString * fullPath, UIImage * image, NSString * format, float quality)
|
|
73
71
|
{
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
data = UIImagePNGRepresentation(image);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
if (data == nil) {
|
|
83
|
-
return NO;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
NSFileManager* fileManager = [NSFileManager defaultManager];
|
|
87
|
-
return [fileManager createFileAtPath:fullPath contents:data attributes:nil];
|
|
72
|
+
NSData* data = nil;
|
|
73
|
+
if ([format isEqualToString:@"JPEG"]) {
|
|
74
|
+
data = UIImageJPEGRepresentation(image, quality / 100.0);
|
|
75
|
+
} else if ([format isEqualToString:@"PNG"]) {
|
|
76
|
+
data = UIImagePNGRepresentation(image);
|
|
88
77
|
}
|
|
89
78
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
CFStringRef imgType = kUTTypeJPEG;
|
|
94
|
-
|
|
95
|
-
if ([format isEqualToString:@"JPEG"]) {
|
|
96
|
-
[metadata setObject:@(quality / 100.0) forKey:(__bridge NSString *)kCGImageDestinationLossyCompressionQuality];
|
|
97
|
-
}
|
|
98
|
-
else if([format isEqualToString:@"PNG"]){
|
|
99
|
-
imgType = kUTTypePNG;
|
|
100
|
-
}
|
|
101
|
-
else{
|
|
102
|
-
return NO;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
NSMutableData * destData = [NSMutableData data];
|
|
106
|
-
|
|
107
|
-
CGImageDestinationRef destination = CGImageDestinationCreateWithData((__bridge CFMutableDataRef)destData, imgType, 1, NULL);
|
|
108
|
-
|
|
109
|
-
@try{
|
|
110
|
-
CGImageDestinationAddImage(destination, image.CGImage, (__bridge CFDictionaryRef) metadata);
|
|
111
|
-
|
|
112
|
-
// write final image data with metadata to our destination
|
|
113
|
-
if (CGImageDestinationFinalize(destination)){
|
|
114
|
-
|
|
115
|
-
NSFileManager* fileManager = [NSFileManager defaultManager];
|
|
116
|
-
return [fileManager createFileAtPath:fullPath contents:destData attributes:nil];
|
|
117
|
-
}
|
|
118
|
-
else{
|
|
119
|
-
return NO;
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
@finally{
|
|
123
|
-
@try{
|
|
124
|
-
CFRelease(destination);
|
|
125
|
-
}
|
|
126
|
-
@catch(NSException *exception){
|
|
127
|
-
NSLog(@"Failed to release CGImageDestinationRef: %@", exception);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
79
|
+
if (data == nil) {
|
|
80
|
+
return NO;
|
|
130
81
|
}
|
|
82
|
+
|
|
83
|
+
NSFileManager* fileManager = [NSFileManager defaultManager];
|
|
84
|
+
return [fileManager createFileAtPath:fullPath contents:data attributes:nil];
|
|
131
85
|
}
|
|
132
86
|
|
|
133
87
|
NSString * generateFilePath(NSString * ext, NSString * outputPath)
|
|
@@ -268,81 +222,12 @@ UIImage* scaleImage (UIImage* image, CGSize toSize, NSString* mode, bool onlySca
|
|
|
268
222
|
return newImage;
|
|
269
223
|
}
|
|
270
224
|
|
|
271
|
-
// Returns the image's metadata, or nil if failed to retrieve it.
|
|
272
|
-
NSMutableDictionary * getImageMeta(NSString * path)
|
|
273
|
-
{
|
|
274
|
-
if([path hasPrefix:@"assets-library"]) {
|
|
275
|
-
|
|
276
|
-
__block NSMutableDictionary* res = nil;
|
|
277
|
-
|
|
278
|
-
ALAssetsLibraryAssetForURLResultBlock resultblock = ^(ALAsset *myasset)
|
|
279
|
-
{
|
|
280
|
-
|
|
281
|
-
NSDictionary *exif = [[myasset defaultRepresentation] metadata];
|
|
282
|
-
res = [exif mutableCopy];
|
|
283
|
-
|
|
284
|
-
};
|
|
285
|
-
|
|
286
|
-
ALAssetsLibrary* assetslibrary = [[ALAssetsLibrary alloc] init];
|
|
287
|
-
NSURL *url = [NSURL URLWithString:[path stringByAddingPercentEscapesUsingEncoding:NSUTF8StringEncoding]];
|
|
288
|
-
|
|
289
|
-
[assetslibrary assetForURL:url resultBlock:resultblock failureBlock:^(NSError *error) { NSLog(@"error couldn't image from assets library"); }];
|
|
290
|
-
|
|
291
|
-
return res;
|
|
292
|
-
|
|
293
|
-
} else {
|
|
294
|
-
|
|
295
|
-
NSData* imageData = nil;
|
|
296
|
-
|
|
297
|
-
if ([path hasPrefix:@"data:"] || [path hasPrefix:@"file:"]) {
|
|
298
|
-
NSURL *imageUrl = [[NSURL alloc] initWithString:path];
|
|
299
|
-
imageData = [NSData dataWithContentsOfURL:imageUrl];
|
|
300
|
-
|
|
301
|
-
} else {
|
|
302
|
-
imageData = [NSData dataWithContentsOfFile:path];
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
if(imageData == nil){
|
|
306
|
-
NSLog(@"Could not get image file data to extract metadata.");
|
|
307
|
-
return nil;
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
CGImageSourceRef source = CGImageSourceCreateWithData((CFDataRef)imageData, NULL);
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
if(source != nil){
|
|
314
|
-
|
|
315
|
-
CFDictionaryRef metaRef = CGImageSourceCopyPropertiesAtIndex(source, 0, NULL);
|
|
316
|
-
|
|
317
|
-
// release CF image
|
|
318
|
-
CFRelease(source);
|
|
319
|
-
|
|
320
|
-
CFMutableDictionaryRef metaRefMutable = CFDictionaryCreateMutableCopy(NULL, 0, metaRef);
|
|
321
|
-
|
|
322
|
-
// release the source meta ref now that we've copie it
|
|
323
|
-
CFRelease(metaRef);
|
|
324
|
-
|
|
325
|
-
// bridge CF object so it auto releases
|
|
326
|
-
NSMutableDictionary* res = (NSMutableDictionary *)CFBridgingRelease(metaRefMutable);
|
|
327
|
-
|
|
328
|
-
return res;
|
|
329
|
-
|
|
330
|
-
}
|
|
331
|
-
else{
|
|
332
|
-
return nil;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
|
|
338
225
|
NSDictionary * transformImage(UIImage *image,
|
|
339
|
-
NSString * originalPath,
|
|
340
226
|
int rotation,
|
|
341
227
|
CGSize newSize,
|
|
342
228
|
NSString* fullPath,
|
|
343
229
|
NSString* format,
|
|
344
230
|
int quality,
|
|
345
|
-
BOOL keepMeta,
|
|
346
231
|
NSDictionary* options)
|
|
347
232
|
{
|
|
348
233
|
if (image == nil) {
|
|
@@ -368,25 +253,8 @@ NSDictionary * transformImage(UIImage *image,
|
|
|
368
253
|
if (scaledImage == nil) {
|
|
369
254
|
[NSException raise:moduleName format:@"Can't resize the image."];
|
|
370
255
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
NSMutableDictionary *metadata = nil;
|
|
374
|
-
|
|
375
|
-
// to be consistent with Android, we will only allow JPEG
|
|
376
|
-
// to do this.
|
|
377
|
-
if(keepMeta && [format isEqualToString:@"JPEG"]){
|
|
378
|
-
|
|
379
|
-
metadata = getImageMeta(originalPath);
|
|
380
|
-
|
|
381
|
-
// remove orientation (since we fix it)
|
|
382
|
-
// width/height meta is adjusted automatically
|
|
383
|
-
// NOTE: This might still leave some stale values due to resize
|
|
384
|
-
metadata[(NSString*)kCGImagePropertyOrientation] = @(1);
|
|
385
|
-
|
|
386
|
-
}
|
|
387
|
-
|
|
388
256
|
// Compress and save the image
|
|
389
|
-
if (!saveImage(fullPath, scaledImage, format, quality
|
|
257
|
+
if (!saveImage(fullPath, scaledImage, format, quality)) {
|
|
390
258
|
[NSException raise:moduleName format:@"Can't save the image. Check your compression format and your output path"];
|
|
391
259
|
}
|
|
392
260
|
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#ifdef RCT_NEW_ARCH_ENABLED
|
|
2
|
+
|
|
3
|
+
#if __has_include("StreamChatReactNativeSpec.h")
|
|
4
|
+
#import "StreamChatReactNativeSpec.h"
|
|
5
|
+
#elif __has_include("StreamChatExpoSpec.h")
|
|
6
|
+
#import "StreamChatExpoSpec.h"
|
|
7
|
+
#else
|
|
8
|
+
#error "Unable to find generated codegen spec header for StreamVideoThumbnail."
|
|
9
|
+
#endif
|
|
10
|
+
|
|
11
|
+
@interface StreamVideoThumbnail : NSObject <NativeStreamVideoThumbnailSpec>
|
|
12
|
+
@end
|
|
13
|
+
|
|
14
|
+
#endif
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#import "StreamVideoThumbnail.h"
|
|
2
|
+
|
|
3
|
+
#ifdef RCT_NEW_ARCH_ENABLED
|
|
4
|
+
|
|
5
|
+
#if __has_include(<stream_chat_react_native/stream_chat_react_native-Swift.h>)
|
|
6
|
+
#import <stream_chat_react_native/stream_chat_react_native-Swift.h>
|
|
7
|
+
#elif __has_include(<stream_chat_expo/stream_chat_expo-Swift.h>)
|
|
8
|
+
#import <stream_chat_expo/stream_chat_expo-Swift.h>
|
|
9
|
+
#elif __has_include("stream_chat_react_native-Swift.h")
|
|
10
|
+
#import "stream_chat_react_native-Swift.h"
|
|
11
|
+
#elif __has_include("stream_chat_expo-Swift.h")
|
|
12
|
+
#import "stream_chat_expo-Swift.h"
|
|
13
|
+
#else
|
|
14
|
+
#error "Unable to import generated Swift header for StreamVideoThumbnail."
|
|
15
|
+
#endif
|
|
16
|
+
|
|
17
|
+
@implementation StreamVideoThumbnail
|
|
18
|
+
|
|
19
|
+
RCT_EXPORT_MODULE(StreamVideoThumbnail)
|
|
20
|
+
|
|
21
|
+
RCT_REMAP_METHOD(createVideoThumbnails, urls:(NSArray<NSString *> *)urls resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject)
|
|
22
|
+
{
|
|
23
|
+
[self createVideoThumbnails:urls resolve:resolve reject:reject];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
- (void)createVideoThumbnails:(NSArray<NSString *> *)urls
|
|
27
|
+
resolve:(RCTPromiseResolveBlock)resolve
|
|
28
|
+
reject:(RCTPromiseRejectBlock)reject
|
|
29
|
+
{
|
|
30
|
+
[StreamVideoThumbnailGenerator generateThumbnailsWithUrls:urls completion:^(NSArray<StreamVideoThumbnailResult *> *thumbnails) {
|
|
31
|
+
NSMutableArray<NSDictionary<NSString *, id> *> *payload = [NSMutableArray arrayWithCapacity:thumbnails.count];
|
|
32
|
+
|
|
33
|
+
for (StreamVideoThumbnailResult *thumbnail in thumbnails) {
|
|
34
|
+
NSMutableDictionary<NSString *, id> *entry = [NSMutableDictionary dictionaryWithCapacity:2];
|
|
35
|
+
entry[@"uri"] = thumbnail.uri ?: [NSNull null];
|
|
36
|
+
entry[@"error"] = thumbnail.error ?: [NSNull null];
|
|
37
|
+
[payload addObject:entry];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@try {
|
|
41
|
+
resolve(payload);
|
|
42
|
+
} @catch (NSException *exception) {
|
|
43
|
+
reject(@"stream_video_thumbnail_error", exception.reason, nil);
|
|
44
|
+
}
|
|
45
|
+
}];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
|
|
49
|
+
(const facebook::react::ObjCTurboModule::InitParams &)params
|
|
50
|
+
{
|
|
51
|
+
return std::make_shared<facebook::react::NativeStreamVideoThumbnailSpecJSI>(params);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@end
|
|
55
|
+
|
|
56
|
+
#endif
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import Photos
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
private final class StreamPhotoLibraryAssetRequestState: @unchecked Sendable {
|
|
6
|
+
let lock = NSLock()
|
|
7
|
+
var didResume = false
|
|
8
|
+
var requestID: PHImageRequestID = PHInvalidImageRequestID
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@objcMembers
|
|
12
|
+
public final class StreamVideoThumbnailResult: NSObject {
|
|
13
|
+
public let error: String?
|
|
14
|
+
public let uri: String?
|
|
15
|
+
|
|
16
|
+
public init(error: String? = nil, uri: String? = nil) {
|
|
17
|
+
self.error = error
|
|
18
|
+
self.uri = uri
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@objcMembers
|
|
23
|
+
public final class StreamVideoThumbnailGenerator: NSObject {
|
|
24
|
+
private static let compressionQuality: CGFloat = 0.8
|
|
25
|
+
private static let maxDimension: CGFloat = 512
|
|
26
|
+
private static let cacheVersion = "v1"
|
|
27
|
+
private static let cacheDirectoryName = "@stream-io-stream-video-thumbnails"
|
|
28
|
+
private static let maxConcurrentGenerations = 5
|
|
29
|
+
private static let photoLibraryAssetResolutionTimeout: TimeInterval = 3
|
|
30
|
+
|
|
31
|
+
@objc(generateThumbnailsWithUrls:completion:)
|
|
32
|
+
public static func generateThumbnails(
|
|
33
|
+
urls: [String],
|
|
34
|
+
completion: @escaping ([StreamVideoThumbnailResult]) -> Void
|
|
35
|
+
) {
|
|
36
|
+
Task(priority: .userInitiated) {
|
|
37
|
+
completion(await generateThumbnailsAsync(urls: urls))
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private static func generateThumbnailsAsync(urls: [String]) async -> [StreamVideoThumbnailResult] {
|
|
42
|
+
guard !urls.isEmpty else {
|
|
43
|
+
return []
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if urls.count == 1 {
|
|
47
|
+
return [await generateThumbnailResult(url: urls[0])]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let parallelism = min(maxConcurrentGenerations, urls.count)
|
|
51
|
+
|
|
52
|
+
return await withTaskGroup(
|
|
53
|
+
of: (Int, StreamVideoThumbnailResult).self,
|
|
54
|
+
returning: [StreamVideoThumbnailResult].self
|
|
55
|
+
) { group in
|
|
56
|
+
var thumbnails = Array<StreamVideoThumbnailResult?>(repeating: nil, count: urls.count)
|
|
57
|
+
var nextIndexToSchedule = 0
|
|
58
|
+
|
|
59
|
+
while nextIndexToSchedule < parallelism {
|
|
60
|
+
let index = nextIndexToSchedule
|
|
61
|
+
let url = urls[index]
|
|
62
|
+
group.addTask {
|
|
63
|
+
(index, await generateThumbnailResult(url: url))
|
|
64
|
+
}
|
|
65
|
+
nextIndexToSchedule += 1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
while let (index, thumbnail) = await group.next() {
|
|
69
|
+
thumbnails[index] = thumbnail
|
|
70
|
+
|
|
71
|
+
if nextIndexToSchedule < urls.count {
|
|
72
|
+
let nextIndex = nextIndexToSchedule
|
|
73
|
+
let nextURL = urls[nextIndex]
|
|
74
|
+
group.addTask {
|
|
75
|
+
(nextIndex, await generateThumbnailResult(url: nextURL))
|
|
76
|
+
}
|
|
77
|
+
nextIndexToSchedule += 1
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return thumbnails.enumerated().map { index, thumbnail in
|
|
82
|
+
thumbnail ?? StreamVideoThumbnailResult(
|
|
83
|
+
error: "Thumbnail generation produced no output for index \(index)",
|
|
84
|
+
uri: nil
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private static func generateThumbnailResult(url: String) async -> StreamVideoThumbnailResult {
|
|
91
|
+
do {
|
|
92
|
+
return StreamVideoThumbnailResult(uri: try await generateThumbnail(url: url))
|
|
93
|
+
} catch {
|
|
94
|
+
return StreamVideoThumbnailResult(
|
|
95
|
+
error: error.localizedDescription,
|
|
96
|
+
uri: nil
|
|
97
|
+
)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private static func generateThumbnail(url: String) async throws -> String {
|
|
102
|
+
let outputDirectory = try thumbnailCacheDirectory()
|
|
103
|
+
let outputURL = outputDirectory
|
|
104
|
+
.appendingPathComponent(buildCacheFileName(url: url))
|
|
105
|
+
.appendingPathExtension("jpg")
|
|
106
|
+
|
|
107
|
+
if
|
|
108
|
+
FileManager.default.fileExists(atPath: outputURL.path),
|
|
109
|
+
let attributes = try? FileManager.default.attributesOfItem(atPath: outputURL.path),
|
|
110
|
+
let fileSize = attributes[.size] as? NSNumber,
|
|
111
|
+
fileSize.intValue > 0
|
|
112
|
+
{
|
|
113
|
+
return outputURL.absoluteString
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let asset = try await resolveAsset(url: url)
|
|
117
|
+
let generator = AVAssetImageGenerator(asset: asset)
|
|
118
|
+
generator.appliesPreferredTrackTransform = true
|
|
119
|
+
generator.maximumSize = CGSize(width: maxDimension, height: maxDimension)
|
|
120
|
+
|
|
121
|
+
let requestedTime = thumbnailTime(for: asset)
|
|
122
|
+
|
|
123
|
+
do {
|
|
124
|
+
let cgImage = try generator.copyCGImage(at: requestedTime, actualTime: nil)
|
|
125
|
+
let image = UIImage(cgImage: cgImage)
|
|
126
|
+
guard let data = image.jpegData(compressionQuality: compressionQuality) else {
|
|
127
|
+
throw thumbnailError(code: 2, message: "Failed to encode JPEG thumbnail for \(url)")
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try data.write(to: outputURL, options: .atomic)
|
|
131
|
+
return outputURL.absoluteString
|
|
132
|
+
} catch {
|
|
133
|
+
throw thumbnailError(error, code: 3, message: "Thumbnail generation failed for \(url)")
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private static func thumbnailCacheDirectory() throws -> URL {
|
|
138
|
+
let outputDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
|
|
139
|
+
.appendingPathComponent(cacheDirectoryName, isDirectory: true)
|
|
140
|
+
try FileManager.default.createDirectory(
|
|
141
|
+
at: outputDirectory,
|
|
142
|
+
withIntermediateDirectories: true
|
|
143
|
+
)
|
|
144
|
+
return outputDirectory
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private static func buildCacheFileName(url: String) -> String {
|
|
148
|
+
let cacheKey = fnv1a64("\(cacheVersion)|\(Int(maxDimension))|\(compressionQuality)|\(url)")
|
|
149
|
+
return "stream-video-thumbnail-\(cacheKey)"
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private static func fnv1a64(_ value: String) -> String {
|
|
153
|
+
var hash: UInt64 = 0xcbf29ce484222325
|
|
154
|
+
let prime: UInt64 = 0x100000001b3
|
|
155
|
+
|
|
156
|
+
for byte in value.utf8 {
|
|
157
|
+
hash ^= UInt64(byte)
|
|
158
|
+
hash &*= prime
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return String(hash, radix: 16)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private static func thumbnailTime(for asset: AVAsset) -> CMTime {
|
|
165
|
+
let durationSeconds = asset.duration.seconds
|
|
166
|
+
if durationSeconds.isFinite, durationSeconds > 0.1 {
|
|
167
|
+
return CMTime(seconds: 0.1, preferredTimescale: 600)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return .zero
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private static func resolveAsset(url: String) async throws -> AVAsset {
|
|
174
|
+
if isPhotoLibraryURL(url) {
|
|
175
|
+
return try await resolvePhotoLibraryAsset(url: url)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if let normalizedURL = normalizeLocalURL(url) {
|
|
179
|
+
return AVURLAsset(url: normalizedURL)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
throw thumbnailError(code: 5, message: "Unsupported video URL for thumbnail generation: \(url)")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private static func isPhotoLibraryURL(_ url: String) -> Bool {
|
|
186
|
+
url.lowercased().hasPrefix("ph://")
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private static func resolvePhotoLibraryAsset(url: String) async throws -> AVAsset {
|
|
190
|
+
let identifier = photoLibraryIdentifier(from: url)
|
|
191
|
+
|
|
192
|
+
guard !identifier.isEmpty else {
|
|
193
|
+
throw thumbnailError(code: 6, message: "Missing photo library identifier for \(url)")
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [identifier], options: nil)
|
|
197
|
+
guard let asset = fetchResult.firstObject else {
|
|
198
|
+
throw thumbnailError(code: 7, message: "Failed to find photo library asset for \(url)")
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let options = PHVideoRequestOptions()
|
|
202
|
+
options.deliveryMode = .highQualityFormat
|
|
203
|
+
options.isNetworkAccessAllowed = true
|
|
204
|
+
options.version = .current
|
|
205
|
+
|
|
206
|
+
return try await withThrowingTaskGroup(of: AVAsset.self) { group in
|
|
207
|
+
group.addTask {
|
|
208
|
+
try await requestPhotoLibraryAsset(url: url, asset: asset, options: options)
|
|
209
|
+
}
|
|
210
|
+
group.addTask {
|
|
211
|
+
try await Task.sleep(nanoseconds: UInt64(photoLibraryAssetResolutionTimeout * 1_000_000_000))
|
|
212
|
+
throw thumbnailError(
|
|
213
|
+
code: 11,
|
|
214
|
+
message: "Timed out resolving photo library asset for \(url)"
|
|
215
|
+
)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
guard let resolvedAsset = try await group.next() else {
|
|
219
|
+
throw thumbnailError(
|
|
220
|
+
code: 10,
|
|
221
|
+
message: "Failed to resolve photo library asset for \(url)"
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
group.cancelAll()
|
|
226
|
+
return resolvedAsset
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private static func requestPhotoLibraryAsset(
|
|
231
|
+
url: String,
|
|
232
|
+
asset: PHAsset,
|
|
233
|
+
options: PHVideoRequestOptions
|
|
234
|
+
) async throws -> AVAsset {
|
|
235
|
+
let imageManager = PHImageManager.default()
|
|
236
|
+
let state = StreamPhotoLibraryAssetRequestState()
|
|
237
|
+
|
|
238
|
+
return try await withTaskCancellationHandler(operation: {
|
|
239
|
+
try await withCheckedThrowingContinuation { continuation in
|
|
240
|
+
let requestID = imageManager.requestAVAsset(forVideo: asset, options: options) {
|
|
241
|
+
avAsset, _, info in
|
|
242
|
+
state.lock.lock()
|
|
243
|
+
if state.didResume {
|
|
244
|
+
state.lock.unlock()
|
|
245
|
+
return
|
|
246
|
+
}
|
|
247
|
+
state.didResume = true
|
|
248
|
+
state.lock.unlock()
|
|
249
|
+
|
|
250
|
+
if let isCancelled = (info?[PHImageCancelledKey] as? NSNumber)?.boolValue, isCancelled {
|
|
251
|
+
continuation.resume(
|
|
252
|
+
throwing: thumbnailError(
|
|
253
|
+
code: 8,
|
|
254
|
+
message: "Photo library asset request was cancelled for \(url)"
|
|
255
|
+
)
|
|
256
|
+
)
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if let error = info?[PHImageErrorKey] as? Error {
|
|
261
|
+
continuation.resume(
|
|
262
|
+
throwing: thumbnailError(
|
|
263
|
+
error,
|
|
264
|
+
code: 9,
|
|
265
|
+
message: "Photo library asset request failed for \(url)"
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
guard let avAsset else {
|
|
272
|
+
continuation.resume(
|
|
273
|
+
throwing: thumbnailError(
|
|
274
|
+
code: 10,
|
|
275
|
+
message: "Failed to resolve photo library asset for \(url)"
|
|
276
|
+
)
|
|
277
|
+
)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
continuation.resume(returning: avAsset)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
state.lock.lock()
|
|
285
|
+
state.requestID = requestID
|
|
286
|
+
state.lock.unlock()
|
|
287
|
+
}
|
|
288
|
+
}, onCancel: {
|
|
289
|
+
state.lock.lock()
|
|
290
|
+
let requestID = state.requestID
|
|
291
|
+
state.lock.unlock()
|
|
292
|
+
|
|
293
|
+
if requestID != PHInvalidImageRequestID {
|
|
294
|
+
imageManager.cancelImageRequest(requestID)
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private static func photoLibraryIdentifier(from url: String) -> String {
|
|
300
|
+
guard let parsedURL = URL(string: url), parsedURL.scheme?.lowercased() == "ph" else {
|
|
301
|
+
return url
|
|
302
|
+
.replacingOccurrences(of: "ph://", with: "", options: [.caseInsensitive])
|
|
303
|
+
.removingPercentEncoding?
|
|
304
|
+
.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let host = parsedURL.host ?? ""
|
|
308
|
+
let path = parsedURL.path
|
|
309
|
+
let combined = host.isEmpty ? path : "\(host)\(path)"
|
|
310
|
+
return combined.removingPercentEncoding?
|
|
311
|
+
.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
private static func normalizeLocalURL(_ url: String) -> URL? {
|
|
315
|
+
if let parsedURL = URL(string: url), let scheme = parsedURL.scheme?.lowercased() {
|
|
316
|
+
if scheme == "file" {
|
|
317
|
+
return parsedURL
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return nil
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return URL(fileURLWithPath: url)
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
private static func thumbnailError(
|
|
327
|
+
_ error: Error? = nil,
|
|
328
|
+
code: Int,
|
|
329
|
+
message: String
|
|
330
|
+
) -> Error {
|
|
331
|
+
let description = error.map { "\(message): \($0.localizedDescription)" } ?? message
|
|
332
|
+
return NSError(
|
|
333
|
+
domain: "StreamVideoThumbnail",
|
|
334
|
+
code: code,
|
|
335
|
+
userInfo: [NSLocalizedDescriptionKey: description]
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "stream-chat-react-native",
|
|
3
3
|
"description": "The official React Native SDK for Stream Chat, a service for building chat applications",
|
|
4
|
-
"version": "9.0.0-beta.
|
|
4
|
+
"version": "9.0.0-beta.30",
|
|
5
5
|
"homepage": "https://www.npmjs.com/package/stream-chat-react-native",
|
|
6
6
|
"author": {
|
|
7
7
|
"company": "Stream.io Inc",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"android/gradle",
|
|
22
22
|
"ios",
|
|
23
23
|
"*.podspec",
|
|
24
|
+
"react-native.config.js",
|
|
24
25
|
"package.json"
|
|
25
26
|
],
|
|
26
27
|
"license": "SEE LICENSE IN LICENSE",
|
|
@@ -29,7 +30,7 @@
|
|
|
29
30
|
"dependencies": {
|
|
30
31
|
"es6-symbol": "^3.1.3",
|
|
31
32
|
"mime": "^4.0.7",
|
|
32
|
-
"stream-chat-react-native-core": "9.0.0-beta.
|
|
33
|
+
"stream-chat-react-native-core": "9.0.0-beta.30"
|
|
33
34
|
},
|
|
34
35
|
"peerDependencies": {
|
|
35
36
|
"@react-native-camera-roll/camera-roll": ">=7.8.0",
|
|
@@ -81,7 +82,6 @@
|
|
|
81
82
|
}
|
|
82
83
|
},
|
|
83
84
|
"scripts": {
|
|
84
|
-
"postinstall": "if [ -f ../scripts/sync-shared-native.sh ] && [ -d ../shared-native/ios ]; then bash ../scripts/sync-shared-native.sh native-package; fi",
|
|
85
85
|
"prepack": "bash ../scripts/sync-shared-native.sh native-package && cp ../../README.md .",
|
|
86
86
|
"postpack": "rm README.md && bash ../scripts/clean-shared-native-copies.sh native-package"
|
|
87
87
|
},
|
|
@@ -92,7 +92,14 @@
|
|
|
92
92
|
"name": "StreamChatReactNativeSpec",
|
|
93
93
|
"type": "all",
|
|
94
94
|
"jsSrcsDir": "src/native",
|
|
95
|
+
"android": {
|
|
96
|
+
"javaPackageName": "com.streamchatreactnative"
|
|
97
|
+
},
|
|
95
98
|
"ios": {
|
|
99
|
+
"modulesProvider": {
|
|
100
|
+
"StreamChatReactNative": "StreamChatReactNative",
|
|
101
|
+
"StreamVideoThumbnail": "StreamVideoThumbnail"
|
|
102
|
+
},
|
|
96
103
|
"componentProvider": {
|
|
97
104
|
"StreamShimmerView": "StreamShimmerViewComponentView"
|
|
98
105
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module.exports = {
|
|
2
|
+
dependency: {
|
|
3
|
+
platforms: {
|
|
4
|
+
android: {
|
|
5
|
+
packageImportPath: 'import com.streamchatreactnative.StreamChatReactNativePackage;',
|
|
6
|
+
packageInstance: 'new StreamChatReactNativePackage()',
|
|
7
|
+
},
|
|
8
|
+
ios: {
|
|
9
|
+
podspecPath: 'stream-chat-react-native.podspec',
|
|
10
|
+
},
|
|
11
|
+
},
|
|
12
|
+
},
|
|
13
|
+
};
|