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:
parent
9ac0069e5c
commit
2c1f318868
18 changed files with 335 additions and 31 deletions
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
6
app/products/calls/context.ts
Normal file
6
app/products/calls/context.ts
Normal 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[]>([]);
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
|
|
|
|||
73
app/screens/gallery/footer/actions/inverted_action.tsx
Normal file
73
app/screens/gallery/footer/actions/inverted_action.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
12
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
types/api/files.d.ts
vendored
1
types/api/files.d.ts
vendored
|
|
@ -22,6 +22,7 @@ type FileInfo = {
|
|||
uri?: string;
|
||||
user_id: string;
|
||||
width: number;
|
||||
postProps?: Record<string, any>;
|
||||
};
|
||||
|
||||
type FilesState = {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue