MM-54866 - Calls: Transcription support (#7703)

* captions on videos from posts and searches

* add the patch for react-native-video which fixes subtitle downloading

* improve spacing

* fix patch file

* upgrade compass-icons; use cc icon

* revert patch overwrite

* fix patch

* use useMemo

* fix hitslops on pressables

* use new Caption format; refactor for clarity

* simplify tracks creation and use
This commit is contained in:
Christopher Poile 2023-12-21 16:20:21 -05:00 committed by GitHub
parent 9ac0069e5c
commit 2c1f318868
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 335 additions and 31 deletions

View file

@ -1,6 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {getPosts} from '@actions/local/post';
import {SYSTEM_IDENTIFIERS} from '@constants/database';
import DatabaseManager from '@database/manager';
import NetworkManager from '@managers/network_manager';
@ -17,6 +18,7 @@ import {fetchPostAuthors, fetchMissingChannelsFromPosts} from './post';
import {forceLogoutIfNecessary} from './session';
import type Model from '@nozbe/watermelondb/Model';
import type PostModel from '@typings/database/models/servers/post';
export async function fetchRecentMentions(serverUrl: string): Promise<PostSearchRequest> {
try {
@ -132,13 +134,27 @@ export const searchFiles = async (serverUrl: string, teamId: string, params: Fil
const client = NetworkManager.getClient(serverUrl);
const result = await client.searchFiles(teamId, params.terms);
const files = result?.file_infos ? Object.values(result.file_infos) : [];
const allChannelIds = files.reduce<string[]>((acc, f) => {
const [allChannelIds, allPostIds] = files.reduce<[Set<string>, Set<string>]>((acc, f) => {
if (f.channel_id) {
acc.push(f.channel_id);
acc[0].add(f.channel_id);
}
if (f.post_id) {
acc[1].add(f.post_id);
}
return acc;
}, []);
const channels = [...new Set(allChannelIds)];
}, [new Set<string>(), new Set<string>()]);
const channels = Array.from(allChannelIds.values());
// Attach the file's post's props to the FileInfo (needed for captioning videos)
const postIds = Array.from(allPostIds);
const posts = await getPosts(serverUrl, postIds);
const idToPost = posts.reduce<Dictionary<PostModel>>((acc, p) => {
acc[p.id] = p;
return acc;
}, {});
files.forEach((f) => {
f.postProps = idToPost[f.post_id]?.props;
});
return {files, channels};
} catch (error) {
logDebug('error on searchFiles', getFullErrorMessage(error));

View file

@ -24,6 +24,7 @@ type FilesProps = {
location: string;
isReplyPost: boolean;
postId: string;
postProps: Record<string, any>;
publicLinkEnabled: boolean;
}
@ -48,7 +49,7 @@ const styles = StyleSheet.create({
},
});
const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, location, postId, publicLinkEnabled}: FilesProps) => {
const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, location, postId, postProps, publicLinkEnabled}: FilesProps) => {
const galleryIdentifier = `${postId}-fileAttachments-${location}`;
const [inViewPort, setInViewPort] = useState(false);
const isTablet = useIsTablet();
@ -63,7 +64,7 @@ const Files = ({canDownloadFiles, failed, filesInfo, isReplyPost, layoutWidth, l
};
const handlePreviewPress = preventDoubleTap((idx: number) => {
const items = filesForGallery.value.map((f) => fileToGalleryItem(f, f.user_id));
const items = filesForGallery.value.map((f) => fileToGalleryItem(f, f.user_id, postProps));
openGalleryAtIndex(galleryIdentifier, idx, items);
});

View file

@ -45,6 +45,7 @@ const enhance = withObservables(['post'], ({database, post}: EnhanceProps) => {
return {
canDownloadFiles: observeCanDownloadFiles(database),
postId: of$(post.id),
postProps: of$(post.props),
publicLinkEnabled,
filesInfo,
};

View file

@ -0,0 +1,6 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {createContext} from 'react';
export const CaptionsEnabledContext = createContext<boolean[]>([]);

View file

@ -193,3 +193,15 @@ export type CallsVersion = {
version?: string;
build?: string;
};
export type SubtitleTrack = {
title?: string | undefined;
language?: string | undefined;
type: 'application/x-subrip' | 'application/ttml+xml' | 'text/vtt';
uri: string;
};
export type SelectedSubtitleTrack = {
type: 'system' | 'disabled' | 'title' | 'language' | 'index';
value?: string | number | undefined;
};

View file

@ -3,14 +3,16 @@
import {makeCallsBaseAndBadgeRGB, rgbToCSS} from '@mattermost/calls';
import {Alert} from 'react-native';
import {TextTrackType} from 'react-native-video';
import {buildFileUrl} from '@actions/remote/file';
import {Calls, Post} from '@constants';
import {NOTIFICATION_SUB_TYPE} from '@constants/push_notification';
import {isMinimumServerVersion} from '@utils/helpers';
import {displayUsername} from '@utils/user';
import type {CallSession, CallsTheme, CallsVersion} from '@calls/types/calls';
import type {CallsConfig} from '@mattermost/calls/lib/types';
import type {CallSession, CallsTheme, CallsVersion, SelectedSubtitleTrack, SubtitleTrack} from '@calls/types/calls';
import type {CallsConfig, Caption} from '@mattermost/calls/lib/types';
import type PostModel from '@typings/database/models/servers/post';
import type UserModel from '@typings/database/models/servers/user';
import type {IntlShape} from 'react-intl';
@ -201,3 +203,33 @@ export function isCallsStartedMessage(payload?: NotificationData) {
// calls will be >= 0.21.0, and push proxy will be >= 5.27.0
return (payload?.message === 'You\'ve been invited to a call' || callsMessageRegex.test(payload?.message || ''));
}
export const hasCaptions = (postProps?: Record<string, any> & { captions?: Caption[] }): boolean => {
return !(!postProps || !postProps.captions?.[0]);
};
export const getTranscriptionUri = (serverUrl: string, postProps?: Record<string, any> & { captions?: Caption[] }): {
tracks?: SubtitleTrack[];
selected: SelectedSubtitleTrack;
} => {
// Note: We're not using hasCaptions above because this tells typescript that the caption exists later.
// We could use some fancy typescript to do the same, but it's not worth the complexity.
if (!postProps || !postProps.captions?.[0]) {
return {
tracks: undefined,
selected: {type: 'disabled'},
};
}
const tracks: SubtitleTrack[] = postProps.captions.map((t) => ({
title: t.title,
language: t.language,
type: TextTrackType.VTT,
uri: buildFileUrl(serverUrl, t.file_id),
}));
return {
tracks,
selected: {type: 'index', value: 0},
};
};

View file

@ -5,7 +5,15 @@
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {Platform, Pressable, type PressableAndroidRippleConfig, type PressableStateCallbackType, type StyleProp, type ViewStyle} from 'react-native';
import {
Platform,
Pressable,
type PressableAndroidRippleConfig,
type PressableStateCallbackType,
type StyleProp,
StyleSheet,
type ViewStyle,
} from 'react-native';
import CompassIcon from '@components/compass_icon';
import {changeOpacity} from '@utils/theme';
@ -26,11 +34,21 @@ const pressedStyle = ({pressed}: PressableStateCallbackType) => {
return [{opacity}];
};
const baseStyle = StyleSheet.create({
container: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
},
});
const androidRippleConfig: PressableAndroidRippleConfig = {borderless: true, radius: 24, color: '#FFF'};
const Action = ({disabled, iconName, onPress, style}: Props) => {
const pressableStyle = useCallback((pressed: PressableStateCallbackType) => ([
pressedStyle(pressed),
baseStyle.container,
style,
]), [style]);
@ -38,7 +56,7 @@ const Action = ({disabled, iconName, onPress, style}: Props) => {
<Pressable
android_ripple={androidRippleConfig}
disabled={disabled}
hitSlop={24}
hitSlop={4}
onPress={onPress}
style={pressableStyle}
>

View file

@ -5,6 +5,8 @@ import {useManagedConfig} from '@mattermost/react-native-emm';
import React from 'react';
import {StyleSheet, View} from 'react-native';
import InvertedAction from '@screens/gallery/footer/actions/inverted_action';
import Action from './action';
type Props = {
@ -15,20 +17,22 @@ type Props = {
onCopyPublicLink: () => void;
onDownload: () => void;
onShare: () => void;
hasCaptions: boolean;
captionEnabled: boolean;
onCaptionsPress: () => void;
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
},
action: {
marginLeft: 24,
alignItems: 'center',
gap: 8,
},
});
const Actions = ({
canDownloadFiles, disabled, enablePublicLinks, fileId,
onCopyPublicLink, onDownload, onShare,
canDownloadFiles, disabled, enablePublicLinks, fileId, onCopyPublicLink,
onDownload, onShare, hasCaptions, captionEnabled, onCaptionsPress,
}: Props) => {
const managedConfig = useManagedConfig<ManagedConfig>();
const canCopyPublicLink = !fileId.startsWith('uid') && enablePublicLinks && managedConfig.copyAndPasteProtection !== 'true';
@ -41,19 +45,24 @@ const Actions = ({
iconName='link-variant'
onPress={onCopyPublicLink}
/>}
{hasCaptions &&
<InvertedAction
activated={captionEnabled}
iconName='closed-caption-outline'
onPress={onCaptionsPress}
/>
}
{canDownloadFiles &&
<>
<Action
disabled={disabled}
iconName='download-outline'
onPress={onDownload}
style={styles.action}
/>
<Action
disabled={disabled}
iconName='export-variant'
onPress={onShare}
style={styles.action}
/>
</>
}

View file

@ -0,0 +1,73 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback} from 'react';
import {
Platform,
Pressable,
type PressableAndroidRippleConfig,
type PressableStateCallbackType,
type StyleProp,
StyleSheet,
type ViewStyle,
} from 'react-native';
import CompassIcon from '@components/compass_icon';
type Props = {
activated: boolean;
iconName: string;
onPress: () => void;
style?: StyleProp<ViewStyle>;
}
const pressedStyle = ({pressed}: PressableStateCallbackType) => {
let opacity = 1;
if (Platform.OS === 'ios' && pressed) {
opacity = 0.5;
}
return [{opacity}];
};
const baseStyle = StyleSheet.create({
container: {
width: 40,
height: 40,
borderRadius: 4,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#000',
},
containerActivated: {
backgroundColor: '#fff',
},
});
const androidRippleConfig: PressableAndroidRippleConfig = {borderless: true, radius: 24, color: '#FFF'};
const InvertedAction = ({activated, iconName, onPress, style}: Props) => {
const pressableStyle = useCallback((pressed: PressableStateCallbackType) => ([
pressedStyle(pressed),
baseStyle.container,
activated && baseStyle.containerActivated,
style,
]), [style, activated]);
return (
<Pressable
android_ripple={androidRippleConfig}
hitSlop={4}
onPress={onPress}
style={pressableStyle}
>
<CompassIcon
color={activated ? '#000' : '#fff'}
name={iconName}
size={24}
/>
</Pressable>
);
};
export default InvertedAction;

View file

@ -35,6 +35,9 @@ type Props = {
post?: PostModel;
style: StyleProp<ViewStyle>;
teammateNameDisplay: string;
hasCaptions: boolean;
captionEnabled: boolean;
onCaptionsPress: () => void;
}
const AnimatedSafeAreaView = Animated.createAnimatedComponent(SafeAreaView);
@ -58,6 +61,7 @@ const Footer = ({
author, canDownloadFiles, channelName, currentUserId,
enablePostIconOverride, enablePostUsernameOverride, enablePublicLink,
hideActions, isDirectChannel, item, post, style, teammateNameDisplay,
hasCaptions, captionEnabled, onCaptionsPress,
}: Props) => {
const showActions = !hideActions && Boolean(item.id) && !item.id?.startsWith('uid');
const [action, setAction] = useState<GalleryAction>('none');
@ -142,6 +146,9 @@ const Footer = ({
onCopyPublicLink={handleCopyLink}
onDownload={handleDownload}
onShare={handleShare}
hasCaptions={hasCaptions}
captionEnabled={captionEnabled}
onCaptionsPress={onCaptionsPress}
/>
}
</View>

View file

@ -1,9 +1,11 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo, useRef, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {NativeModules, useWindowDimensions, Platform} from 'react-native';
import {CaptionsEnabledContext} from '@calls/context';
import {hasCaptions} from '@calls/utils';
import useAndroidHardwareBackHandler from '@hooks/android_back_handler';
import {useIsTablet} from '@hooks/device';
import {useGalleryControls} from '@hooks/gallery';
@ -29,10 +31,27 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde
const dim = useWindowDimensions();
const isTablet = useIsTablet();
const [localIndex, setLocalIndex] = useState(initialIndex);
const [captionsEnabled, setCaptionsEnabled] = useState<boolean[]>(new Array(items.length).fill(true));
const [captionsAvailable, setCaptionsAvailable] = useState<boolean[]>([]);
const {setControlsHidden, headerStyles, footerStyles} = useGalleryControls();
const dimensions = useMemo(() => ({width: dim.width, height: dim.height}), [dim.width]);
const galleryRef = useRef<GalleryRef>(null);
useEffect(() => {
const captions = items.reduce((acc, item) => {
acc.push(hasCaptions(item.postProps));
return acc;
}, [] as boolean[]);
setCaptionsAvailable(captions);
}, [items]);
const onCaptionsPressIdx = useCallback((idx: number) => {
const enabled = [...captionsEnabled];
enabled[idx] = !enabled[idx];
setCaptionsEnabled(enabled);
}, [captionsEnabled, setCaptionsEnabled]);
const onCaptionsPress = useCallback(() => onCaptionsPressIdx(localIndex), [localIndex, onCaptionsPressIdx]);
const onClose = useCallback(() => {
// We keep the un freeze here as we want
// the screen to be visible when the gallery
@ -63,7 +82,7 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde
useAndroidHardwareBackHandler(componentId, close);
return (
<>
<CaptionsEnabledContext.Provider value={captionsEnabled}>
<Header
index={localIndex}
onClose={onClose}
@ -84,8 +103,11 @@ const GalleryScreen = ({componentId, galleryIdentifier, hideActions, initialInde
hideActions={hideActions}
item={items[localIndex]}
style={footerStyles}
hasCaptions={captionsAvailable[localIndex]}
captionEnabled={captionsEnabled[localIndex]}
onCaptionsPress={onCaptionsPress}
/>
</>
</CaptionsEnabledContext.Provider>
);
};

View file

@ -1,13 +1,22 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import React, {useCallback, useContext, useEffect, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter, Platform, StyleSheet, useWindowDimensions} from 'react-native';
import Animated, {Easing, useAnimatedRef, useAnimatedStyle, useSharedValue, withTiming, type WithTimingConfig} from 'react-native-reanimated';
import Animated, {
Easing,
useAnimatedRef,
useAnimatedStyle,
useSharedValue,
withTiming,
type WithTimingConfig,
} from 'react-native-reanimated';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
import Video, {type OnPlaybackRateData} from 'react-native-video';
import {updateLocalFilePath} from '@actions/local/file';
import {CaptionsEnabledContext} from '@calls/context';
import {getTranscriptionUri} from '@calls/utils';
import CompassIcon from '@components/compass_icon';
import {Events} from '@constants';
import {GALLERY_FOOTER_HEIGHT, VIDEO_INSET} from '@constants/gallery';
@ -59,12 +68,14 @@ const VideoRenderer = ({height, index, initialIndex, item, isPageActive, onShoul
const serverUrl = useServerUrl();
const videoRef = useAnimatedRef<Video>();
const showControls = useRef(!(initialIndex === index));
const captionsEnabled = useContext(CaptionsEnabledContext);
const [paused, setPaused] = useState(!(initialIndex === index));
const [videoReady, setVideoReady] = useState(false);
const [videoUri, setVideoUri] = useState(item.uri);
const [downloading, setDownloading] = useState(false);
const [hasError, setHasError] = useState(false);
const source = useMemo(() => ({uri: videoUri}), [videoUri]);
const {tracks, selected} = useMemo(() => getTranscriptionUri(serverUrl, item.postProps), [serverUrl, item.postProps]);
const setFullscreen = (value: boolean) => {
fullscreen.value = value;
@ -183,6 +194,8 @@ const VideoRenderer = ({height, index, initialIndex, item, isPageActive, onShoul
onReadyForDisplay={onReadyForDisplay}
onEnd={onEnd}
onTouchStart={handleTouchStart}
textTracks={tracks}
selectedTextTrack={captionsEnabled[index] ? selected : {type: 'disabled'}}
/>
{Platform.OS === 'android' && paused && videoReady &&
<Animated.View style={styles.playContainer}>

View file

@ -28,7 +28,7 @@ export const clampVelocity = (velocity: number, minVelocity: number, maxVelocity
return Math.max(Math.min(velocity, -minVelocity), -maxVelocity);
};
export const fileToGalleryItem = (file: FileInfo, authorId?: string, lastPictureUpdate = 0): GalleryItemType => {
export const fileToGalleryItem = (file: FileInfo, authorId?: string, postProps?: Record<string, any>, lastPictureUpdate = 0): GalleryItemType => {
let type: GalleryItemType['type'] = 'file';
if (isVideo(file)) {
type = 'video';
@ -50,6 +50,7 @@ export const fileToGalleryItem = (file: FileInfo, authorId?: string, lastPicture
type,
uri: file.localPath || file.uri || '',
width: file.width,
postProps: postProps || file.postProps,
};
};

12
package-lock.json generated
View file

@ -18,8 +18,8 @@
"@formatjs/intl-pluralrules": "5.2.10",
"@formatjs/intl-relativetimeformat": "11.2.10",
"@gorhom/bottom-sheet": "4.5.1",
"@mattermost/calls": "github:mattermost/calls-common#v0.21.0",
"@mattermost/compass-icons": "0.1.39",
"@mattermost/calls": "github:mattermost/calls-common#v0.22.0",
"@mattermost/compass-icons": "0.1.40",
"@mattermost/react-native-emm": "1.4.0",
"@mattermost/react-native-network-client": "1.5.0",
"@mattermost/react-native-paste-input": "0.7.0",
@ -3446,7 +3446,7 @@
"node_modules/@mattermost/calls": {
"name": "@calls/common",
"version": "0.14.0",
"resolved": "git+ssh://git@github.com/mattermost/calls-common.git#4bca3651b2eb5d46fab9a29af000d21d9f56727c"
"resolved": "git+ssh://git@github.com/mattermost/calls-common.git#4a45138c02e3ce45d9e26f81c0f421a136ae0e59"
},
"node_modules/@mattermost/commonmark": {
"version": "0.30.1-2",
@ -3468,9 +3468,9 @@
}
},
"node_modules/@mattermost/compass-icons": {
"version": "0.1.39",
"resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.39.tgz",
"integrity": "sha512-rr+grRMg9xs020O4PC2H+/q4+B8hjuGay49i2xEVxGATaHJFkGItDVXqlfPVDcQPVOmZI7m66H+DOnnYrr5pBQ=="
"version": "0.1.40",
"resolved": "https://registry.npmjs.org/@mattermost/compass-icons/-/compass-icons-0.1.40.tgz",
"integrity": "sha512-Vz4BeTuwk7bgcKNyOn8F3Q0WsZF8s44bJNawg29pKQc4TLVPurceUhlsqi+B2vVTyCSVyNlFWijg/PWemyCgdQ=="
},
"node_modules/@mattermost/react-native-emm": {
"version": "1.4.0",

View file

@ -19,8 +19,8 @@
"@formatjs/intl-pluralrules": "5.2.10",
"@formatjs/intl-relativetimeformat": "11.2.10",
"@gorhom/bottom-sheet": "4.5.1",
"@mattermost/calls": "github:mattermost/calls-common#v0.21.0",
"@mattermost/compass-icons": "0.1.39",
"@mattermost/calls": "github:mattermost/calls-common#v0.22.0",
"@mattermost/compass-icons": "0.1.40",
"@mattermost/react-native-emm": "1.4.0",
"@mattermost/react-native-network-client": "1.5.0",
"@mattermost/react-native-paste-input": "0.7.0",

View file

@ -91,3 +91,91 @@ index becee6a..5d4b2cd 100644
</LinearLayout>
</LinearLayout>
diff --git a/node_modules/react-native-video/ios/Video/RCTVideo.m b/node_modules/react-native-video/ios/Video/RCTVideo.m
index a757c08..2e402e2 100644
--- a/node_modules/react-native-video/ios/Video/RCTVideo.m
+++ b/node_modules/react-native-video/ios/Video/RCTVideo.m
@@ -449,6 +449,7 @@ - (void)playerItemPrepareText:(AVAsset *)asset assetOptions:(NSDictionary * __nu
_allowsExternalPlayback = NO;
// sideload text tracks
+ NSLock *mixCompositionLock = [[NSLock alloc] init]; // mixComposition is not thread-safe; lock in the async blocks below
AVMutableComposition *mixComposition = [[AVMutableComposition alloc] init];
AVAssetTrack *videoAsset = [asset tracksWithMediaType:AVMediaTypeVideo].firstObject;
@@ -466,30 +467,58 @@ - (void)playerItemPrepareText:(AVAsset *)asset assetOptions:(NSDictionary * __nu
error:nil];
NSMutableArray* validTextTracks = [NSMutableArray array];
+ NSLock *validTextTracksLock = [[NSLock alloc] init]; // validTextTracks is not thread-safe
+ dispatch_group_t textTracksDispatchGroup = dispatch_group_create();
+
for (int i = 0; i < _textTracks.count; ++i) {
AVURLAsset *textURLAsset;
- NSString *textUri = [_textTracks objectAtIndex:i][@"uri"];
+ NSDictionary *curTrack = _textTracks[i]; // capture the current track, because we're going to set _textTracks in an async block below
+ NSString *textUri = curTrack[@"uri"];
+
if ([[textUri lowercaseString] hasPrefix:@"http"]) {
textURLAsset = [AVURLAsset URLAssetWithURL:[NSURL URLWithString:textUri] options:assetOptions];
} else {
textURLAsset = [AVURLAsset URLAssetWithURL:[self urlFilePath:textUri] options:nil];
}
- AVAssetTrack *textTrackAsset = [textURLAsset tracksWithMediaType:AVMediaTypeText].firstObject;
- if (!textTrackAsset) continue; // fix when there's no textTrackAsset
- [validTextTracks addObject:[_textTracks objectAtIndex:i]];
- AVMutableCompositionTrack *textCompTrack = [mixComposition
- addMutableTrackWithMediaType:AVMediaTypeText
- preferredTrackID:kCMPersistentTrackID_Invalid];
- [textCompTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, videoAsset.timeRange.duration)
- ofTrack:textTrackAsset
- atTime:kCMTimeZero
- error:nil];
- }
- if (validTextTracks.count != _textTracks.count) {
- [self setTextTracks:validTextTracks];
- }
-
- handler([AVPlayerItem playerItemWithAsset:mixComposition]);
+
+ dispatch_group_enter(textTracksDispatchGroup); // completionHandler runs async
+
+ [textURLAsset loadTracksWithMediaType:AVMediaTypeText completionHandler:^(NSArray<AVAssetTrack *> * trackArr, NSError * error) {
+ if (error) {
+ RCTLogError(@"Error from loadTracksWithMediaType: %@", error);
+ dispatch_group_leave(textTracksDispatchGroup);
+ return;
+ }
+
+ AVAssetTrack *textTrackAsset = trackArr.firstObject;
+
+ if (!textTrackAsset) return;
+
+ [validTextTracksLock lock]; // validTextTracks is not thread-safe
+ [validTextTracks addObject:curTrack];
+ [validTextTracksLock unlock];
+
+ [mixCompositionLock lock]; // mixComposition is not thread-safe
+ AVMutableCompositionTrack *textCompTrack = [mixComposition
+ addMutableTrackWithMediaType:AVMediaTypeText
+ preferredTrackID:kCMPersistentTrackID_Invalid];
+ [textCompTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, videoAsset.timeRange.duration)
+ ofTrack:textTrackAsset
+ atTime:kCMTimeZero
+ error:nil];
+ [mixCompositionLock unlock];
+
+ dispatch_group_leave(textTracksDispatchGroup);
+ }];
+ }
+
+ dispatch_group_notify(textTracksDispatchGroup, dispatch_get_main_queue(), ^{
+ if (validTextTracks.count > 0) {
+ [self setTextTracks:validTextTracks];
+ }
+
+ handler([AVPlayerItem playerItemWithAsset:mixComposition]);
+ });
}
- (void)playerItemForSource:(NSDictionary *)source withCallback:(void(^)(AVPlayerItem *))handler

View file

@ -22,6 +22,7 @@ type FileInfo = {
uri?: string;
user_id: string;
width: number;
postProps?: Record<string, any>;
};
type FilesState = {

View file

@ -1,6 +1,9 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Caption} from '@mattermost/calls/lib/types';
import type {GestureHandlerGestureEvent} from 'react-native-gesture-handler';
import type Animated from 'react-native-reanimated';
@ -71,6 +74,7 @@ export type GalleryItemType = {
authorId?: string;
size?: number;
postId?: string;
postProps?: Record<string, any> & {captions?: Caption[]};
};
export type GalleryAction = 'none' | 'downloading' | 'copying' | 'sharing' | 'opening' | 'external';