Skip to content

Commit

Permalink
feat: new audio player (RocketChat#5160)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Nov 20, 2023
1 parent d6c37bf commit 0a75a66
Show file tree
Hide file tree
Showing 25 changed files with 760 additions and 360 deletions.
3 changes: 2 additions & 1 deletion __mocks__/react-native-mmkv-storage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()]));

Large diffs are not rendered by default.

Binary file modified android/app/src/main/assets/fonts/custom.ttf
Binary file not shown.
52 changes: 52 additions & 0 deletions app/containers/AudioPlayer/PlayButton.tsx
Original file line number Diff line number Diff line change
@@ -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;
32 changes: 32 additions & 0 deletions app/containers/AudioPlayer/PlaybackSpeed.tsx
Original file line number Diff line number Diff line change
@@ -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;
129 changes: 129 additions & 0 deletions app/containers/AudioPlayer/Seek.tsx
Original file line number Diff line number Diff line change
@@ -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;
13 changes: 13 additions & 0 deletions app/containers/AudioPlayer/constants.ts
Original file line number Diff line number Diff line change
@@ -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;
162 changes: 162 additions & 0 deletions app/containers/AudioPlayer/index.tsx
Original file line number Diff line number Diff line change
@@ -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;
Loading

0 comments on commit 0a75a66

Please sign in to comment.