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, ];