feat: new audio player (#5160)

* feat: media auto-download view

* media auto download view completed and saving the settings in mmkv

* audio download preference

* audio auto download when the user who sent the audio is the same logged on mobile

* creation of isAutoDownloadEnabled, evaluate hist hook, Image Full Size preload done

* minor tweak audio show play button after download

* refactor audioFile to handleMediaDownload and fixed the audio download

* desestructured params to download too

* image download and autoDownload, algo fix the formatAttachmentUrl to show the image from local

* add the possibility to cancel image download and clear local images

* refactor blur component

* video download and auto download, also keeped the behavior to download unsuportted videos to the gallery

* add the possibility to start downloading a video, then exit the room, back again to room and cancel the video previously downloading

* remove the custom hook for autoDownload

* remove blurcomponent, fix the blur style in image.tsx, minor tweak video function name

* send messageId to video

* introducing the reducer to keep the downloads in progress

* create a media download selector

* remove all the redux stuff and do the same as file upload

* video download behavior

* done for image and audio

* fix the try catch download media

* clean up

* image container uiKit

* fix lint

* change rn-fetch-blob to expo-filesystem

* add pt-br

* pass the correct message id when there is an attachment on reply

* refactor some changes requested

* fix audio and move the netInfo from autoDownloadPreference to redux

* variable isAutoDownloadEnable name and handleMediaDownload getExtension

* message/Image refactored, change the component to show the image from FastImage to Image

* refactor handleMediaDownload and deleteMedia

* minor tweak

* refactor audio

* refactor video

* fix the type on the messagesView(the view of files)

* minor tweak

* fix the name of searchMediaFIleAsync's result

* minor tweak, add the default behavior, add the OFF as label

* minor tweaks

* verify if the media auto download exists on settings view

* fix media auto download view layout and minor tweak wifi

* avoid auto download from reply

* minor tweak at comment

* tweak list.section

* change the name to netInfoState and Local_document_directory

* remove mediaType and refactor audio and image

* separate blurview

* thumbnail video and video behavior

* add Audio to i18n and minor tweak

* set the blur as always dark and add the possibility to overlay

* don't need to controle the filepath in the view

* fix the loading in image and video at begin

* save the file with a similar filename as expected

* removed the necessity of messageId or id

* minor tweak

* switch useLayoutEffect to useEffect

* avoid onpress do some edge case because of cached at video

* minor tweak

* tweak at audio comment extension

* minor tweak type userpreferences

* remove test id from mediaAutoDownloadView

* change action's name to SET_NET_INFO_STATE

* caching and deleting video's thumbnails

* remove generate thumbnail

* minor tweak in image

* update camera-roll and save the file from local url

* remove local_cache_directory and deleteThumbnail

* update blur to fix error on android

* first commit

* fix togglePlayPause

* separate audio to a folder inside components and minor tweak attachment

* created the slider with text

* play/pause button, currentTime equal the sound, can change the slider and reflect to the sound

* play/pause, track is working and onEnd

* update the icons with play-shaped-filled, pause-shape-filled, loading

* start the tweaks on layout

* can play multiple audios, pausing the previous to execute the new one

* loading animated

* added the audio rate

* layout fixed

* removed the sound manipulation from Slider to manipulate only in the index

* fix time margin horizontal

* fix play 2 audios and play/pause properly

* change the way we treat the audio

* remove audio copy

* minor tweak

* fix rate state

* remove the PAUSE_AUDIO

* fix unloadAll, add hit slop to slider, show the duration on the first render

* refactor colors to be the same as figmas name

* change the class' name and add the method pauseCurrentAudio

* pause audio when unmount a RoomView and unloadAll when focusing at RoomsListView

* pause audio when entering a thread

* fix where call the pauseCurrentAudio

* moved the player from messageAudio to audioPlayer

* refactor audio component

* remove loading

* update snapshot

* fix colors name

* pauseAudio when roomview is blur

* moved audio from message/component/audio to message/Audio

* add navigation focus to AudioPlayer component and fix the jest

* add the { androidImplementation: 'MediaPlayer' }

* fix action sheet swipe 02-room

* fix action sheet swipe 05-threads

* tweak touchable

* remove react.memo from playbutton

* hitSlop

* speed playback from array

* textinputprops

* tweak at names

* minor tweak at onEnd

* minor tweak at names

* update styles

* thumb seek size

* change marginBottom

* add the clamp, adjust the thumb position, remove the necessity of OnEndGestureHandler

* change the utils to constants

* change to audioState

* fix the seek for android

* TDownloadState

* speed array

* pause audio from messagesView when open the files

* update test

* minor tweak

* change the time after ony one click, fixes the thumb to move sync with the click

* Fix seek

* minor tweak Sound to Audio.Sound

* name of Icon

* enable PlaybackSpeed only when playing the audio

* playbackSpeed to mmkv

* mock implementation

* create native button

* minor tweak

* minor tweaks

* playbackSpeed after loadAudio

* avoid show the error when try to setRate without audio

* add messageID to differ audios inside a quote/forward from original one

* unloadRoomAudios instead of unloadAllAudios inside the roomsListView

* minor tweak

---------

Co-authored-by: Diego Mello <diegolmello@gmail.com>
Co-authored-by: Gleidson Daniel Silva <gleidson10daniel@hotmail.com>
This commit is contained in:
Reinaldo Neto 2023-11-20 11:13:15 -03:00 committed by GitHub
parent d6c37bf4a2
commit 0a75a6615c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 763 additions and 363 deletions

View File

@ -20,4 +20,5 @@ export const IOSAccessibleStates = {
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: ''
};
export const create = jest.fn();
// fix the useUserPreference hook
export const create = jest.fn().mockImplementation(() => jest.fn().mockImplementation(() => [0, jest.fn()]));

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,52 @@
import React from 'react';
import { CustomIcon } from '../CustomIcon';
import { useTheme } from '../../theme';
import styles from './styles';
import RCActivityIndicator from '../ActivityIndicator';
import { AUDIO_BUTTON_HIT_SLOP } from './constants';
import { TAudioState } from './types';
import NativeButton from '../NativeButton';
interface IButton {
disabled?: boolean;
onPress: () => void;
audioState: TAudioState;
}
type TCustomIconName = 'arrow-down' | 'play-shape-filled' | 'pause-shape-filled';
const Icon = ({ audioState, disabled }: { audioState: TAudioState; disabled: boolean }) => {
const { colors } = useTheme();
if (audioState === 'loading') {
return <RCActivityIndicator />;
}
let customIconName: TCustomIconName = 'arrow-down';
if (audioState === 'playing') {
customIconName = 'pause-shape-filled';
}
if (audioState === 'paused') {
customIconName = 'play-shape-filled';
}
return <CustomIcon name={customIconName} size={24} color={disabled ? colors.tintDisabled : colors.buttonFontPrimary} />;
};
const PlayButton = ({ onPress, disabled = false, audioState }: IButton) => {
const { colors } = useTheme();
return (
<NativeButton
style={[styles.playPauseButton, { backgroundColor: colors.buttonBackgroundPrimaryDefault }]}
disabled={disabled}
onPress={onPress}
hitSlop={AUDIO_BUTTON_HIT_SLOP}
>
<Icon audioState={audioState} disabled={disabled} />
</NativeButton>
);
};
export default PlayButton;

View File

@ -0,0 +1,32 @@
import React from 'react';
import { Text } from 'react-native';
import styles from './styles';
import { useTheme } from '../../theme';
import { AUDIO_PLAYBACK_SPEED, AVAILABLE_SPEEDS } from './constants';
import { TAudioState } from './types';
import { useUserPreferences } from '../../lib/methods';
import NativeButton from '../NativeButton';
const PlaybackSpeed = ({ audioState }: { audioState: TAudioState }) => {
const [playbackSpeed, setPlaybackSpeed] = useUserPreferences<number>(AUDIO_PLAYBACK_SPEED, AVAILABLE_SPEEDS[1]);
const { colors } = useTheme();
const onPress = () => {
const speedIndex = AVAILABLE_SPEEDS.indexOf(playbackSpeed);
const nextSpeedIndex = speedIndex + 1 >= AVAILABLE_SPEEDS.length ? 0 : speedIndex + 1;
setPlaybackSpeed(AVAILABLE_SPEEDS[nextSpeedIndex]);
};
return (
<NativeButton
disabled={audioState !== 'playing'}
onPress={onPress}
style={[styles.containerPlaybackSpeed, { backgroundColor: colors.buttonBackgroundSecondaryDefault }]}
>
<Text style={[styles.playbackSpeedText, { color: colors.buttonFontSecondary }]}>{playbackSpeed}x</Text>
</NativeButton>
);
};
export default PlaybackSpeed;

View File

@ -0,0 +1,129 @@
import React from 'react';
import { LayoutChangeEvent, View, TextInput, TextInputProps, TouchableNativeFeedback } from 'react-native';
import { PanGestureHandler, PanGestureHandlerGestureEvent } from 'react-native-gesture-handler';
import Animated, {
SharedValue,
runOnJS,
useAnimatedGestureHandler,
useAnimatedProps,
useAnimatedStyle,
useDerivedValue,
useSharedValue
} from 'react-native-reanimated';
import styles from './styles';
import { useTheme } from '../../theme';
import { SEEK_HIT_SLOP, THUMB_SEEK_SIZE, ACTIVE_OFFSET_X, DEFAULT_TIME_LABEL } from './constants';
const AnimatedTextInput = Animated.createAnimatedComponent(TextInput);
interface ISeek {
duration: SharedValue<number>;
currentTime: SharedValue<number>;
loaded: boolean;
onChangeTime: (time: number) => Promise<void>;
}
function clamp(value: number, min: number, max: number) {
'worklet';
return Math.min(Math.max(value, min), max);
}
// https://docs.swmansion.com/react-native-reanimated/docs/2.x/fundamentals/worklets/
const formatTime = (ms: number) => {
'worklet';
const minutes = Math.floor(ms / 60);
const remainingSeconds = Math.floor(ms % 60);
const formattedMinutes = String(minutes).padStart(2, '0');
const formattedSeconds = String(remainingSeconds).padStart(2, '0');
return `${formattedMinutes}:${formattedSeconds}`;
};
const Seek = ({ currentTime, duration, loaded = false, onChangeTime }: ISeek) => {
const { colors } = useTheme();
const maxWidth = useSharedValue(1);
const translateX = useSharedValue(0);
const timeLabel = useSharedValue(DEFAULT_TIME_LABEL);
const scale = useSharedValue(1);
const isPanning = useSharedValue(false);
const styleLine = useAnimatedStyle(() => ({
width: translateX.value
}));
const styleThumb = useAnimatedStyle(() => ({
transform: [{ translateX: translateX.value - THUMB_SEEK_SIZE / 2 }, { scale: scale.value }]
}));
const onLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout;
maxWidth.value = width;
};
const onGestureEvent = useAnimatedGestureHandler<PanGestureHandlerGestureEvent, { offsetX: number }>({
onStart: (event, ctx) => {
isPanning.value = true;
ctx.offsetX = translateX.value;
},
onActive: ({ translationX }, ctx) => {
translateX.value = clamp(ctx.offsetX + translationX, 0, maxWidth.value);
scale.value = 1.3;
},
onFinish() {
scale.value = 1;
isPanning.value = false;
runOnJS(onChangeTime)(Math.round(currentTime.value * 1000));
}
});
useDerivedValue(() => {
if (isPanning.value) {
// When the user is panning, always the currentTime.value is been set different from the currentTime provided by
// the audio in progress
currentTime.value = (translateX.value * duration.value) / maxWidth.value || 0;
} else {
translateX.value = (currentTime.value * maxWidth.value) / duration.value || 0;
}
timeLabel.value = formatTime(currentTime.value);
}, [translateX, maxWidth, duration, isPanning, currentTime]);
const timeLabelAnimatedProps = useAnimatedProps(() => {
if (currentTime.value !== 0) {
return {
text: timeLabel.value
} as TextInputProps;
}
return {
text: formatTime(duration.value)
} as TextInputProps;
}, [timeLabel, duration, currentTime]);
const thumbColor = loaded ? colors.buttonBackgroundPrimaryDefault : colors.tintDisabled;
// TouchableNativeFeedback is avoiding do a long press message when seeking the audio
return (
<TouchableNativeFeedback>
<View style={styles.seekContainer}>
<AnimatedTextInput
defaultValue={DEFAULT_TIME_LABEL}
editable={false}
style={[styles.duration, { color: colors.fontDefault }]}
animatedProps={timeLabelAnimatedProps}
/>
<View style={styles.seek} onLayout={onLayout}>
<View style={[styles.line, { backgroundColor: colors.strokeLight }]}>
<Animated.View style={[styles.line, styleLine, { backgroundColor: colors.buttonBackgroundPrimaryDefault }]} />
</View>
<PanGestureHandler enabled={loaded} onGestureEvent={onGestureEvent} activeOffsetX={[-ACTIVE_OFFSET_X, ACTIVE_OFFSET_X]}>
<Animated.View hitSlop={SEEK_HIT_SLOP} style={[styles.thumbSeek, { backgroundColor: thumbColor }, styleThumb]} />
</PanGestureHandler>
</View>
</View>
</TouchableNativeFeedback>
);
};
export default Seek;

View File

@ -0,0 +1,13 @@
export const AVAILABLE_SPEEDS = [0.5, 1, 1.5, 2];
export const AUDIO_BUTTON_HIT_SLOP = { top: 8, right: 8, bottom: 8, left: 8 };
export const SEEK_HIT_SLOP = { top: 12, right: 8, bottom: 12, left: 8 };
export const THUMB_SEEK_SIZE = 12;
export const AUDIO_PLAYBACK_SPEED = 'audioPlaybackSpeed';
export const DEFAULT_TIME_LABEL = '00:00';
export const ACTIVE_OFFSET_X = 0.001;

View File

@ -0,0 +1,162 @@
import React, { useEffect, useRef, useState } from 'react';
import { InteractionManager, View } from 'react-native';
import { AVPlaybackStatus } from 'expo-av';
import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake';
import { useSharedValue } from 'react-native-reanimated';
import { useNavigation } from '@react-navigation/native';
import { useTheme } from '../../theme';
import styles from './styles';
import Seek from './Seek';
import PlaybackSpeed from './PlaybackSpeed';
import PlayButton from './PlayButton';
import audioPlayer from '../../lib/methods/audioPlayer';
import { AUDIO_PLAYBACK_SPEED, AVAILABLE_SPEEDS } from './constants';
import { TDownloadState } from '../../lib/methods/handleMediaDownload';
import { TAudioState } from './types';
import { useUserPreferences } from '../../lib/methods';
interface IAudioPlayerProps {
fileUri: string;
disabled?: boolean;
onPlayButtonPress?: Function;
downloadState: TDownloadState;
rid: string;
// It's optional when comes from MessagesView
msgId?: string;
}
const AudioPlayer = ({
fileUri,
disabled = false,
onPlayButtonPress = () => {},
downloadState,
msgId,
rid
}: IAudioPlayerProps) => {
const isLoading = downloadState === 'loading';
const isDownloaded = downloadState === 'downloaded';
const [playbackSpeed] = useUserPreferences<number>(AUDIO_PLAYBACK_SPEED, AVAILABLE_SPEEDS[1]);
const [paused, setPaused] = useState(true);
const duration = useSharedValue(0);
const currentTime = useSharedValue(0);
const { colors } = useTheme();
const audioUri = useRef<string>('');
const navigation = useNavigation();
const onPlaybackStatusUpdate = (status: AVPlaybackStatus) => {
if (status) {
onPlaying(status);
handlePlaybackStatusUpdate(status);
onEnd(status);
}
};
const onPlaying = (data: AVPlaybackStatus) => {
if (data.isLoaded && data.isPlaying) {
setPaused(false);
} else {
setPaused(true);
}
};
const handlePlaybackStatusUpdate = (data: AVPlaybackStatus) => {
if (data.isLoaded && data.durationMillis) {
const durationSeconds = data.durationMillis / 1000;
duration.value = durationSeconds > 0 ? durationSeconds : 0;
const currentSecond = data.positionMillis / 1000;
if (currentSecond <= durationSeconds) {
currentTime.value = currentSecond;
}
}
};
const onEnd = (data: AVPlaybackStatus) => {
if (data.isLoaded && data.didJustFinish) {
try {
setPaused(true);
currentTime.value = 0;
} catch {
// do nothing
}
}
};
const setPosition = async (time: number) => {
await audioPlayer.setPositionAsync(audioUri.current, time);
};
const togglePlayPause = async () => {
try {
if (!paused) {
await audioPlayer.pauseAudio(audioUri.current);
} else {
await audioPlayer.playAudio(audioUri.current);
}
} catch {
// Do nothing
}
};
useEffect(() => {
audioPlayer.setRateAsync(audioUri.current, playbackSpeed);
}, [playbackSpeed]);
const onPress = () => {
onPlayButtonPress();
if (isLoading) {
return;
}
if (isDownloaded) {
togglePlayPause();
}
};
useEffect(() => {
InteractionManager.runAfterInteractions(async () => {
audioUri.current = await audioPlayer.loadAudio({ msgId, rid, uri: fileUri });
audioPlayer.setOnPlaybackStatusUpdate(audioUri.current, onPlaybackStatusUpdate);
audioPlayer.setRateAsync(audioUri.current, playbackSpeed);
});
}, [fileUri]);
useEffect(() => {
if (paused) {
deactivateKeepAwake();
} else {
activateKeepAwake();
}
}, [paused]);
useEffect(() => {
const unsubscribeFocus = navigation.addListener('focus', () => {
audioPlayer.setOnPlaybackStatusUpdate(audioUri.current, onPlaybackStatusUpdate);
});
return () => {
unsubscribeFocus();
};
}, [navigation]);
let audioState: TAudioState = 'to-download';
if (isLoading) {
audioState = 'loading';
}
if (isDownloaded && paused) {
audioState = 'paused';
}
if (isDownloaded && !paused) {
audioState = 'playing';
}
return (
<View style={[styles.audioContainer, { backgroundColor: colors.surfaceTint, borderColor: colors.strokeExtraLight }]}>
<PlayButton disabled={disabled} audioState={audioState} onPress={onPress} />
<Seek currentTime={currentTime} duration={duration} loaded={!disabled && isDownloaded} onChangeTime={setPosition} />
{audioState === 'playing' ? <PlaybackSpeed audioState={audioState} /> : null}
</View>
);
};
export default AudioPlayer;

View File

@ -0,0 +1,68 @@
import { StyleSheet } from 'react-native';
import sharedStyles from '../../views/Styles';
import { THUMB_SEEK_SIZE } from './constants';
const styles = StyleSheet.create({
audioContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
height: 56,
borderWidth: 1,
borderRadius: 4,
marginBottom: 8
},
playPauseButton: {
alignItems: 'center',
marginLeft: 16,
height: 32,
width: 32,
borderRadius: 4,
justifyContent: 'center'
},
seekContainer: {
flexDirection: 'row',
flex: 1,
alignItems: 'center',
height: '100%'
},
seek: {
marginRight: 12,
height: '100%',
justifyContent: 'center',
flex: 1
},
line: {
height: 4,
borderRadius: 2,
width: '100%'
},
duration: {
marginHorizontal: 12,
fontVariant: ['tabular-nums'],
fontSize: 14,
...sharedStyles.textRegular
},
thumbSeek: {
height: THUMB_SEEK_SIZE,
width: THUMB_SEEK_SIZE,
borderRadius: THUMB_SEEK_SIZE / 2,
position: 'absolute'
},
containerPlaybackSpeed: {
width: 36,
height: 24,
borderRadius: 4,
marginRight: 16,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden'
},
playbackSpeedText: {
fontSize: 14,
...sharedStyles.textBold
}
});
export default styles;

View File

@ -0,0 +1 @@
export type TAudioState = 'loading' | 'paused' | 'to-download' | 'playing';

View File

@ -1,4 +1,7 @@
export const mappedIcons = {
'play-shape-filled': 59842,
'pause-shape-filled': 59843,
'loading': 59844,
'add': 59872,
'administration': 59662,
'adobe-reader-monochromatic': 59663,

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,18 @@
import React from 'react';
import { TouchableNativeFeedback, TouchableOpacity, TouchableOpacityProps, View } from 'react-native';
import { isIOS } from '../../lib/methods/helpers';
const NativeButton = (props: TouchableOpacityProps) => {
if (isIOS) {
return <TouchableOpacity {...props}>{props.children}</TouchableOpacity>;
}
return (
<TouchableNativeFeedback {...props}>
<View style={props.style}>{props.children}</View>
</TouchableNativeFeedback>
);
};
export default NativeButton;

View File

@ -8,7 +8,6 @@ import Video from './Video';
import Reply from './Reply';
import Button from '../Button';
import MessageContext from './Context';
import { useTheme } from '../../theme';
import { IAttachment, TGetCustomEmoji } from '../../definitions';
import CollapsibleQuote from './Components/CollapsibleQuote';
import openLink from '../../lib/methods/helpers/openLink';
@ -56,7 +55,6 @@ const AttachedActions = ({ attachment, getCustomEmoji }: { attachment: IAttachme
const Attachments: React.FC<IMessageAttachments> = React.memo(
({ attachments, timeFormat, showAttachment, style, getCustomEmoji, isReply, author }: IMessageAttachments) => {
const { theme } = useTheme();
const { translateLanguage } = useContext(MessageContext);
if (!attachments || attachments.length === 0) {
@ -88,7 +86,6 @@ const Attachments: React.FC<IMessageAttachments> = React.memo(
getCustomEmoji={getCustomEmoji}
isReply={isReply}
style={style}
theme={theme}
author={author}
msg={msg}
/>

View File

@ -1,218 +1,36 @@
import React from 'react';
import { StyleProp, StyleSheet, Text, TextStyle, View } from 'react-native';
import { Audio, AVPlaybackStatus, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av';
import Slider from '@react-native-community/slider';
import moment from 'moment';
import { dequal } from 'dequal';
import { activateKeepAwake, deactivateKeepAwake } from 'expo-keep-awake';
import { Sound } from 'expo-av/build/Audio/Sound';
import { connect } from 'react-redux';
import React, { useContext, useEffect, useState } from 'react';
import { StyleProp, TextStyle } from 'react-native';
import Touchable from './Touchable';
import Markdown from '../markdown';
import { CustomIcon } from '../CustomIcon';
import sharedStyles from '../../views/Styles';
import { themes } from '../../lib/constants';
import { isAndroid, isIOS } from '../../lib/methods/helpers';
import MessageContext from './Context';
import ActivityIndicator from '../ActivityIndicator';
import { withDimensions } from '../../dimensions';
import { TGetCustomEmoji } from '../../definitions/IEmoji';
import { IApplicationState, IAttachment, IUserMessage } from '../../definitions';
import { TSupportedThemes, useTheme } from '../../theme';
import { downloadMediaFile, getMediaCache } from '../../lib/methods/handleMediaDownload';
import EventEmitter from '../../lib/methods/helpers/events';
import { PAUSE_AUDIO } from './constants';
import { IAttachment, IUserMessage } from '../../definitions';
import { TDownloadState, downloadMediaFile, getMediaCache } from '../../lib/methods/handleMediaDownload';
import { fetchAutoDownloadEnabled } from '../../lib/methods/autoDownloadPreference';
interface IButton {
loading: boolean;
paused: boolean;
disabled?: boolean;
onPress: () => void;
cached: boolean;
}
import AudioPlayer from '../AudioPlayer';
import { useAppSelector } from '../../lib/hooks';
interface IMessageAudioProps {
file: IAttachment;
isReply?: boolean;
style?: StyleProp<TextStyle>[];
theme: TSupportedThemes;
getCustomEmoji: TGetCustomEmoji;
scale?: number;
author?: IUserMessage;
msg?: string;
cdnPrefix?: string;
}
interface IMessageAudioState {
loading: boolean;
currentTime: number;
duration: number;
paused: boolean;
cached: boolean;
}
const MessageAudio = ({ file, getCustomEmoji, author, isReply, style, msg }: IMessageAudioProps) => {
const [downloadState, setDownloadState] = useState<TDownloadState>('loading');
const [fileUri, setFileUri] = useState('');
const mode = {
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
staysActiveInBackground: true,
shouldDuckAndroid: true,
playThroughEarpieceAndroid: false,
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix
};
const { baseUrl, user, id, rid } = useContext(MessageContext);
const styles = StyleSheet.create({
audioContainer: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
height: 56,
borderWidth: 1,
borderRadius: 4,
marginBottom: 6
},
playPauseButton: {
marginHorizontal: 10,
alignItems: 'center',
backgroundColor: 'transparent'
},
audioLoading: {
marginHorizontal: 8
},
slider: {
flex: 1
},
duration: {
marginHorizontal: 12,
fontSize: 14,
...sharedStyles.textRegular
}
});
const { cdnPrefix } = useAppSelector(state => ({
cdnPrefix: state.settings.CDN_PREFIX as string
}));
const formatTime = (seconds: number) => moment.utc(seconds * 1000).format('mm:ss');
const BUTTON_HIT_SLOP = { top: 12, right: 12, bottom: 12, left: 12 };
const Button = React.memo(({ loading, paused, onPress, disabled, cached }: IButton) => {
const { colors } = useTheme();
let customIconName: 'arrow-down-circle' | 'play-filled' | 'pause-filled' = 'arrow-down-circle';
if (cached) {
customIconName = paused ? 'play-filled' : 'pause-filled';
}
return (
<Touchable
style={styles.playPauseButton}
disabled={disabled}
onPress={onPress}
hitSlop={BUTTON_HIT_SLOP}
background={Touchable.SelectableBackgroundBorderless()}
>
{loading ? (
<ActivityIndicator style={[styles.playPauseButton, styles.audioLoading]} />
) : (
<CustomIcon name={customIconName} size={36} color={disabled ? colors.tintDisabled : colors.tintColor} />
)}
</Touchable>
);
});
Button.displayName = 'MessageAudioButton';
class MessageAudio extends React.Component<IMessageAudioProps, IMessageAudioState> {
static contextType = MessageContext;
private sound: Sound;
constructor(props: IMessageAudioProps) {
super(props);
this.state = {
loading: true,
currentTime: 0,
duration: 0,
paused: true,
cached: false
};
this.sound = new Audio.Sound();
this.sound.setOnPlaybackStatusUpdate(this.onPlaybackStatusUpdate);
}
pauseSound = () => {
EventEmitter.removeListener(PAUSE_AUDIO, this.pauseSound);
this.togglePlayPause();
};
async componentDidMount() {
const { file, isReply } = this.props;
const cachedAudioResult = await getMediaCache({
type: 'audio',
mimeType: file.audio_type,
urlToCache: this.getUrl()
});
if (cachedAudioResult?.exists) {
await this.sound.loadAsync({ uri: cachedAudioResult.uri }, { androidImplementation: 'MediaPlayer' });
this.setState({ loading: false, cached: true });
return;
}
if (isReply) {
this.setState({ loading: false });
return;
}
await this.handleAutoDownload();
}
shouldComponentUpdate(nextProps: IMessageAudioProps, nextState: IMessageAudioState) {
const { currentTime, duration, paused, loading, cached } = this.state;
const { file, theme } = this.props;
if (nextProps.theme !== theme) {
return true;
}
if (nextState.currentTime !== currentTime) {
return true;
}
if (nextState.duration !== duration) {
return true;
}
if (nextState.paused !== paused) {
return true;
}
if (!dequal(nextProps.file, file)) {
return true;
}
if (nextState.loading !== loading) {
return true;
}
if (nextState.cached !== cached) {
return true;
}
return false;
}
componentDidUpdate() {
const { paused } = this.state;
if (paused) {
deactivateKeepAwake();
} else {
activateKeepAwake();
}
}
async componentWillUnmount() {
EventEmitter.removeListener(PAUSE_AUDIO, this.pauseSound);
try {
await this.sound.stopAsync();
} catch {
// Do nothing
}
}
getUrl = () => {
const { file, cdnPrefix } = this.props;
// @ts-ignore can't use declare to type this
const { baseUrl } = this.context;
const getUrl = () => {
let url = file.audio_url;
if (url && !url.startsWith('http')) {
url = `${cdnPrefix || baseUrl}${file.audio_url}`;
@ -220,182 +38,84 @@ class MessageAudio extends React.Component<IMessageAudioProps, IMessageAudioStat
return url;
};
handleAutoDownload = async () => {
const { author } = this.props;
// @ts-ignore can't use declare to type this
const { user } = this.context;
const url = this.getUrl();
const onPlayButtonPress = () => {
if (downloadState === 'to-download') {
handleDownload();
}
};
const handleDownload = async () => {
setDownloadState('loading');
try {
if (url) {
const isCurrentUserAuthor = author?._id === user.id;
const isAutoDownloadEnabled = fetchAutoDownloadEnabled('audioPreferenceDownload');
if (isAutoDownloadEnabled || isCurrentUserAuthor) {
await this.handleDownload();
return;
}
this.setState({ loading: false, cached: false });
}
} catch {
// Do nothing
}
};
onPlaybackStatusUpdate = (status: AVPlaybackStatus) => {
if (status) {
this.onLoad(status);
this.onProgress(status);
this.onEnd(status);
}
};
onLoad = (data: AVPlaybackStatus) => {
if (data.isLoaded && data.durationMillis) {
const duration = data.durationMillis / 1000;
this.setState({ duration: duration > 0 ? duration : 0 });
}
};
onProgress = (data: AVPlaybackStatus) => {
if (data.isLoaded) {
const { duration } = this.state;
const currentTime = data.positionMillis / 1000;
if (currentTime <= duration) {
this.setState({ currentTime });
}
}
};
onEnd = async (data: AVPlaybackStatus) => {
if (data.isLoaded) {
if (data.didJustFinish) {
try {
await this.sound.stopAsync();
this.setState({ paused: true, currentTime: 0 });
EventEmitter.removeListener(PAUSE_AUDIO, this.pauseSound);
} catch {
// do nothing
}
}
}
};
get duration() {
const { currentTime, duration } = this.state;
return formatTime(currentTime || duration);
}
togglePlayPause = () => {
const { paused } = this.state;
this.setState({ paused: !paused }, this.playPause);
};
handleDownload = async () => {
const { file } = this.props;
// @ts-ignore can't use declare to type this
const { user } = this.context;
this.setState({ loading: true });
try {
const url = this.getUrl();
const url = getUrl();
if (url) {
const audio = await downloadMediaFile({
downloadUrl: `${url}?rc_uid=${user.id}&rc_token=${user.token}`,
type: 'audio',
mimeType: file.audio_type
});
await this.sound.loadAsync({ uri: audio }, { androidImplementation: 'MediaPlayer' });
this.setState({ loading: false, cached: true });
setFileUri(audio);
setDownloadState('downloaded');
}
} catch {
this.setState({ loading: false, cached: false });
setDownloadState('to-download');
}
};
onPress = () => {
const { cached, loading } = this.state;
if (loading) {
return;
}
if (cached) {
this.togglePlayPause();
return;
}
this.handleDownload();
};
playPause = async () => {
const { paused } = this.state;
const handleAutoDownload = async () => {
const url = getUrl();
try {
if (paused) {
await this.sound.pauseAsync();
EventEmitter.removeListener(PAUSE_AUDIO, this.pauseSound);
} else {
EventEmitter.emit(PAUSE_AUDIO);
EventEmitter.addEventListener(PAUSE_AUDIO, this.pauseSound);
await Audio.setAudioModeAsync(mode);
await this.sound.playAsync();
if (url) {
const isCurrentUserAuthor = author?._id === user.id;
const isAutoDownloadEnabled = fetchAutoDownloadEnabled('audioPreferenceDownload');
if (isAutoDownloadEnabled || isCurrentUserAuthor) {
await handleDownload();
return;
}
setDownloadState('to-download');
}
} catch {
// Do nothing
}
};
onValueChange = async (value: number) => {
try {
await this.sound.setPositionAsync(value * 1000);
} catch {
// Do nothing
useEffect(() => {
const handleCache = async () => {
const cachedAudioResult = await getMediaCache({
type: 'audio',
mimeType: file.audio_type,
urlToCache: getUrl()
});
if (cachedAudioResult?.exists) {
setFileUri(cachedAudioResult.uri);
setDownloadState('downloaded');
return;
}
if (isReply) {
setDownloadState('to-download');
return;
}
await handleAutoDownload();
};
render() {
const { loading, paused, currentTime, duration, cached } = this.state;
const { msg, getCustomEmoji, theme, scale, isReply, style } = this.props;
// @ts-ignore can't use declare to type this
const { baseUrl, user } = this.context;
handleCache();
}, []);
if (!baseUrl) {
return null;
}
let thumbColor;
if (isAndroid && isReply) {
thumbColor = themes[theme].tintDisabled;
} else if (isAndroid) {
thumbColor = themes[theme].tintColor;
}
return (
<>
<Markdown msg={msg} style={[isReply && style]} username={user.username} getCustomEmoji={getCustomEmoji} theme={theme} />
<View
style={[
styles.audioContainer,
{ backgroundColor: themes[theme].chatComponentBackground, borderColor: themes[theme].borderColor }
]}
>
<Button disabled={isReply} loading={loading} paused={paused} cached={cached} onPress={this.onPress} />
<Slider
<Markdown msg={msg} style={[isReply && style]} username={user.username} getCustomEmoji={getCustomEmoji} />
<AudioPlayer
msgId={id}
fileUri={fileUri}
downloadState={downloadState}
disabled={isReply}
style={styles.slider}
value={currentTime}
maximumValue={duration}
minimumValue={0}
thumbTintColor={thumbColor}
minimumTrackTintColor={themes[theme].tintColor}
maximumTrackTintColor={themes[theme].auxiliaryText}
onValueChange={this.onValueChange}
thumbImage={isIOS ? { uri: 'audio_thumb', scale } : undefined}
onPlayButtonPress={onPlayButtonPress}
rid={rid}
/>
<Text style={[styles.duration, { color: themes[theme].auxiliaryText }]}>{this.duration}</Text>
</View>
</>
);
}
}
};
const mapStateToProps = (state: IApplicationState) => ({
cdnPrefix: state.settings.CDN_PREFIX as string
});
export default connect(mapStateToProps)(withDimensions(MessageAudio));
export default MessageAudio;

View File

@ -1,3 +1,2 @@
export const DISCUSSION = 'discussion';
export const THREAD = 'thread';
export const PAUSE_AUDIO = 'pause_audio';

View File

@ -403,6 +403,8 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
return (
<MessageContext.Provider
value={{
id,
rid,
user,
baseUrl,
onPress: this.onPressAction,

View File

@ -102,6 +102,14 @@ export const colors = {
statusBackgroundWarning: '#FFECAD',
statusFontOnWarning: '#B88D00',
overlayColor: '#1F2329B2',
buttonBackgroundPrimaryDefault: '#156FF5',
buttonBackgroundSecondaryDefault: '#E4E7EA',
buttonFontPrimary: '#FFFFFF',
buttonFontSecondary: '#1F2329',
fontDefault: '#2F343D',
strokeExtraLight: '#EBECEF',
strokeLight: '#CBCED1',
surfaceTint: '#F7F8FA',
...mentions,
...callButtons
},
@ -181,6 +189,14 @@ export const colors = {
statusBackgroundWarning: '#FFECAD',
statusFontOnWarning: '#B88D00',
overlayColor: '#1F2329B2',
buttonBackgroundPrimaryDefault: '#3976D1',
buttonBackgroundSecondaryDefault: '#2F343D',
buttonFontPrimary: '#FFFFFF',
buttonFontSecondary: '#E4E7EA',
fontDefault: '#E4E7EA',
strokeExtraLight: '#2F343D',
strokeLight: '#333842',
surfaceTint: '#1F2329',
...mentions,
...callButtons
},
@ -260,6 +276,14 @@ export const colors = {
statusBackgroundWarning: '#FFECAD',
statusFontOnWarning: '#B88D00',
overlayColor: '#1F2329B2',
buttonBackgroundPrimaryDefault: '#3976D1',
buttonBackgroundSecondaryDefault: '#2F343D',
buttonFontPrimary: '#FFFFFF',
buttonFontSecondary: '#E4E7EA',
fontDefault: '#E4E7EA',
strokeExtraLight: '#2F343D',
strokeLight: '#333842',
surfaceTint: '#1F2329',
...mentions,
...callButtons
}

View File

@ -0,0 +1,146 @@
import { AVPlaybackStatus, Audio, InterruptionModeAndroid, InterruptionModeIOS } from 'expo-av';
const AUDIO_MODE = {
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
staysActiveInBackground: true,
shouldDuckAndroid: true,
playThroughEarpieceAndroid: false,
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix
};
class AudioPlayer {
private audioQueue: { [audioKey: string]: Audio.Sound };
private audioPlaying: string;
constructor() {
this.audioQueue = {};
this.audioPlaying = '';
}
async loadAudio({ msgId, rid, uri }: { rid: string; msgId?: string; uri: string }): Promise<string> {
const audioKey = `${msgId}-${rid}-${uri}`;
if (this.audioQueue[audioKey]) {
return audioKey;
}
const { sound } = await Audio.Sound.createAsync({ uri }, { androidImplementation: 'MediaPlayer' });
this.audioQueue[audioKey] = sound;
return audioKey;
}
onPlaybackStatusUpdate(audioKey: string, status: AVPlaybackStatus, callback: (status: AVPlaybackStatus) => void) {
if (status) {
callback(status);
this.onEnd(audioKey, status);
}
}
async onEnd(audioKey: string, status: AVPlaybackStatus) {
if (status.isLoaded) {
if (status.didJustFinish) {
try {
await this.audioQueue[audioKey]?.stopAsync();
this.audioPlaying = '';
} catch {
// do nothing
}
}
}
}
setOnPlaybackStatusUpdate(audioKey: string, callback: (status: AVPlaybackStatus) => void): void {
return this.audioQueue[audioKey]?.setOnPlaybackStatusUpdate(status =>
this.onPlaybackStatusUpdate(audioKey, status, callback)
);
}
async playAudio(audioKey: string) {
if (this.audioPlaying) {
await this.pauseAudio(this.audioPlaying);
}
await Audio.setAudioModeAsync(AUDIO_MODE);
await this.audioQueue[audioKey]?.playAsync();
this.audioPlaying = audioKey;
}
async pauseAudio(audioKey: string) {
await this.audioQueue[audioKey]?.pauseAsync();
this.audioPlaying = '';
}
async pauseCurrentAudio() {
if (this.audioPlaying) {
await this.pauseAudio(this.audioPlaying);
}
}
async setPositionAsync(audioKey: string, time: number) {
try {
await this.audioQueue[audioKey]?.setPositionAsync(time);
} catch {
// Do nothing
}
}
async setRateAsync(audioKey: string, value = 1.0) {
try {
await this.audioQueue[audioKey].setRateAsync(value, true);
} catch {
// Do nothing
}
}
async unloadAudio(audioKey: string) {
await this.audioQueue[audioKey]?.stopAsync();
await this.audioQueue[audioKey]?.unloadAsync();
delete this.audioQueue[audioKey];
this.audioPlaying = '';
}
async unloadCurrentAudio() {
if (this.audioPlaying) {
await this.unloadAudio(this.audioPlaying);
}
}
async unloadRoomAudios(rid?: string) {
if (!rid) {
return;
}
const regExp = new RegExp(rid);
const roomAudioKeysLoaded = Object.keys(this.audioQueue).filter(audioKey => regExp.test(audioKey));
const roomAudiosLoaded = roomAudioKeysLoaded.map(key => this.audioQueue[key]);
try {
await Promise.all(
roomAudiosLoaded.map(async audio => {
await audio?.stopAsync();
await audio?.unloadAsync();
})
);
} catch {
// Do nothing
}
roomAudioKeysLoaded.forEach(key => delete this.audioQueue[key]);
this.audioPlaying = '';
}
async unloadAllAudios() {
const audiosLoaded = Object.values(this.audioQueue);
try {
await Promise.all(
audiosLoaded.map(async audio => {
await audio?.stopAsync();
await audio?.unloadAsync();
})
);
} catch {
// Do nothing
}
this.audioPlaying = '';
this.audioQueue = {};
}
}
const audioPlayer = new AudioPlayer();
export default audioPlayer;

View File

@ -8,6 +8,8 @@ import log from './helpers/log';
export type MediaTypes = 'audio' | 'image' | 'video';
export type TDownloadState = 'to-download' | 'loading' | 'downloaded';
const defaultType = {
audio: 'mp3',
image: 'jpg',

View File

@ -37,6 +37,7 @@ export * from './crashReport';
export * from './parseSettings';
export * from './subscribeRooms';
export * from './serializeAsciiUrl';
export * from './audioPlayer';
export * from './isRoomFederated';
export * from './checkSupportedVersions';
export * from './getServerInfo';

View File

@ -34,6 +34,7 @@ import {
} from '../../definitions';
import { Services } from '../../lib/services';
import { TNavigation } from '../../stacks/stackType';
import audioPlayer from '../../lib/methods/audioPlayer';
interface IMessagesViewProps {
user: {
@ -100,6 +101,10 @@ class MessagesView extends React.Component<IMessagesViewProps, IMessagesViewStat
this.load();
}
componentWillUnmount(): void {
audioPlayer.pauseCurrentAudio();
}
shouldComponentUpdate(nextProps: IMessagesViewProps, nextState: IMessagesViewState) {
const { loading, messages, fileLoading } = this.state;
const { theme } = this.props;

View File

@ -92,6 +92,7 @@ import {
import { Services } from '../../lib/services';
import { withActionSheet, IActionSheetProvider } from '../../containers/ActionSheet';
import { goRoom, TGoRoomItem } from '../../lib/methods/helpers/goRoom';
import audioPlayer from '../../lib/methods/audioPlayer';
import { IListContainerRef, TListRef } from './List/definitions';
type TStateAttrsUpdate = keyof IRoomViewState;
@ -227,6 +228,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
cancel: () => void;
};
private sub?: RoomClass;
private unsubscribeBlur?: () => void;
constructor(props: IRoomViewProps) {
super(props);
@ -304,6 +306,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
}
componentDidMount() {
const { navigation } = this.props;
this.mounted = true;
this.didMountInteraction = InteractionManager.runAfterInteractions(() => {
const { isAuthenticated } = this.props;
@ -330,6 +333,10 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
}
});
EventEmitter.addEventListener('ROOM_REMOVED', this.handleRoomRemoved);
// TODO: Refactor when audio becomes global
this.unsubscribeBlur = navigation.addListener('blur', () => {
audioPlayer.pauseCurrentAudio();
});
}
shouldComponentUpdate(nextProps: IRoomViewProps, nextState: IRoomViewState) {
@ -446,8 +453,15 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
if (this.retryInitTimeout) {
clearTimeout(this.retryInitTimeout);
}
if (this.unsubscribeBlur) {
this.unsubscribeBlur();
}
EventEmitter.removeListener('connected', this.handleConnected);
EventEmitter.removeListener('ROOM_REMOVED', this.handleRoomRemoved);
if (!this.tmid) {
// TODO: Refactor when audio becomes global
await audioPlayer.unloadRoomAudios(this.rid);
}
}
canForwardGuest = async () => {

Binary file not shown.

View File

@ -42,11 +42,17 @@ jest.mock('expo-haptics', () => jest.fn(() => null));
jest.mock('./app/lib/database', () => jest.fn(() => null));
const mockedNavigate = jest.fn();
jest.mock('@react-navigation/native', () => ({
...jest.requireActual('@react-navigation/native'),
useNavigation: () => mockedNavigate
useNavigation: () => ({
navigate: jest.fn(),
addListener: jest.fn().mockImplementation((event, callback) => {
callback();
return {
remove: jest.fn()
};
})
})
}));
jest.mock('react-native-notifications', () => ({
@ -81,3 +87,8 @@ jest.mock('react-native-math-view', () => {
MathText: react.View // {...} Named export
};
});
jest.mock('expo-av', () => ({
InterruptionModeIOS: { DoNotMix: 1 },
InterruptionModeAndroid: { DoNotMix: 1 }
}));