diff --git a/native/media/video-playback-modal.react.js b/native/media/video-playback-modal.react.js
new file mode 100644
index 000000000..4e173eef2
--- /dev/null
+++ b/native/media/video-playback-modal.react.js
@@ -0,0 +1,189 @@
+// @flow
+
+import invariant from 'invariant';
+import * as React from 'react';
+import { useState } from 'react';
+import { View, Text, TouchableWithoutFeedback } from 'react-native';
+import * as Progress from 'react-native-progress';
+import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
+import Video from 'react-native-video';
+
+import Button from '../components/button.react';
+import Modal from '../components/modal.react';
+import type { AppNavigationProp } from '../navigation/app-navigator.react';
+import type { NavigationRoute } from '../navigation/route-names';
+import { useStyles } from '../themes/colors';
+import { formatDuration } from './video-utils';
+
+export type VideoPlaybackModalParams = {|
+ +videoUri: string,
+|};
+
+type Props = {|
+ +navigation: AppNavigationProp<'VideoPlaybackModal'>,
+ +route: NavigationRoute<'VideoPlaybackModal'>,
+|};
+function VideoPlaybackModal(props: Props) {
+ const styles = useStyles(unboundStyles);
+
+ const [paused, setPaused] = useState(false);
+ const [percentElapsed, setPercentElapsed] = useState(0);
+ const [controlsVisible, setControlsVisible] = useState(true);
+ const [timeElapsed, setTimeElapsed] = useState('0:00');
+ const [totalDuration, setTotalDuration] = useState('0:00');
+ const videoRef = React.useRef();
+
+ const {
+ navigation,
+ route: {
+ params: { videoUri },
+ },
+ } = props;
+
+ const togglePlayback = React.useCallback(() => {
+ setPaused(!paused);
+ }, [paused]);
+
+ const togglePlaybackControls = React.useCallback(() => {
+ setControlsVisible(!controlsVisible);
+ }, [controlsVisible]);
+
+ const resetVideo = React.useCallback(() => {
+ invariant(videoRef.current, 'videoRef.current should be set in resetVideo');
+ videoRef.current.seek(0);
+ }, []);
+
+ const progressCallback = React.useCallback((res) => {
+ setTimeElapsed(formatDuration(res.currentTime));
+ setTotalDuration(formatDuration(res.seekableDuration));
+ setPercentElapsed(
+ Math.ceil((res.currentTime / res.seekableDuration) * 100),
+ );
+ }, []);
+
+ let controls;
+ if (controlsVisible) {
+ controls = (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {timeElapsed} / {totalDuration}
+
+
+
+ >
+ );
+ }
+
+ return (
+
+
+
+
+ {controls}
+
+ );
+}
+
+const unboundStyles = {
+ modal: {
+ backgroundColor: 'black',
+ justifyContent: 'center',
+ marginHorizontal: 0,
+ marginTop: 0,
+ marginBottom: 0,
+ padding: 0,
+ borderRadius: 0,
+ },
+ backgroundVideo: {
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ bottom: 0,
+ right: 0,
+ },
+ footer: {
+ position: 'absolute',
+ justifyContent: 'flex-end',
+ left: 0,
+ right: 0,
+ bottom: 0,
+ },
+ header: {
+ position: 'absolute',
+ justifyContent: 'flex-start',
+ left: 0,
+ right: 0,
+ top: 0,
+ },
+ playPauseButton: {
+ backgroundColor: 'rgba(52,52,52,0.6)',
+ justifyContent: 'center',
+ alignItems: 'center',
+ flexDirection: 'row',
+ flex: 0,
+ height: 76,
+ },
+ closeButton: {
+ paddingTop: 10,
+ paddingLeft: 20,
+ justifyContent: 'flex-start',
+ alignItems: 'center',
+ flexDirection: 'row',
+ height: 100,
+ },
+ progressBar: {
+ position: 'relative',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: 'white',
+ paddingRight: 10,
+ },
+ iconButton: {
+ paddingRight: 10,
+ color: 'white',
+ },
+ durationText: {
+ color: 'white',
+ fontSize: 11,
+ },
+};
+
+export default VideoPlaybackModal;
diff --git a/native/media/video-utils.js b/native/media/video-utils.js
index d2915bb93..f6aa0cc6c 100644
--- a/native/media/video-utils.js
+++ b/native/media/video-utils.js
@@ -1,218 +1,224 @@
// @flow
import invariant from 'invariant';
import { Platform } from 'react-native';
import filesystem from 'react-native-fs';
import { mediaConfig, pathFromURI } from 'lib/media/file-utils';
import { getVideoProcessingPlan } from 'lib/media/video-utils';
import type {
MediaMissionStep,
MediaMissionFailure,
VideoProbeMediaMissionStep,
Dimensions,
} from 'lib/types/media-types';
import { getMessageForException } from 'lib/utils/errors';
import { ffmpeg } from './ffmpeg';
// These are some numbers I sorta kinda made up
// We should try to calculate them on a per-device basis
const uploadSpeeds = Object.freeze({
wifi: 4096, // in KiB/s
cellular: 512, // in KiB/s
});
const clientTranscodeSpeed = 1.15; // in seconds of video transcoded per second
type ProcessVideoInfo = {|
uri: string,
mime: string,
filename: string,
fileSize: number,
dimensions: Dimensions,
hasWiFi: boolean,
|};
type VideoProcessConfig = {|
+onTranscodingProgress: (percent: number) => void,
|};
type ProcessVideoResponse = {|
success: true,
uri: string,
mime: string,
dimensions: Dimensions,
loop: boolean,
|};
async function processVideo(
input: ProcessVideoInfo,
config: VideoProcessConfig,
): Promise<{|
steps: $ReadOnlyArray,
result: MediaMissionFailure | ProcessVideoResponse,
|}> {
const steps = [];
const path = pathFromURI(input.uri);
invariant(path, `could not extract path from ${input.uri}`);
const initialCheckStep = await checkVideoInfo(path);
steps.push(initialCheckStep);
if (!initialCheckStep.success || !initialCheckStep.duration) {
return { steps, result: { success: false, reason: 'video_probe_failed' } };
}
const { validFormat, duration } = initialCheckStep;
const plan = getVideoProcessingPlan({
inputPath: path,
inputHasCorrectContainerAndCodec: validFormat,
inputFileSize: input.fileSize,
inputFilename: input.filename,
inputDuration: duration,
inputDimensions: input.dimensions,
outputDirectory: Platform.select({
ios: filesystem.TemporaryDirectoryPath,
default: `${filesystem.TemporaryDirectoryPath}/`,
}),
// We want ffmpeg to use hardware-accelerated encoders. On iOS we can do
// this using VideoToolbox, but ffmpeg on Android is still missing
// MediaCodec encoding support: https://trac.ffmpeg.org/ticket/6407
outputCodec: Platform.select({
ios: 'h264_videotoolbox',
//android: 'h264_mediacodec',
default: 'h264',
}),
clientConnectionInfo: {
hasWiFi: input.hasWiFi,
speed: input.hasWiFi ? uploadSpeeds.wifi : uploadSpeeds.cellular,
},
clientTranscodeSpeed,
});
if (plan.action === 'reject') {
return { steps, result: plan.failure };
}
if (plan.action === 'none') {
return {
steps,
result: {
success: true,
uri: input.uri,
mime: 'video/mp4',
dimensions: input.dimensions,
loop: false,
},
};
}
const { outputPath, ffmpegCommand } = plan;
let returnCode,
newPath,
stats,
success = false,
exceptionMessage;
const start = Date.now();
try {
const { rc, lastStats } = await ffmpeg.process(
ffmpegCommand,
duration,
config.onTranscodingProgress,
);
success = rc === 0;
if (success) {
returnCode = rc;
newPath = outputPath;
stats = lastStats;
}
} catch (e) {
exceptionMessage = getMessageForException(e);
}
if (!success) {
unlink(outputPath);
}
steps.push({
step: 'video_ffmpeg_transcode',
success,
exceptionMessage,
time: Date.now() - start,
returnCode,
newPath,
stats,
});
if (!success) {
return {
steps,
result: { success: false, reason: 'video_transcode_failed' },
};
}
const transcodeProbeStep = await checkVideoInfo(outputPath);
steps.push(transcodeProbeStep);
if (!transcodeProbeStep.validFormat) {
unlink(outputPath);
return {
steps,
result: { success: false, reason: 'video_transcode_failed' },
};
}
const dimensions = transcodeProbeStep.dimensions
? transcodeProbeStep.dimensions
: input.dimensions;
const loop = !!(
mediaConfig[input.mime] &&
mediaConfig[input.mime].videoConfig &&
mediaConfig[input.mime].videoConfig.loop
);
return {
steps,
result: {
success: true,
uri: `file://${outputPath}`,
mime: 'video/mp4',
dimensions,
loop,
},
};
}
async function checkVideoInfo(
path: string,
): Promise {
let codec,
format,
dimensions,
duration,
success = false,
validFormat = false,
exceptionMessage;
const start = Date.now();
try {
({ codec, format, dimensions, duration } = await ffmpeg.getVideoInfo(path));
success = true;
validFormat = codec === 'h264' && format.includes('mp4');
} catch (e) {
exceptionMessage = getMessageForException(e);
}
return {
step: 'video_probe',
success,
exceptionMessage,
time: Date.now() - start,
path,
validFormat,
duration,
codec,
format,
dimensions,
};
}
async function unlink(path: string) {
try {
await filesystem.unlink(path);
} catch {}
}
-export { processVideo };
+function formatDuration(seconds: number) {
+ const mm = Math.floor(seconds / 60);
+ const ss = (seconds % 60).toFixed(0).padStart(2, '0');
+ return `${mm}:${ss}`;
+}
+
+export { processVideo, formatDuration };
diff --git a/native/navigation/app-navigator.react.js b/native/navigation/app-navigator.react.js
index f9ac1d0d7..7cabfcb8c 100644
--- a/native/navigation/app-navigator.react.js
+++ b/native/navigation/app-navigator.react.js
@@ -1,210 +1,216 @@
// @flow
import type { BottomTabNavigationProp } from '@react-navigation/bottom-tabs';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
import * as SplashScreen from 'expo-splash-screen';
import * as React from 'react';
import Icon from 'react-native-vector-icons/FontAwesome';
import { PersistGate } from 'redux-persist/integration/react';
import { unreadCount } from 'lib/selectors/thread-selectors';
import Calendar from '../calendar/calendar.react';
import Chat from '../chat/chat.react';
import { MultimediaTooltipModal } from '../chat/multimedia-tooltip-modal.react';
import { RobotextMessageTooltipModal } from '../chat/robotext-message-tooltip-modal.react';
import ThreadSettingsMemberTooltipModal from '../chat/settings/thread-settings-member-tooltip-modal.react';
import { TextMessageTooltipModal } from '../chat/text-message-tooltip-modal.react';
import KeyboardStateContainer from '../keyboard/keyboard-state-container.react';
import CameraModal from '../media/camera-modal.react';
import MultimediaModal from '../media/multimedia-modal.react';
+import VideoPlaybackModal from '../media/video-playback-modal.react';
import More from '../more/more.react';
import RelationshipListItemTooltipModal from '../more/relationship-list-item-tooltip-modal.react';
import PushHandler from '../push/push-handler.react';
import { getPersistor } from '../redux/persist';
import { useSelector } from '../redux/redux-utils';
import { RootContext } from '../root-context';
import { waitForInteractions } from '../utils/timers';
import ActionResultModal from './action-result-modal.react';
import { createOverlayNavigator } from './overlay-navigator.react';
import type { OverlayRouterNavigationProp } from './overlay-router';
import type { RootNavigationProp } from './root-navigator.react';
import {
CalendarRouteName,
ChatRouteName,
MoreRouteName,
TabNavigatorRouteName,
MultimediaModalRouteName,
MultimediaTooltipModalRouteName,
ActionResultModalRouteName,
TextMessageTooltipModalRouteName,
ThreadSettingsMemberTooltipModalRouteName,
RelationshipListItemTooltipModalRouteName,
RobotextMessageTooltipModalRouteName,
CameraModalRouteName,
+ VideoPlaybackModalRouteName,
type ScreenParamList,
type TabParamList,
type OverlayParamList,
} from './route-names';
import { tabBar } from './tab-bar.react';
let splashScreenHasHidden = false;
const calendarTabOptions = {
tabBarLabel: 'Calendar',
// eslint-disable-next-line react/display-name
tabBarIcon: ({ color }) => (
),
};
const getChatTabOptions = (badge: number) => ({
tabBarLabel: 'Chat',
// eslint-disable-next-line react/display-name
tabBarIcon: ({ color }) => (
),
tabBarBadge: badge ? badge : undefined,
});
const moreTabOptions = {
tabBarLabel: 'More',
// eslint-disable-next-line react/display-name
tabBarIcon: ({ color }) => (
),
};
export type TabNavigationProp<
RouteName: $Keys = $Keys,
> = BottomTabNavigationProp;
const Tab = createBottomTabNavigator<
ScreenParamList,
TabParamList,
TabNavigationProp<>,
>();
const tabBarOptions = { keyboardHidesTabBar: false };
function TabNavigator() {
const chatBadge = useSelector(unreadCount);
return (
);
}
export type AppNavigationProp<
RouteName: $Keys = $Keys,
> = OverlayRouterNavigationProp;
const App = createOverlayNavigator<
ScreenParamList,
OverlayParamList,
AppNavigationProp<>,
>();
type AppNavigatorProps = {
navigation: RootNavigationProp<'App'>,
};
function AppNavigator(props: AppNavigatorProps) {
const { navigation } = props;
const rootContext = React.useContext(RootContext);
const setNavStateInitialized =
rootContext && rootContext.setNavStateInitialized;
React.useEffect(() => {
setNavStateInitialized && setNavStateInitialized();
}, [setNavStateInitialized]);
const [
localSplashScreenHasHidden,
setLocalSplashScreenHasHidden,
] = React.useState(splashScreenHasHidden);
React.useEffect(() => {
if (localSplashScreenHasHidden) {
return;
}
splashScreenHasHidden = true;
(async () => {
await waitForInteractions();
try {
await SplashScreen.hideAsync();
setLocalSplashScreenHasHidden(true);
} catch {}
})();
}, [localSplashScreenHasHidden]);
let pushHandler;
if (localSplashScreenHasHidden) {
pushHandler = (
);
}
return (
+
{pushHandler}
);
}
const styles = {
icon: {
fontSize: 28,
},
};
export default AppNavigator;
diff --git a/native/navigation/route-names.js b/native/navigation/route-names.js
index 68c4233ca..3cb61885a 100644
--- a/native/navigation/route-names.js
+++ b/native/navigation/route-names.js
@@ -1,167 +1,171 @@
// @flow
import type { LeafRoute } from '@react-navigation/native';
import type { VerificationModalParams } from '../account/verification-modal.react';
import type { ThreadPickerModalParams } from '../calendar/thread-picker-modal.react';
import type { ComposeThreadParams } from '../chat/compose-thread.react';
import type { ImagePasteModalParams } from '../chat/image-paste-modal.react';
import type { MessageListParams } from '../chat/message-list-types';
import type { MultimediaTooltipModalParams } from '../chat/multimedia-tooltip-modal.react';
import type { RobotextMessageTooltipModalParams } from '../chat/robotext-message-tooltip-modal.react';
import type { AddUsersModalParams } from '../chat/settings/add-users-modal.react';
import type { ColorPickerModalParams } from '../chat/settings/color-picker-modal.react';
import type { ComposeSubthreadModalParams } from '../chat/settings/compose-subthread-modal.react';
import type { DeleteThreadParams } from '../chat/settings/delete-thread.react';
import type { ThreadSettingsMemberTooltipModalParams } from '../chat/settings/thread-settings-member-tooltip-modal.react';
import type { ThreadSettingsParams } from '../chat/settings/thread-settings.react';
import type { SidebarListModalParams } from '../chat/sidebar-list-modal.react';
import type { TextMessageTooltipModalParams } from '../chat/text-message-tooltip-modal.react';
import type { CameraModalParams } from '../media/camera-modal.react';
import type { MultimediaModalParams } from '../media/multimedia-modal.react';
+import type { VideoPlaybackModalParams } from '../media/video-playback-modal.react';
import type { CustomServerModalParams } from '../more/custom-server-modal.react';
import type { RelationshipListItemTooltipModalParams } from '../more/relationship-list-item-tooltip-modal.react';
import type { ActionResultModalParams } from './action-result-modal.react';
export const AppRouteName = 'App';
export const TabNavigatorRouteName = 'TabNavigator';
export const ComposeThreadRouteName = 'ComposeThread';
export const DeleteThreadRouteName = 'DeleteThread';
export const ThreadSettingsRouteName = 'ThreadSettings';
export const MessageListRouteName = 'MessageList';
export const VerificationModalRouteName = 'VerificationModal';
export const LoggedOutModalRouteName = 'LoggedOutModal';
export const MoreRouteName = 'More';
export const MoreScreenRouteName = 'MoreScreen';
export const RelationshipListItemTooltipModalRouteName =
'RelationshipListItemTooltipModal';
export const ChatRouteName = 'Chat';
export const ChatThreadListRouteName = 'ChatThreadList';
export const HomeChatThreadListRouteName = 'HomeChatThreadList';
export const BackgroundChatThreadListRouteName = 'BackgroundChatThreadList';
export const CalendarRouteName = 'Calendar';
export const BuildInfoRouteName = 'BuildInfo';
export const DeleteAccountRouteName = 'DeleteAccount';
export const DevToolsRouteName = 'DevTools';
export const EditEmailRouteName = 'EditEmail';
export const EditPasswordRouteName = 'EditPassword';
export const AppearancePreferencesRouteName = 'AppearancePreferences';
export const ThreadPickerModalRouteName = 'ThreadPickerModal';
export const AddUsersModalRouteName = 'AddUsersModal';
export const CustomServerModalRouteName = 'CustomServerModal';
export const ColorPickerModalRouteName = 'ColorPickerModal';
export const ComposeSubthreadModalRouteName = 'ComposeSubthreadModal';
export const MultimediaModalRouteName = 'MultimediaModal';
export const MultimediaTooltipModalRouteName = 'MultimediaTooltipModal';
export const ActionResultModalRouteName = 'ActionResultModal';
export const TextMessageTooltipModalRouteName = 'TextMessageTooltipModal';
export const ThreadSettingsMemberTooltipModalRouteName =
'ThreadSettingsMemberTooltipModal';
export const CameraModalRouteName = 'CameraModal';
+export const VideoPlaybackModalRouteName = 'VideoPlaybackModal';
export const FriendListRouteName = 'FriendList';
export const BlockListRouteName = 'BlockList';
export const SidebarListModalRouteName = 'SidebarListModal';
export const ImagePasteModalRouteName = 'ImagePasteModal';
export const RobotextMessageTooltipModalRouteName =
'RobotextMessageTooltipModal';
export type RootParamList = {|
- LoggedOutModal: void,
- VerificationModal: VerificationModalParams,
- App: void,
- ThreadPickerModal: ThreadPickerModalParams,
- AddUsersModal: AddUsersModalParams,
- CustomServerModal: CustomServerModalParams,
- ColorPickerModal: ColorPickerModalParams,
- ComposeSubthreadModal: ComposeSubthreadModalParams,
- SidebarListModal: SidebarListModalParams,
- ImagePasteModal: ImagePasteModalParams,
+ +LoggedOutModal: void,
+ +VerificationModal: VerificationModalParams,
+ +App: void,
+ +ThreadPickerModal: ThreadPickerModalParams,
+ +AddUsersModal: AddUsersModalParams,
+ +CustomServerModal: CustomServerModalParams,
+ +ColorPickerModal: ColorPickerModalParams,
+ +ComposeSubthreadModal: ComposeSubthreadModalParams,
+ +SidebarListModal: SidebarListModalParams,
+ +ImagePasteModal: ImagePasteModalParams,
|};
export type TooltipModalParamList = {|
+MultimediaTooltipModal: MultimediaTooltipModalParams,
+TextMessageTooltipModal: TextMessageTooltipModalParams,
+ThreadSettingsMemberTooltipModal: ThreadSettingsMemberTooltipModalParams,
+RelationshipListItemTooltipModal: RelationshipListItemTooltipModalParams,
+RobotextMessageTooltipModal: RobotextMessageTooltipModalParams,
|};
export type OverlayParamList = {|
- TabNavigator: void,
- MultimediaModal: MultimediaModalParams,
- ActionResultModal: ActionResultModalParams,
- CameraModal: CameraModalParams,
+ +TabNavigator: void,
+ +MultimediaModal: MultimediaModalParams,
+ +ActionResultModal: ActionResultModalParams,
+ +CameraModal: CameraModalParams,
+ +VideoPlaybackModal: VideoPlaybackModalParams,
...TooltipModalParamList,
|};
export type TabParamList = {|
- Calendar: void,
- Chat: void,
- More: void,
+ +Calendar: void,
+ +Chat: void,
+ +More: void,
|};
export type ChatParamList = {|
- ChatThreadList: void,
- MessageList: MessageListParams,
- ComposeThread: ComposeThreadParams,
- ThreadSettings: ThreadSettingsParams,
- DeleteThread: DeleteThreadParams,
+ +ChatThreadList: void,
+ +MessageList: MessageListParams,
+ +ComposeThread: ComposeThreadParams,
+ +ThreadSettings: ThreadSettingsParams,
+ +DeleteThread: DeleteThreadParams,
|};
export type ChatTopTabsParamList = {|
- HomeChatThreadList: void,
- BackgroundChatThreadList: void,
+ +HomeChatThreadList: void,
+ +BackgroundChatThreadList: void,
|};
export type MoreParamList = {|
- MoreScreen: void,
- EditEmail: void,
- EditPassword: void,
- DeleteAccount: void,
- BuildInfo: void,
- DevTools: void,
- AppearancePreferences: void,
- FriendList: void,
- BlockList: void,
+ +MoreScreen: void,
+ +EditEmail: void,
+ +EditPassword: void,
+ +DeleteAccount: void,
+ +BuildInfo: void,
+ +DevTools: void,
+ +AppearancePreferences: void,
+ +FriendList: void,
+ +BlockList: void,
|};
export type ScreenParamList = {|
...RootParamList,
...OverlayParamList,
...TabParamList,
...ChatParamList,
...ChatTopTabsParamList,
...MoreParamList,
|};
export type NavigationRoute> = {|
...LeafRoute,
+params: $ElementType,
|};
export const accountModals = [
LoggedOutModalRouteName,
VerificationModalRouteName,
];
export const scrollBlockingModals = [
MultimediaModalRouteName,
MultimediaTooltipModalRouteName,
TextMessageTooltipModalRouteName,
ThreadSettingsMemberTooltipModalRouteName,
RelationshipListItemTooltipModalRouteName,
RobotextMessageTooltipModalRouteName,
+ VideoPlaybackModalRouteName,
];
export const chatRootModals = [
AddUsersModalRouteName,
ColorPickerModalRouteName,
ComposeSubthreadModalRouteName,
];
export const threadRoutes = [
MessageListRouteName,
ThreadSettingsRouteName,
DeleteThreadRouteName,
ComposeThreadRouteName,
];