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:
parent
d6c37bf4a2
commit
0a75a6615c
|
@ -20,4 +20,5 @@ export const IOSAccessibleStates = {
|
||||||
AFTER_FIRST_UNLOCK_THIS_DEVICE_ONLY: ''
|
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
Binary file not shown.
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -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;
|
|
@ -0,0 +1 @@
|
||||||
|
export type TAudioState = 'loading' | 'paused' | 'to-download' | 'playing';
|
|
@ -1,4 +1,7 @@
|
||||||
export const mappedIcons = {
|
export const mappedIcons = {
|
||||||
|
'play-shape-filled': 59842,
|
||||||
|
'pause-shape-filled': 59843,
|
||||||
|
'loading': 59844,
|
||||||
'add': 59872,
|
'add': 59872,
|
||||||
'administration': 59662,
|
'administration': 59662,
|
||||||
'adobe-reader-monochromatic': 59663,
|
'adobe-reader-monochromatic': 59663,
|
||||||
|
|
File diff suppressed because one or more lines are too long
|
@ -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;
|
|
@ -8,7 +8,6 @@ import Video from './Video';
|
||||||
import Reply from './Reply';
|
import Reply from './Reply';
|
||||||
import Button from '../Button';
|
import Button from '../Button';
|
||||||
import MessageContext from './Context';
|
import MessageContext from './Context';
|
||||||
import { useTheme } from '../../theme';
|
|
||||||
import { IAttachment, TGetCustomEmoji } from '../../definitions';
|
import { IAttachment, TGetCustomEmoji } from '../../definitions';
|
||||||
import CollapsibleQuote from './Components/CollapsibleQuote';
|
import CollapsibleQuote from './Components/CollapsibleQuote';
|
||||||
import openLink from '../../lib/methods/helpers/openLink';
|
import openLink from '../../lib/methods/helpers/openLink';
|
||||||
|
@ -56,7 +55,6 @@ const AttachedActions = ({ attachment, getCustomEmoji }: { attachment: IAttachme
|
||||||
|
|
||||||
const Attachments: React.FC<IMessageAttachments> = React.memo(
|
const Attachments: React.FC<IMessageAttachments> = React.memo(
|
||||||
({ attachments, timeFormat, showAttachment, style, getCustomEmoji, isReply, author }: IMessageAttachments) => {
|
({ attachments, timeFormat, showAttachment, style, getCustomEmoji, isReply, author }: IMessageAttachments) => {
|
||||||
const { theme } = useTheme();
|
|
||||||
const { translateLanguage } = useContext(MessageContext);
|
const { translateLanguage } = useContext(MessageContext);
|
||||||
|
|
||||||
if (!attachments || attachments.length === 0) {
|
if (!attachments || attachments.length === 0) {
|
||||||
|
@ -88,7 +86,6 @@ const Attachments: React.FC<IMessageAttachments> = React.memo(
|
||||||
getCustomEmoji={getCustomEmoji}
|
getCustomEmoji={getCustomEmoji}
|
||||||
isReply={isReply}
|
isReply={isReply}
|
||||||
style={style}
|
style={style}
|
||||||
theme={theme}
|
|
||||||
author={author}
|
author={author}
|
||||||
msg={msg}
|
msg={msg}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,218 +1,36 @@
|
||||||
import React from 'react';
|
import React, { useContext, useEffect, useState } from 'react';
|
||||||
import { StyleProp, StyleSheet, Text, TextStyle, View } from 'react-native';
|
import { StyleProp, TextStyle } 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 Touchable from './Touchable';
|
|
||||||
import Markdown from '../markdown';
|
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 MessageContext from './Context';
|
||||||
import ActivityIndicator from '../ActivityIndicator';
|
|
||||||
import { withDimensions } from '../../dimensions';
|
|
||||||
import { TGetCustomEmoji } from '../../definitions/IEmoji';
|
import { TGetCustomEmoji } from '../../definitions/IEmoji';
|
||||||
import { IApplicationState, IAttachment, IUserMessage } from '../../definitions';
|
import { IAttachment, IUserMessage } from '../../definitions';
|
||||||
import { TSupportedThemes, useTheme } from '../../theme';
|
import { TDownloadState, downloadMediaFile, getMediaCache } from '../../lib/methods/handleMediaDownload';
|
||||||
import { downloadMediaFile, getMediaCache } from '../../lib/methods/handleMediaDownload';
|
|
||||||
import EventEmitter from '../../lib/methods/helpers/events';
|
|
||||||
import { PAUSE_AUDIO } from './constants';
|
|
||||||
import { fetchAutoDownloadEnabled } from '../../lib/methods/autoDownloadPreference';
|
import { fetchAutoDownloadEnabled } from '../../lib/methods/autoDownloadPreference';
|
||||||
|
import AudioPlayer from '../AudioPlayer';
|
||||||
interface IButton {
|
import { useAppSelector } from '../../lib/hooks';
|
||||||
loading: boolean;
|
|
||||||
paused: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
onPress: () => void;
|
|
||||||
cached: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IMessageAudioProps {
|
interface IMessageAudioProps {
|
||||||
file: IAttachment;
|
file: IAttachment;
|
||||||
isReply?: boolean;
|
isReply?: boolean;
|
||||||
style?: StyleProp<TextStyle>[];
|
style?: StyleProp<TextStyle>[];
|
||||||
theme: TSupportedThemes;
|
|
||||||
getCustomEmoji: TGetCustomEmoji;
|
getCustomEmoji: TGetCustomEmoji;
|
||||||
scale?: number;
|
|
||||||
author?: IUserMessage;
|
author?: IUserMessage;
|
||||||
msg?: string;
|
msg?: string;
|
||||||
cdnPrefix?: string;
|
cdnPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IMessageAudioState {
|
const MessageAudio = ({ file, getCustomEmoji, author, isReply, style, msg }: IMessageAudioProps) => {
|
||||||
loading: boolean;
|
const [downloadState, setDownloadState] = useState<TDownloadState>('loading');
|
||||||
currentTime: number;
|
const [fileUri, setFileUri] = useState('');
|
||||||
duration: number;
|
|
||||||
paused: boolean;
|
|
||||||
cached: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mode = {
|
const { baseUrl, user, id, rid } = useContext(MessageContext);
|
||||||
allowsRecordingIOS: false,
|
|
||||||
playsInSilentModeIOS: true,
|
|
||||||
staysActiveInBackground: true,
|
|
||||||
shouldDuckAndroid: true,
|
|
||||||
playThroughEarpieceAndroid: false,
|
|
||||||
interruptionModeIOS: InterruptionModeIOS.DoNotMix,
|
|
||||||
interruptionModeAndroid: InterruptionModeAndroid.DoNotMix
|
|
||||||
};
|
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const { cdnPrefix } = useAppSelector(state => ({
|
||||||
audioContainer: {
|
cdnPrefix: state.settings.CDN_PREFIX as string
|
||||||
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 formatTime = (seconds: number) => moment.utc(seconds * 1000).format('mm:ss');
|
const getUrl = () => {
|
||||||
|
|
||||||
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;
|
|
||||||
let url = file.audio_url;
|
let url = file.audio_url;
|
||||||
if (url && !url.startsWith('http')) {
|
if (url && !url.startsWith('http')) {
|
||||||
url = `${cdnPrefix || baseUrl}${file.audio_url}`;
|
url = `${cdnPrefix || baseUrl}${file.audio_url}`;
|
||||||
|
@ -220,182 +38,84 @@ class MessageAudio extends React.Component<IMessageAudioProps, IMessageAudioStat
|
||||||
return url;
|
return url;
|
||||||
};
|
};
|
||||||
|
|
||||||
handleAutoDownload = async () => {
|
const onPlayButtonPress = () => {
|
||||||
const { author } = this.props;
|
if (downloadState === 'to-download') {
|
||||||
// @ts-ignore can't use declare to type this
|
handleDownload();
|
||||||
const { user } = this.context;
|
}
|
||||||
const url = this.getUrl();
|
};
|
||||||
|
|
||||||
|
const handleDownload = async () => {
|
||||||
|
setDownloadState('loading');
|
||||||
try {
|
try {
|
||||||
if (url) {
|
const url = getUrl();
|
||||||
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();
|
|
||||||
if (url) {
|
if (url) {
|
||||||
const audio = await downloadMediaFile({
|
const audio = await downloadMediaFile({
|
||||||
downloadUrl: `${url}?rc_uid=${user.id}&rc_token=${user.token}`,
|
downloadUrl: `${url}?rc_uid=${user.id}&rc_token=${user.token}`,
|
||||||
type: 'audio',
|
type: 'audio',
|
||||||
mimeType: file.audio_type
|
mimeType: file.audio_type
|
||||||
});
|
});
|
||||||
await this.sound.loadAsync({ uri: audio }, { androidImplementation: 'MediaPlayer' });
|
setFileUri(audio);
|
||||||
this.setState({ loading: false, cached: true });
|
setDownloadState('downloaded');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.setState({ loading: false, cached: false });
|
setDownloadState('to-download');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onPress = () => {
|
const handleAutoDownload = async () => {
|
||||||
const { cached, loading } = this.state;
|
const url = getUrl();
|
||||||
if (loading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (cached) {
|
|
||||||
this.togglePlayPause();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.handleDownload();
|
|
||||||
};
|
|
||||||
|
|
||||||
playPause = async () => {
|
|
||||||
const { paused } = this.state;
|
|
||||||
try {
|
try {
|
||||||
if (paused) {
|
if (url) {
|
||||||
await this.sound.pauseAsync();
|
const isCurrentUserAuthor = author?._id === user.id;
|
||||||
EventEmitter.removeListener(PAUSE_AUDIO, this.pauseSound);
|
const isAutoDownloadEnabled = fetchAutoDownloadEnabled('audioPreferenceDownload');
|
||||||
} else {
|
if (isAutoDownloadEnabled || isCurrentUserAuthor) {
|
||||||
EventEmitter.emit(PAUSE_AUDIO);
|
await handleDownload();
|
||||||
EventEmitter.addEventListener(PAUSE_AUDIO, this.pauseSound);
|
return;
|
||||||
await Audio.setAudioModeAsync(mode);
|
}
|
||||||
await this.sound.playAsync();
|
setDownloadState('to-download');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Do nothing
|
// Do nothing
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onValueChange = async (value: number) => {
|
useEffect(() => {
|
||||||
try {
|
const handleCache = async () => {
|
||||||
await this.sound.setPositionAsync(value * 1000);
|
const cachedAudioResult = await getMediaCache({
|
||||||
} catch {
|
type: 'audio',
|
||||||
// Do nothing
|
mimeType: file.audio_type,
|
||||||
}
|
urlToCache: getUrl()
|
||||||
};
|
});
|
||||||
|
if (cachedAudioResult?.exists) {
|
||||||
|
setFileUri(cachedAudioResult.uri);
|
||||||
|
setDownloadState('downloaded');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isReply) {
|
||||||
|
setDownloadState('to-download');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await handleAutoDownload();
|
||||||
|
};
|
||||||
|
handleCache();
|
||||||
|
}, []);
|
||||||
|
|
||||||
render() {
|
if (!baseUrl) {
|
||||||
const { loading, paused, currentTime, duration, cached } = this.state;
|
return null;
|
||||||
const { msg, getCustomEmoji, theme, scale, isReply, style } = this.props;
|
|
||||||
// @ts-ignore can't use declare to type this
|
|
||||||
const { baseUrl, user } = this.context;
|
|
||||||
|
|
||||||
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
|
|
||||||
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}
|
|
||||||
/>
|
|
||||||
<Text style={[styles.duration, { color: themes[theme].auxiliaryText }]}>{this.duration}</Text>
|
|
||||||
</View>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
return (
|
||||||
|
<>
|
||||||
|
<Markdown msg={msg} style={[isReply && style]} username={user.username} getCustomEmoji={getCustomEmoji} />
|
||||||
|
<AudioPlayer
|
||||||
|
msgId={id}
|
||||||
|
fileUri={fileUri}
|
||||||
|
downloadState={downloadState}
|
||||||
|
disabled={isReply}
|
||||||
|
onPlayButtonPress={onPlayButtonPress}
|
||||||
|
rid={rid}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: IApplicationState) => ({
|
export default MessageAudio;
|
||||||
cdnPrefix: state.settings.CDN_PREFIX as string
|
|
||||||
});
|
|
||||||
|
|
||||||
export default connect(mapStateToProps)(withDimensions(MessageAudio));
|
|
||||||
|
|
|
@ -1,3 +1,2 @@
|
||||||
export const DISCUSSION = 'discussion';
|
export const DISCUSSION = 'discussion';
|
||||||
export const THREAD = 'thread';
|
export const THREAD = 'thread';
|
||||||
export const PAUSE_AUDIO = 'pause_audio';
|
|
||||||
|
|
|
@ -403,6 +403,8 @@ class MessageContainer extends React.Component<IMessageContainerProps, IMessageC
|
||||||
return (
|
return (
|
||||||
<MessageContext.Provider
|
<MessageContext.Provider
|
||||||
value={{
|
value={{
|
||||||
|
id,
|
||||||
|
rid,
|
||||||
user,
|
user,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
onPress: this.onPressAction,
|
onPress: this.onPressAction,
|
||||||
|
|
|
@ -102,6 +102,14 @@ export const colors = {
|
||||||
statusBackgroundWarning: '#FFECAD',
|
statusBackgroundWarning: '#FFECAD',
|
||||||
statusFontOnWarning: '#B88D00',
|
statusFontOnWarning: '#B88D00',
|
||||||
overlayColor: '#1F2329B2',
|
overlayColor: '#1F2329B2',
|
||||||
|
buttonBackgroundPrimaryDefault: '#156FF5',
|
||||||
|
buttonBackgroundSecondaryDefault: '#E4E7EA',
|
||||||
|
buttonFontPrimary: '#FFFFFF',
|
||||||
|
buttonFontSecondary: '#1F2329',
|
||||||
|
fontDefault: '#2F343D',
|
||||||
|
strokeExtraLight: '#EBECEF',
|
||||||
|
strokeLight: '#CBCED1',
|
||||||
|
surfaceTint: '#F7F8FA',
|
||||||
...mentions,
|
...mentions,
|
||||||
...callButtons
|
...callButtons
|
||||||
},
|
},
|
||||||
|
@ -181,6 +189,14 @@ export const colors = {
|
||||||
statusBackgroundWarning: '#FFECAD',
|
statusBackgroundWarning: '#FFECAD',
|
||||||
statusFontOnWarning: '#B88D00',
|
statusFontOnWarning: '#B88D00',
|
||||||
overlayColor: '#1F2329B2',
|
overlayColor: '#1F2329B2',
|
||||||
|
buttonBackgroundPrimaryDefault: '#3976D1',
|
||||||
|
buttonBackgroundSecondaryDefault: '#2F343D',
|
||||||
|
buttonFontPrimary: '#FFFFFF',
|
||||||
|
buttonFontSecondary: '#E4E7EA',
|
||||||
|
fontDefault: '#E4E7EA',
|
||||||
|
strokeExtraLight: '#2F343D',
|
||||||
|
strokeLight: '#333842',
|
||||||
|
surfaceTint: '#1F2329',
|
||||||
...mentions,
|
...mentions,
|
||||||
...callButtons
|
...callButtons
|
||||||
},
|
},
|
||||||
|
@ -260,6 +276,14 @@ export const colors = {
|
||||||
statusBackgroundWarning: '#FFECAD',
|
statusBackgroundWarning: '#FFECAD',
|
||||||
statusFontOnWarning: '#B88D00',
|
statusFontOnWarning: '#B88D00',
|
||||||
overlayColor: '#1F2329B2',
|
overlayColor: '#1F2329B2',
|
||||||
|
buttonBackgroundPrimaryDefault: '#3976D1',
|
||||||
|
buttonBackgroundSecondaryDefault: '#2F343D',
|
||||||
|
buttonFontPrimary: '#FFFFFF',
|
||||||
|
buttonFontSecondary: '#E4E7EA',
|
||||||
|
fontDefault: '#E4E7EA',
|
||||||
|
strokeExtraLight: '#2F343D',
|
||||||
|
strokeLight: '#333842',
|
||||||
|
surfaceTint: '#1F2329',
|
||||||
...mentions,
|
...mentions,
|
||||||
...callButtons
|
...callButtons
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
@ -8,6 +8,8 @@ import log from './helpers/log';
|
||||||
|
|
||||||
export type MediaTypes = 'audio' | 'image' | 'video';
|
export type MediaTypes = 'audio' | 'image' | 'video';
|
||||||
|
|
||||||
|
export type TDownloadState = 'to-download' | 'loading' | 'downloaded';
|
||||||
|
|
||||||
const defaultType = {
|
const defaultType = {
|
||||||
audio: 'mp3',
|
audio: 'mp3',
|
||||||
image: 'jpg',
|
image: 'jpg',
|
||||||
|
|
|
@ -37,6 +37,7 @@ export * from './crashReport';
|
||||||
export * from './parseSettings';
|
export * from './parseSettings';
|
||||||
export * from './subscribeRooms';
|
export * from './subscribeRooms';
|
||||||
export * from './serializeAsciiUrl';
|
export * from './serializeAsciiUrl';
|
||||||
|
export * from './audioPlayer';
|
||||||
export * from './isRoomFederated';
|
export * from './isRoomFederated';
|
||||||
export * from './checkSupportedVersions';
|
export * from './checkSupportedVersions';
|
||||||
export * from './getServerInfo';
|
export * from './getServerInfo';
|
||||||
|
|
|
@ -34,6 +34,7 @@ import {
|
||||||
} from '../../definitions';
|
} from '../../definitions';
|
||||||
import { Services } from '../../lib/services';
|
import { Services } from '../../lib/services';
|
||||||
import { TNavigation } from '../../stacks/stackType';
|
import { TNavigation } from '../../stacks/stackType';
|
||||||
|
import audioPlayer from '../../lib/methods/audioPlayer';
|
||||||
|
|
||||||
interface IMessagesViewProps {
|
interface IMessagesViewProps {
|
||||||
user: {
|
user: {
|
||||||
|
@ -100,6 +101,10 @@ class MessagesView extends React.Component<IMessagesViewProps, IMessagesViewStat
|
||||||
this.load();
|
this.load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
componentWillUnmount(): void {
|
||||||
|
audioPlayer.pauseCurrentAudio();
|
||||||
|
}
|
||||||
|
|
||||||
shouldComponentUpdate(nextProps: IMessagesViewProps, nextState: IMessagesViewState) {
|
shouldComponentUpdate(nextProps: IMessagesViewProps, nextState: IMessagesViewState) {
|
||||||
const { loading, messages, fileLoading } = this.state;
|
const { loading, messages, fileLoading } = this.state;
|
||||||
const { theme } = this.props;
|
const { theme } = this.props;
|
||||||
|
|
|
@ -92,6 +92,7 @@ import {
|
||||||
import { Services } from '../../lib/services';
|
import { Services } from '../../lib/services';
|
||||||
import { withActionSheet, IActionSheetProvider } from '../../containers/ActionSheet';
|
import { withActionSheet, IActionSheetProvider } from '../../containers/ActionSheet';
|
||||||
import { goRoom, TGoRoomItem } from '../../lib/methods/helpers/goRoom';
|
import { goRoom, TGoRoomItem } from '../../lib/methods/helpers/goRoom';
|
||||||
|
import audioPlayer from '../../lib/methods/audioPlayer';
|
||||||
import { IListContainerRef, TListRef } from './List/definitions';
|
import { IListContainerRef, TListRef } from './List/definitions';
|
||||||
|
|
||||||
type TStateAttrsUpdate = keyof IRoomViewState;
|
type TStateAttrsUpdate = keyof IRoomViewState;
|
||||||
|
@ -227,6 +228,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
|
||||||
cancel: () => void;
|
cancel: () => void;
|
||||||
};
|
};
|
||||||
private sub?: RoomClass;
|
private sub?: RoomClass;
|
||||||
|
private unsubscribeBlur?: () => void;
|
||||||
|
|
||||||
constructor(props: IRoomViewProps) {
|
constructor(props: IRoomViewProps) {
|
||||||
super(props);
|
super(props);
|
||||||
|
@ -304,6 +306,7 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
const { navigation } = this.props;
|
||||||
this.mounted = true;
|
this.mounted = true;
|
||||||
this.didMountInteraction = InteractionManager.runAfterInteractions(() => {
|
this.didMountInteraction = InteractionManager.runAfterInteractions(() => {
|
||||||
const { isAuthenticated } = this.props;
|
const { isAuthenticated } = this.props;
|
||||||
|
@ -330,6 +333,10 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
EventEmitter.addEventListener('ROOM_REMOVED', this.handleRoomRemoved);
|
EventEmitter.addEventListener('ROOM_REMOVED', this.handleRoomRemoved);
|
||||||
|
// TODO: Refactor when audio becomes global
|
||||||
|
this.unsubscribeBlur = navigation.addListener('blur', () => {
|
||||||
|
audioPlayer.pauseCurrentAudio();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldComponentUpdate(nextProps: IRoomViewProps, nextState: IRoomViewState) {
|
shouldComponentUpdate(nextProps: IRoomViewProps, nextState: IRoomViewState) {
|
||||||
|
@ -446,8 +453,15 @@ class RoomView extends React.Component<IRoomViewProps, IRoomViewState> {
|
||||||
if (this.retryInitTimeout) {
|
if (this.retryInitTimeout) {
|
||||||
clearTimeout(this.retryInitTimeout);
|
clearTimeout(this.retryInitTimeout);
|
||||||
}
|
}
|
||||||
|
if (this.unsubscribeBlur) {
|
||||||
|
this.unsubscribeBlur();
|
||||||
|
}
|
||||||
EventEmitter.removeListener('connected', this.handleConnected);
|
EventEmitter.removeListener('connected', this.handleConnected);
|
||||||
EventEmitter.removeListener('ROOM_REMOVED', this.handleRoomRemoved);
|
EventEmitter.removeListener('ROOM_REMOVED', this.handleRoomRemoved);
|
||||||
|
if (!this.tmid) {
|
||||||
|
// TODO: Refactor when audio becomes global
|
||||||
|
await audioPlayer.unloadRoomAudios(this.rid);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
canForwardGuest = async () => {
|
canForwardGuest = async () => {
|
||||||
|
|
BIN
ios/custom.ttf
BIN
ios/custom.ttf
Binary file not shown.
|
@ -42,11 +42,17 @@ jest.mock('expo-haptics', () => jest.fn(() => null));
|
||||||
|
|
||||||
jest.mock('./app/lib/database', () => jest.fn(() => null));
|
jest.mock('./app/lib/database', () => jest.fn(() => null));
|
||||||
|
|
||||||
const mockedNavigate = jest.fn();
|
|
||||||
|
|
||||||
jest.mock('@react-navigation/native', () => ({
|
jest.mock('@react-navigation/native', () => ({
|
||||||
...jest.requireActual('@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', () => ({
|
jest.mock('react-native-notifications', () => ({
|
||||||
|
@ -81,3 +87,8 @@ jest.mock('react-native-math-view', () => {
|
||||||
MathText: react.View // {...} Named export
|
MathText: react.View // {...} Named export
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
jest.mock('expo-av', () => ({
|
||||||
|
InterruptionModeIOS: { DoNotMix: 1 },
|
||||||
|
InterruptionModeAndroid: { DoNotMix: 1 }
|
||||||
|
}));
|
||||||
|
|
Loading…
Reference in New Issue