stream-chat-expo 9.0.0-beta.8 → 9.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/android/build.gradle +30 -0
- package/android/src/main/java/com/streamchatexpo/StreamChatExpoPackage.java +33 -0
- package/android/src/main/java/com/streamchatreactnative/shared/StreamVideoThumbnailGenerator.kt +159 -0
- package/android/src/newarch/com/streamchatexpo/StreamVideoThumbnailModule.kt +50 -0
- package/ios/shared/StreamVideoThumbnail.h +14 -0
- package/ios/shared/StreamVideoThumbnail.mm +56 -0
- package/ios/shared/StreamVideoThumbnailGenerator.swift +338 -0
- package/package.json +12 -4
- package/src/native/NativeStreamVideoThumbnail.ts +14 -0
- package/src/native/videoThumbnail.ts +8 -0
- package/src/optionalDependencies/AudioVideo.ts +3 -5
- package/src/optionalDependencies/Sound.ts +2 -2
- package/src/optionalDependencies/Video.tsx +3 -31
- package/src/optionalDependencies/generateThumbnail.ts +9 -0
- package/src/optionalDependencies/getPhotos.ts +29 -11
- package/src/optionalDependencies/index.ts +1 -0
- package/src/optionalDependencies/pickImage.ts +34 -9
- package/src/optionalDependencies/takePhoto.ts +6 -0
package/android/build.gradle
CHANGED
|
@@ -137,3 +137,33 @@ if (isNewArchitectureEnabled()) {
|
|
|
137
137
|
codegenJavaPackageName = "com.streamchatexpo"
|
|
138
138
|
}
|
|
139
139
|
}
|
|
140
|
+
|
|
141
|
+
if (isNewArchitectureEnabled()) {
|
|
142
|
+
gradle.projectsEvaluated {
|
|
143
|
+
if (rootProject.ext.has("streamChatReactNativeCodegenHookInstalled")) {
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def androidAppProject = rootProject.subprojects.find { it.plugins.hasPlugin("com.android.application") }
|
|
148
|
+
if (androidAppProject == null) {
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
def dependencyCodegenTasks = rootProject.subprojects
|
|
153
|
+
.findAll { it != androidAppProject }
|
|
154
|
+
.collect { it.tasks.findByName("generateCodegenArtifactsFromSchema") }
|
|
155
|
+
.findAll { it != null }
|
|
156
|
+
|
|
157
|
+
if (dependencyCodegenTasks.isEmpty()) {
|
|
158
|
+
return
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
rootProject.ext.set("streamChatReactNativeCodegenHookInstalled", true)
|
|
162
|
+
|
|
163
|
+
androidAppProject.tasks.matching { task ->
|
|
164
|
+
task.name.startsWith("configureCMake")
|
|
165
|
+
}.configureEach {
|
|
166
|
+
dependsOn(dependencyCodegenTasks)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -14,9 +14,15 @@ import java.util.List;
|
|
|
14
14
|
import java.util.Map;
|
|
15
15
|
|
|
16
16
|
public class StreamChatExpoPackage extends TurboReactPackage {
|
|
17
|
+
private static final String STREAM_VIDEO_THUMBNAIL_MODULE = "StreamVideoThumbnail";
|
|
18
|
+
|
|
17
19
|
@Nullable
|
|
18
20
|
@Override
|
|
19
21
|
public NativeModule getModule(String name, ReactApplicationContext reactContext) {
|
|
22
|
+
if (name.equals(STREAM_VIDEO_THUMBNAIL_MODULE) && BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
|
|
23
|
+
return createNewArchModule("com.streamchatexpo.StreamVideoThumbnailModule", reactContext);
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
return null;
|
|
21
27
|
}
|
|
22
28
|
|
|
@@ -24,6 +30,18 @@ public class StreamChatExpoPackage extends TurboReactPackage {
|
|
|
24
30
|
public ReactModuleInfoProvider getReactModuleInfoProvider() {
|
|
25
31
|
return () -> {
|
|
26
32
|
final Map<String, ReactModuleInfo> moduleInfos = new HashMap<>();
|
|
33
|
+
boolean isTurboModule = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED;
|
|
34
|
+
moduleInfos.put(
|
|
35
|
+
STREAM_VIDEO_THUMBNAIL_MODULE,
|
|
36
|
+
new ReactModuleInfo(
|
|
37
|
+
STREAM_VIDEO_THUMBNAIL_MODULE,
|
|
38
|
+
STREAM_VIDEO_THUMBNAIL_MODULE,
|
|
39
|
+
false, // canOverrideExistingModule
|
|
40
|
+
false, // needsEagerInit
|
|
41
|
+
false, // hasConstants
|
|
42
|
+
false, // isCxxModule
|
|
43
|
+
isTurboModule // isTurboModule
|
|
44
|
+
));
|
|
27
45
|
return moduleInfos;
|
|
28
46
|
};
|
|
29
47
|
}
|
|
@@ -32,4 +50,19 @@ public class StreamChatExpoPackage extends TurboReactPackage {
|
|
|
32
50
|
public List<ViewManager> createViewManagers(ReactApplicationContext reactContext) {
|
|
33
51
|
return Collections.<ViewManager>singletonList(new StreamShimmerViewManager());
|
|
34
52
|
}
|
|
53
|
+
|
|
54
|
+
@Nullable
|
|
55
|
+
private NativeModule createNewArchModule(
|
|
56
|
+
String className,
|
|
57
|
+
ReactApplicationContext reactContext
|
|
58
|
+
) {
|
|
59
|
+
try {
|
|
60
|
+
Class<?> moduleClass = Class.forName(className);
|
|
61
|
+
return (NativeModule) moduleClass
|
|
62
|
+
.getConstructor(ReactApplicationContext.class)
|
|
63
|
+
.newInstance(reactContext);
|
|
64
|
+
} catch (Throwable ignored) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
35
68
|
}
|
package/android/src/main/java/com/streamchatreactnative/shared/StreamVideoThumbnailGenerator.kt
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
package com.streamchatreactnative.shared
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.graphics.Bitmap
|
|
5
|
+
import android.media.MediaMetadataRetriever
|
|
6
|
+
import android.net.Uri
|
|
7
|
+
import android.os.Build
|
|
8
|
+
import java.io.File
|
|
9
|
+
import java.io.FileOutputStream
|
|
10
|
+
import java.util.concurrent.Executors
|
|
11
|
+
|
|
12
|
+
data class StreamVideoThumbnailResult(
|
|
13
|
+
val error: String? = null,
|
|
14
|
+
val uri: String? = null,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
object StreamVideoThumbnailGenerator {
|
|
18
|
+
private const val DEFAULT_COMPRESSION_QUALITY = 80
|
|
19
|
+
private const val DEFAULT_MAX_DIMENSION = 512
|
|
20
|
+
private const val CACHE_VERSION = "v1"
|
|
21
|
+
private const val CACHE_DIRECTORY_NAME = "@stream-io-stream-video-thumbnails"
|
|
22
|
+
private const val MAX_CONCURRENT_GENERATIONS = 5
|
|
23
|
+
|
|
24
|
+
fun generateThumbnails(context: Context, urls: List<String>): List<StreamVideoThumbnailResult> {
|
|
25
|
+
if (urls.size <= 1) {
|
|
26
|
+
return urls.map { url -> generateThumbnailResult(context, url) }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
val parallelism = minOf(urls.size, MAX_CONCURRENT_GENERATIONS)
|
|
30
|
+
val executor = Executors.newFixedThreadPool(parallelism)
|
|
31
|
+
|
|
32
|
+
return try {
|
|
33
|
+
val tasks = urls.map { url ->
|
|
34
|
+
executor.submit<StreamVideoThumbnailResult> {
|
|
35
|
+
generateThumbnailResult(context, url)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
tasks.map { task -> task.get() }
|
|
39
|
+
} finally {
|
|
40
|
+
executor.shutdown()
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private fun generateThumbnailResult(context: Context, url: String): StreamVideoThumbnailResult {
|
|
45
|
+
return try {
|
|
46
|
+
StreamVideoThumbnailResult(uri = generateThumbnail(context, url))
|
|
47
|
+
} catch (error: Throwable) {
|
|
48
|
+
StreamVideoThumbnailResult(
|
|
49
|
+
error = error.message ?: "Thumbnail generation failed for $url",
|
|
50
|
+
uri = null,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private fun generateThumbnail(context: Context, url: String): String {
|
|
56
|
+
val outputDirectory = File(context.cacheDir, CACHE_DIRECTORY_NAME).apply { mkdirs() }
|
|
57
|
+
val outputFile = File(outputDirectory, buildCacheFileName(url))
|
|
58
|
+
|
|
59
|
+
if (outputFile.isFile() && outputFile.length() > 0L) {
|
|
60
|
+
return Uri.fromFile(outputFile).toString()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
val retriever = MediaMetadataRetriever()
|
|
64
|
+
|
|
65
|
+
return try {
|
|
66
|
+
setDataSource(retriever, context, url)
|
|
67
|
+
val thumbnail = extractThumbnailFrame(retriever, url)
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
FileOutputStream(outputFile).use { stream ->
|
|
71
|
+
thumbnail.compress(Bitmap.CompressFormat.JPEG, DEFAULT_COMPRESSION_QUALITY, stream)
|
|
72
|
+
}
|
|
73
|
+
} finally {
|
|
74
|
+
if (!thumbnail.isRecycled) {
|
|
75
|
+
thumbnail.recycle()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
Uri.fromFile(outputFile).toString()
|
|
80
|
+
} catch (error: Throwable) {
|
|
81
|
+
throw IllegalStateException("Thumbnail generation failed for $url", error)
|
|
82
|
+
} finally {
|
|
83
|
+
try {
|
|
84
|
+
retriever.release()
|
|
85
|
+
} catch (_: Throwable) {
|
|
86
|
+
// Ignore cleanup failures.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private fun extractThumbnailFrame(retriever: MediaMetadataRetriever, url: String): Bitmap {
|
|
92
|
+
if (Build.VERSION.SDK_INT >= 27) {
|
|
93
|
+
return retriever.getScaledFrameAtTime(
|
|
94
|
+
100000,
|
|
95
|
+
MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
|
|
96
|
+
DEFAULT_MAX_DIMENSION,
|
|
97
|
+
DEFAULT_MAX_DIMENSION,
|
|
98
|
+
) ?: throw IllegalStateException("Failed to extract video frame for $url")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
val frame =
|
|
102
|
+
retriever.getFrameAtTime(100000, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
|
|
103
|
+
?: throw IllegalStateException("Failed to extract video frame for $url")
|
|
104
|
+
val scaledFrame = scaleBitmap(frame)
|
|
105
|
+
|
|
106
|
+
if (scaledFrame != frame) {
|
|
107
|
+
frame.recycle()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return scaledFrame
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
private fun buildCacheFileName(url: String): String {
|
|
114
|
+
val cacheKey =
|
|
115
|
+
fnv1a64("$CACHE_VERSION|$DEFAULT_MAX_DIMENSION|$DEFAULT_COMPRESSION_QUALITY|$url")
|
|
116
|
+
return "stream-video-thumbnail-$cacheKey.jpg"
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private fun fnv1a64(value: String): String {
|
|
120
|
+
var hash = -0x340d631b8c46751fL
|
|
121
|
+
|
|
122
|
+
value.toByteArray(Charsets.UTF_8).forEach { byte ->
|
|
123
|
+
hash = hash xor (byte.toLong() and 0xff)
|
|
124
|
+
hash *= 0x100000001b3L
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return java.lang.Long.toUnsignedString(hash, 16)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private fun setDataSource(retriever: MediaMetadataRetriever, context: Context, url: String) {
|
|
131
|
+
val uri = Uri.parse(url)
|
|
132
|
+
val scheme = uri.scheme?.lowercase()
|
|
133
|
+
|
|
134
|
+
when {
|
|
135
|
+
scheme.isNullOrEmpty() -> retriever.setDataSource(url)
|
|
136
|
+
scheme == "content" || scheme == "file" -> retriever.setDataSource(context, uri)
|
|
137
|
+
else ->
|
|
138
|
+
throw IllegalArgumentException(
|
|
139
|
+
"Unsupported video URI scheme for thumbnail generation: $scheme. Local assets only.",
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private fun scaleBitmap(bitmap: Bitmap): Bitmap {
|
|
145
|
+
val width = bitmap.width
|
|
146
|
+
val height = bitmap.height
|
|
147
|
+
val largestDimension = maxOf(width, height)
|
|
148
|
+
|
|
149
|
+
if (largestDimension <= DEFAULT_MAX_DIMENSION) {
|
|
150
|
+
return bitmap
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
val scale = DEFAULT_MAX_DIMENSION.toFloat() / largestDimension.toFloat()
|
|
154
|
+
val targetWidth = (width * scale).toInt().coerceAtLeast(1)
|
|
155
|
+
val targetHeight = (height * scale).toInt().coerceAtLeast(1)
|
|
156
|
+
|
|
157
|
+
return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
package com.streamchatexpo
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Arguments
|
|
4
|
+
import com.facebook.react.bridge.Promise
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.bridge.ReadableArray
|
|
7
|
+
import com.streamchatreactnative.shared.StreamVideoThumbnailGenerator
|
|
8
|
+
import java.util.concurrent.Executors
|
|
9
|
+
|
|
10
|
+
class StreamVideoThumbnailModule(
|
|
11
|
+
reactContext: ReactApplicationContext,
|
|
12
|
+
) : NativeStreamVideoThumbnailSpec(reactContext) {
|
|
13
|
+
override fun getName(): String = NAME
|
|
14
|
+
|
|
15
|
+
override fun createVideoThumbnails(urls: ReadableArray, promise: Promise) {
|
|
16
|
+
val urlList = mutableListOf<String>()
|
|
17
|
+
for (index in 0 until urls.size()) {
|
|
18
|
+
urlList.add(urls.getString(index) ?: "")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
executor.execute {
|
|
22
|
+
try {
|
|
23
|
+
val thumbnails = StreamVideoThumbnailGenerator.generateThumbnails(reactApplicationContext, urlList)
|
|
24
|
+
val result = Arguments.createArray()
|
|
25
|
+
thumbnails.forEach { thumbnail ->
|
|
26
|
+
val thumbnailMap = Arguments.createMap()
|
|
27
|
+
if (thumbnail.uri != null) {
|
|
28
|
+
thumbnailMap.putString("uri", thumbnail.uri)
|
|
29
|
+
} else {
|
|
30
|
+
thumbnailMap.putNull("uri")
|
|
31
|
+
}
|
|
32
|
+
if (thumbnail.error != null) {
|
|
33
|
+
thumbnailMap.putString("error", thumbnail.error)
|
|
34
|
+
} else {
|
|
35
|
+
thumbnailMap.putNull("error")
|
|
36
|
+
}
|
|
37
|
+
result.pushMap(thumbnailMap)
|
|
38
|
+
}
|
|
39
|
+
promise.resolve(result)
|
|
40
|
+
} catch (error: Throwable) {
|
|
41
|
+
promise.reject("stream_video_thumbnail_error", error.message, error)
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
companion object {
|
|
47
|
+
const val NAME = "StreamVideoThumbnail"
|
|
48
|
+
private val executor = Executors.newCachedThreadPool()
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -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-expo",
|
|
3
3
|
"description": "The official Expo SDK for Stream Chat, a service for building chat applications",
|
|
4
|
-
"version": "9.0.0
|
|
4
|
+
"version": "9.0.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"company": "Stream.io Inc",
|
|
7
7
|
"name": "Stream.io Inc"
|
|
@@ -27,10 +27,10 @@
|
|
|
27
27
|
"types": "types/index.d.ts",
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"mime": "^4.0.7",
|
|
30
|
-
"stream-chat-react-native-core": "9.0.0
|
|
30
|
+
"stream-chat-react-native-core": "9.0.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
|
-
"expo": ">=
|
|
33
|
+
"expo": ">=52.0.0",
|
|
34
34
|
"react-native": ">=0.76.0",
|
|
35
35
|
"expo-av": "*",
|
|
36
36
|
"expo-clipboard": "*",
|
|
@@ -69,6 +69,9 @@
|
|
|
69
69
|
"expo-image-picker": {
|
|
70
70
|
"optional": true
|
|
71
71
|
},
|
|
72
|
+
"expo-image-manipulator": {
|
|
73
|
+
"optional": true
|
|
74
|
+
},
|
|
72
75
|
"expo-sharing": {
|
|
73
76
|
"optional": true
|
|
74
77
|
},
|
|
@@ -82,7 +85,6 @@
|
|
|
82
85
|
"expo-audio": "~0.4.6"
|
|
83
86
|
},
|
|
84
87
|
"scripts": {
|
|
85
|
-
"postinstall": "if [ -f ../scripts/sync-shared-native.sh ] && [ -d ../shared-native/ios ]; then bash ../scripts/sync-shared-native.sh expo-package; fi",
|
|
86
88
|
"prepack": "bash ../scripts/sync-shared-native.sh expo-package && cp ../../README.md .",
|
|
87
89
|
"postpack": "rm README.md && bash ../scripts/clean-shared-native-copies.sh expo-package"
|
|
88
90
|
},
|
|
@@ -90,7 +92,13 @@
|
|
|
90
92
|
"name": "StreamChatExpoSpec",
|
|
91
93
|
"type": "all",
|
|
92
94
|
"jsSrcsDir": "src/native",
|
|
95
|
+
"android": {
|
|
96
|
+
"javaPackageName": "com.streamchatexpo"
|
|
97
|
+
},
|
|
93
98
|
"ios": {
|
|
99
|
+
"modulesProvider": {
|
|
100
|
+
"StreamVideoThumbnail": "StreamVideoThumbnail"
|
|
101
|
+
},
|
|
94
102
|
"componentProvider": {
|
|
95
103
|
"StreamShimmerView": "StreamShimmerViewComponentView"
|
|
96
104
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { TurboModule } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { TurboModuleRegistry } from 'react-native';
|
|
4
|
+
|
|
5
|
+
export type VideoThumbnailResult = {
|
|
6
|
+
error?: string | null;
|
|
7
|
+
uri?: string | null;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export interface Spec extends TurboModule {
|
|
11
|
+
createVideoThumbnails(urls: ReadonlyArray<string>): Promise<ReadonlyArray<VideoThumbnailResult>>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default TurboModuleRegistry.getEnforcing<Spec>('StreamVideoThumbnail');
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import NativeStreamVideoThumbnail, { type VideoThumbnailResult } from './NativeStreamVideoThumbnail';
|
|
2
|
+
|
|
3
|
+
export type { VideoThumbnailResult } from './NativeStreamVideoThumbnail';
|
|
4
|
+
|
|
5
|
+
export const createVideoThumbnails = async (urls: string[]): Promise<VideoThumbnailResult[]> => {
|
|
6
|
+
const results = await NativeStreamVideoThumbnail.createVideoThumbnails(urls);
|
|
7
|
+
return Array.from(results);
|
|
8
|
+
};
|
|
@@ -1,20 +1,18 @@
|
|
|
1
1
|
let AudioComponent;
|
|
2
|
-
let VideoComponent;
|
|
3
2
|
let RecordingObject;
|
|
4
3
|
|
|
5
4
|
try {
|
|
6
5
|
const audioVideoPackage = require('expo-av');
|
|
7
6
|
AudioComponent = audioVideoPackage.Audio;
|
|
8
|
-
VideoComponent = audioVideoPackage.Video;
|
|
9
7
|
RecordingObject = audioVideoPackage.RecordingObject;
|
|
10
8
|
} catch (e) {
|
|
11
9
|
// do nothing
|
|
12
10
|
}
|
|
13
11
|
|
|
14
|
-
if (!AudioComponent
|
|
12
|
+
if (!AudioComponent) {
|
|
15
13
|
console.log(
|
|
16
|
-
'
|
|
14
|
+
'The audio library is currently not installed. To allow in-app audio playback, install the "expo-av" package.',
|
|
17
15
|
);
|
|
18
16
|
}
|
|
19
17
|
|
|
20
|
-
export { AudioComponent, RecordingObject
|
|
18
|
+
export { AudioComponent, RecordingObject };
|
|
@@ -138,8 +138,8 @@ class ExpoAudioSoundAdapter {
|
|
|
138
138
|
};
|
|
139
139
|
|
|
140
140
|
// eslint-disable-next-line require-await
|
|
141
|
-
setPositionAsync: SoundReturnType['setPositionAsync'] = async (
|
|
142
|
-
this.player.seekTo(
|
|
141
|
+
setPositionAsync: SoundReturnType['setPositionAsync'] = async (millis) => {
|
|
142
|
+
await this.player.seekTo(millis / 1000);
|
|
143
143
|
};
|
|
144
144
|
|
|
145
145
|
// eslint-disable-next-line require-await
|
|
@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
|
|
|
2
2
|
|
|
3
3
|
import { useEventListener } from 'expo';
|
|
4
4
|
|
|
5
|
-
import { AudioComponent
|
|
5
|
+
import { AudioComponent } from './AudioVideo';
|
|
6
6
|
|
|
7
7
|
let videoPackage;
|
|
8
8
|
|
|
@@ -18,8 +18,8 @@ if (!videoPackage) {
|
|
|
18
18
|
);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
const VideoComponent = videoPackage
|
|
22
|
-
const useVideoPlayer = videoPackage
|
|
21
|
+
const VideoComponent = videoPackage?.VideoView;
|
|
22
|
+
const useVideoPlayer = videoPackage?.useVideoPlayer;
|
|
23
23
|
|
|
24
24
|
let Video = null;
|
|
25
25
|
|
|
@@ -84,33 +84,5 @@ if (videoPackage) {
|
|
|
84
84
|
);
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
-
// expo-av
|
|
88
|
-
else if (ExpoAVVideoComponent) {
|
|
89
|
-
Video = ({ onPlaybackStatusUpdate, paused, resizeMode, style, uri, videoRef, rate }) => {
|
|
90
|
-
// This is done so that the audio of the video is not muted when the phone is in silent mode for iOS.
|
|
91
|
-
useEffect(() => {
|
|
92
|
-
const initializeSound = async () => {
|
|
93
|
-
await AudioComponent.setAudioModeAsync({
|
|
94
|
-
playsInSilentModeIOS: true,
|
|
95
|
-
});
|
|
96
|
-
};
|
|
97
|
-
initializeSound();
|
|
98
|
-
}, []);
|
|
99
|
-
|
|
100
|
-
return (
|
|
101
|
-
<VideoComponent
|
|
102
|
-
onPlaybackStatusUpdate={onPlaybackStatusUpdate}
|
|
103
|
-
ref={videoRef}
|
|
104
|
-
resizeMode={resizeMode}
|
|
105
|
-
shouldPlay={!paused}
|
|
106
|
-
source={{
|
|
107
|
-
uri,
|
|
108
|
-
}}
|
|
109
|
-
style={[style]}
|
|
110
|
-
playbackRate={rate}
|
|
111
|
-
/>
|
|
112
|
-
);
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
87
|
|
|
116
88
|
export { Video };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createGenerateVideoThumbnails } from 'stream-chat-react-native-core/src/utils/createGenerateVideoThumbnails';
|
|
2
|
+
|
|
3
|
+
import { createVideoThumbnails, type VideoThumbnailResult } from '../native/videoThumbnail';
|
|
4
|
+
|
|
5
|
+
export const generateThumbnails: (
|
|
6
|
+
uris: string[],
|
|
7
|
+
) => Promise<Record<string, VideoThumbnailResult>> = createGenerateVideoThumbnails({
|
|
8
|
+
createVideoThumbnails,
|
|
9
|
+
});
|
|
@@ -4,6 +4,9 @@ import mime from 'mime';
|
|
|
4
4
|
|
|
5
5
|
import type { File } from 'stream-chat-react-native-core';
|
|
6
6
|
|
|
7
|
+
import { generateThumbnails } from './generateThumbnail';
|
|
8
|
+
import { getLocalAssetUri } from './getLocalAssetUri';
|
|
9
|
+
|
|
7
10
|
let MediaLibrary;
|
|
8
11
|
|
|
9
12
|
try {
|
|
@@ -18,8 +21,6 @@ if (!MediaLibrary) {
|
|
|
18
21
|
);
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
import { getLocalAssetUri } from './getLocalAssetUri';
|
|
22
|
-
|
|
23
24
|
type ReturnType = {
|
|
24
25
|
assets: File[];
|
|
25
26
|
endCursor: string | undefined;
|
|
@@ -52,24 +53,41 @@ export const getPhotos = MediaLibrary
|
|
|
52
53
|
mediaType: [MediaLibrary.MediaType.photo, MediaLibrary.MediaType.video],
|
|
53
54
|
sortBy: [MediaLibrary.SortBy.modificationTime],
|
|
54
55
|
});
|
|
55
|
-
const
|
|
56
|
+
const assetEntries = await Promise.all(
|
|
56
57
|
results.assets.map(async (asset) => {
|
|
57
58
|
const localUri = await getLocalAssetUri(asset.id);
|
|
58
59
|
const mimeType =
|
|
59
60
|
mime.getType(asset.filename || asset.uri) ||
|
|
60
61
|
(asset.mediaType === MediaLibrary.MediaType.video ? 'video/*' : 'image/*');
|
|
62
|
+
const uri = localUri || asset.uri;
|
|
63
|
+
|
|
61
64
|
return {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
thumb_url: asset.mediaType === 'photo' ? undefined : asset.uri,
|
|
67
|
-
type: mimeType,
|
|
68
|
-
uri: localUri || asset.uri,
|
|
69
|
-
width: asset.width,
|
|
65
|
+
asset,
|
|
66
|
+
isVideo: asset.mediaType === MediaLibrary.MediaType.video,
|
|
67
|
+
mimeType,
|
|
68
|
+
uri,
|
|
70
69
|
};
|
|
71
70
|
}),
|
|
72
71
|
);
|
|
72
|
+
const videoUris = assetEntries
|
|
73
|
+
.filter(({ isVideo, uri }) => isVideo && !!uri)
|
|
74
|
+
.map(({ uri }) => uri);
|
|
75
|
+
const videoThumbnailResults = await generateThumbnails(videoUris);
|
|
76
|
+
|
|
77
|
+
const assets = assetEntries.map(({ asset, isVideo, mimeType, uri }) => {
|
|
78
|
+
const thumbnailResult = isVideo && uri ? videoThumbnailResults[uri] : undefined;
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
duration: asset.duration * 1000,
|
|
82
|
+
height: asset.height,
|
|
83
|
+
name: asset.filename,
|
|
84
|
+
size: 0,
|
|
85
|
+
thumb_url: thumbnailResult?.uri || undefined,
|
|
86
|
+
type: mimeType,
|
|
87
|
+
uri,
|
|
88
|
+
width: asset.width,
|
|
89
|
+
};
|
|
90
|
+
});
|
|
73
91
|
|
|
74
92
|
const hasNextPage = results.hasNextPage;
|
|
75
93
|
const endCursor = results.endCursor;
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { Platform } from 'react-native';
|
|
2
|
+
|
|
2
3
|
import mime from 'mime';
|
|
4
|
+
|
|
3
5
|
import { PickImageOptions } from 'stream-chat-react-native-core';
|
|
6
|
+
|
|
7
|
+
import { generateThumbnails } from './generateThumbnail';
|
|
8
|
+
|
|
4
9
|
let ImagePicker;
|
|
5
10
|
|
|
6
11
|
try {
|
|
@@ -43,17 +48,37 @@ export const pickImage = ImagePicker
|
|
|
43
48
|
const canceled = result.canceled;
|
|
44
49
|
|
|
45
50
|
if (!canceled) {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
duration: asset.duration,
|
|
49
|
-
name: asset.fileName,
|
|
50
|
-
size: asset.fileSize,
|
|
51
|
-
type:
|
|
51
|
+
const assetsWithType = result.assets.map((asset) => {
|
|
52
|
+
const type =
|
|
52
53
|
asset.mimeType ||
|
|
53
54
|
mime.getType(asset.fileName || asset.uri) ||
|
|
54
|
-
(asset.duration ? 'video/*' : 'image/*')
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
(asset.duration ? 'video/*' : 'image/*');
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
asset,
|
|
59
|
+
isVideo: type.includes('video'),
|
|
60
|
+
type,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
const videoUris = assetsWithType
|
|
64
|
+
.filter(({ asset, isVideo }) => isVideo && !!asset.uri)
|
|
65
|
+
.map(({ asset }) => asset.uri);
|
|
66
|
+
const videoThumbnailResults = await generateThumbnails(videoUris);
|
|
67
|
+
|
|
68
|
+
const assets = assetsWithType.map(({ asset, isVideo, type }) => {
|
|
69
|
+
const thumbnailResult =
|
|
70
|
+
isVideo && asset.uri ? videoThumbnailResults[asset.uri] : undefined;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
...asset,
|
|
74
|
+
duration: asset.duration,
|
|
75
|
+
name: asset.fileName,
|
|
76
|
+
size: asset.fileSize,
|
|
77
|
+
thumb_url: thumbnailResult?.uri || undefined,
|
|
78
|
+
type,
|
|
79
|
+
uri: asset.uri,
|
|
80
|
+
};
|
|
81
|
+
});
|
|
57
82
|
return { assets, cancelled: false };
|
|
58
83
|
} else {
|
|
59
84
|
return { cancelled: true };
|
|
@@ -2,6 +2,8 @@ import { Image, Platform } from 'react-native';
|
|
|
2
2
|
|
|
3
3
|
import mime from 'mime';
|
|
4
4
|
|
|
5
|
+
import { generateThumbnails } from './generateThumbnail';
|
|
6
|
+
|
|
5
7
|
let ImagePicker;
|
|
6
8
|
|
|
7
9
|
try {
|
|
@@ -61,12 +63,16 @@ export const takePhoto = ImagePicker
|
|
|
61
63
|
if (mimeType.includes('video')) {
|
|
62
64
|
const clearFilter = new RegExp('[.:]', 'g');
|
|
63
65
|
const date = new Date().toISOString().replace(clearFilter, '_');
|
|
66
|
+
const thumbnailResults = await generateThumbnails([photo.uri]);
|
|
67
|
+
const thumbnailResult = thumbnailResults[photo.uri];
|
|
68
|
+
|
|
64
69
|
return {
|
|
65
70
|
...photo,
|
|
66
71
|
cancelled: false,
|
|
67
72
|
duration: photo.duration, // in milliseconds
|
|
68
73
|
name: 'video_recording_' + date + '.' + photo.uri.split('.').pop(),
|
|
69
74
|
size: photo.fileSize,
|
|
75
|
+
thumb_url: thumbnailResult?.uri || undefined,
|
|
70
76
|
type: mimeType,
|
|
71
77
|
uri: photo.uri,
|
|
72
78
|
};
|