renderMessageVideo.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. import React, { useState, useEffect, useMemo } from 'react';
  2. import { View, ActivityIndicator, TouchableOpacity, Platform, Image } from 'react-native';
  3. import { useEvent } from 'expo';
  4. import { useVideoPlayer, VideoView, type VideoSource } from 'expo-video';
  5. import { MaterialCommunityIcons } from '@expo/vector-icons';
  6. import * as FileSystem from 'expo-file-system/legacy';
  7. import { Colors } from 'src/theme';
  8. import { CACHED_ATTACHMENTS_DIR } from 'src/constants/constants';
  9. import { API_HOST, APP_VERSION } from 'src/constants';
  10. type RenderMessageVideoProps = {
  11. props: any;
  12. token: string;
  13. currentUserId: number;
  14. onLongPress: (currentMessage: any, props: any) => any;
  15. };
  16. const MAX_RETRY = 3;
  17. const RenderMessageVideo = ({
  18. props,
  19. token,
  20. currentUserId,
  21. onLongPress
  22. }: RenderMessageVideoProps) => {
  23. const { currentMessage } = props;
  24. if (!currentMessage?.video) return null;
  25. const leftMessage = currentMessage?.user?._id !== currentUserId;
  26. const [videoUri, setVideoUri] = useState<string | null>(null);
  27. const [retryCount, setRetryCount] = useState(0);
  28. const [showPoster, setShowPoster] = useState(true);
  29. const [hasStarted, setHasStarted] = useState(false);
  30. const downloadVideo = async (videoUrl: string) => {
  31. if (!videoUrl.startsWith('https')) {
  32. setVideoUri(videoUrl);
  33. return videoUrl;
  34. }
  35. try {
  36. const dirExist = await FileSystem.getInfoAsync(CACHED_ATTACHMENTS_DIR);
  37. if (!dirExist.exists) {
  38. await FileSystem.makeDirectoryAsync(CACHED_ATTACHMENTS_DIR, { intermediates: true });
  39. }
  40. const filename =
  41. currentMessage?.attachment?.filename ?? `video-${currentMessage?._id ?? ''}.mp4`;
  42. const videoPath = `${CACHED_ATTACHMENTS_DIR}${filename}`;
  43. const videoExists = await FileSystem.getInfoAsync(videoPath);
  44. if (videoExists.exists) {
  45. if ((videoExists.size ?? 0) < 1024) {
  46. await FileSystem.deleteAsync(videoPath, { idempotent: true });
  47. } else {
  48. try {
  49. await FileSystem.readAsStringAsync(videoPath, {
  50. encoding: FileSystem.EncodingType.Base64
  51. });
  52. setVideoUri(videoPath);
  53. return videoPath;
  54. } catch {
  55. await FileSystem.deleteAsync(videoPath, { idempotent: true });
  56. }
  57. }
  58. }
  59. const downloadResult = await FileSystem.downloadAsync(videoUrl, videoPath, {
  60. headers: {
  61. Nmtoken: token,
  62. 'App-Version': APP_VERSION,
  63. Platform: Platform.OS
  64. }
  65. });
  66. setVideoUri(downloadResult.uri);
  67. return downloadResult.uri;
  68. } catch (error) {
  69. console.error('Error downloading video:', error);
  70. return null;
  71. }
  72. };
  73. useEffect(() => {
  74. const loadVideo = async () => {
  75. if (currentMessage?.video && !currentMessage?.isSending) {
  76. setShowPoster(true);
  77. await downloadVideo(currentMessage.video);
  78. }
  79. };
  80. loadVideo();
  81. }, [currentMessage.video, currentMessage.isSending]);
  82. const player = useVideoPlayer(null, (p) => {
  83. p.loop = false;
  84. p.muted = false;
  85. p.volume = 1;
  86. p.timeUpdateEventInterval = 0.5;
  87. });
  88. useEffect(() => {
  89. (async () => {
  90. if (videoUri) {
  91. const source: VideoSource = { uri: videoUri };
  92. await player.replaceAsync(source);
  93. }
  94. })();
  95. }, [videoUri, player]);
  96. const { status, error } = useEvent(player, 'statusChange', { status: player.status });
  97. const { isPlaying } = useEvent(player, 'playingChange', { isPlaying: player.playing });
  98. const isBuffering = status !== 'readyToPlay';
  99. const isVideoLoaded = status === 'readyToPlay';
  100. useEffect(() => {
  101. if (status === 'readyToPlay') setShowPoster(false);
  102. }, [status]);
  103. useEffect(() => {
  104. (async () => {
  105. if ((status === 'error' || !!error) && retryCount < MAX_RETRY && videoUri) {
  106. try {
  107. await FileSystem.deleteAsync(videoUri, { idempotent: true });
  108. } catch {}
  109. const newUri = await downloadVideo(currentMessage.video);
  110. if (newUri) {
  111. setRetryCount((c) => c + 1);
  112. setShowPoster(true);
  113. }
  114. }
  115. })();
  116. }, [status, error]);
  117. const posterUri = useMemo(
  118. () =>
  119. currentMessage?.attachment?.attachment_small_url
  120. ? API_HOST + currentMessage.attachment.attachment_small_url
  121. : null,
  122. [currentMessage]
  123. );
  124. const handlePlayPress = async () => {
  125. if (isVideoLoaded) {
  126. setHasStarted(true);
  127. await player.play();
  128. }
  129. };
  130. return (
  131. <View style={{ width: 200, height: 200, padding: 6, borderRadius: 10, overflow: 'hidden' }}>
  132. {posterUri && showPoster && (
  133. <Image
  134. source={{ uri: posterUri }}
  135. style={{ position: 'absolute', top: 6, left: 6, right: 6, bottom: 6, borderRadius: 10 }}
  136. resizeMode="cover"
  137. />
  138. )}
  139. {videoUri ? (
  140. <VideoView
  141. style={{ flex: 1, borderRadius: 10 }}
  142. player={player}
  143. contentFit="contain"
  144. nativeControls
  145. fullscreenOptions={{
  146. enable: true
  147. }}
  148. allowsPictureInPicture={true}
  149. />
  150. ) : null}
  151. {isBuffering && (
  152. <View
  153. style={{
  154. position: 'absolute',
  155. top: 0,
  156. left: 0,
  157. right: 0,
  158. bottom: 0,
  159. alignItems: 'center',
  160. justifyContent: 'center'
  161. }}
  162. >
  163. <ActivityIndicator
  164. size="large"
  165. color={leftMessage ? Colors.DARK_BLUE : Colors.FILL_LIGHT}
  166. />
  167. </View>
  168. )}
  169. {!hasStarted && !isBuffering && videoUri && (
  170. <TouchableOpacity
  171. style={{
  172. position: 'absolute',
  173. top: 0,
  174. left: 0,
  175. right: 0,
  176. bottom: 0,
  177. alignItems: 'center',
  178. justifyContent: 'center'
  179. }}
  180. onPress={handlePlayPress}
  181. onLongPress={() => onLongPress(currentMessage, props)}
  182. >
  183. <View style={{ backgroundColor: 'rgba(15, 63, 79, 0.4)', borderRadius: 50 }}>
  184. <MaterialCommunityIcons name="play" size={60} color={Colors.WHITE} />
  185. </View>
  186. </TouchableOpacity>
  187. )}
  188. </View>
  189. );
  190. };
  191. export default RenderMessageVideo;