mattermost-mobile/app/components/files/audio_file.tsx
Lucas Reis 8e854a8bdd
Implementing the Audio Playback/Download on the chat (#7900)
* feat: added new check for isAudio and added the supported mime types

* feat: adding the progress and audio on the audio file message

* feat: finishing the layout of the audio_file

* feat: play and pause audio

* feat: update the progress bar when audio is playing

* feat: update the timeframe of the audio

* feat: update with the new design

* feat: adding download and preview

* feat: creates a hook for the file download and preview

* feat: adding useCallback to make the return stable

* fix: iOS issue when playing in loop

* feat: adding localization for the error

* fix: removing code tha was inserted for debug

* feat: add a new line on the en.json

* fix: fixing types

* feat: adding the onSeek method inside the progress bar

* feat: changing progress value to animated value

* feat: changing to GestureDetector and making the seek method work

* feat: adding a touchable without feedback to prevent the audio to triggering other page

* feat: adding the drag on the seek method

* feat: making the download button more gray

* fix: fix tests

* fix: prevent onProgress from clearing the seconds when the audio is paused

* feat: clamping the position of the cursor to never be dragged offscreen

* feat: enhancing the experience of dragging the cursor on the progress bar

* feat: differentiate between the throttles

* feat: remvoing the aac audio files support

* feat: pausing if the focus has changed

* feat: render differently the audio file when in the files list

* refactor: optimize audio file component by using useCallback for event handlers

* refactor: extract stopPropagation function for better readability in AudioFile component

* feat: implement custom useThrottled hook and replace lodash throttle in AudioFile component

* refactor: update useThrottled hook to accept a generic callback type

* fix: loading of uri of audio files after merge

* feat: add audioFile style to enhance layout for audio files

* fix: tests
2025-02-19 11:17:30 +01:00

226 lines
7 KiB
TypeScript

// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {useIntl} from 'react-intl';
import {
View,
TouchableOpacity,
Text,
TouchableWithoutFeedback,
type GestureResponderEvent,
} from 'react-native';
import Video, {type OnLoadData, type OnProgressData, type OnVideoErrorData, type VideoRef} from 'react-native-video';
import {useTheme} from '@context/theme';
import {useDownloadFileAndPreview} from '@hooks/files';
import useThrottled from '@hooks/throttled';
import {alertDownloadDocumentDisabled} from '@utils/document';
import {logDebug} from '@utils/log';
import {changeOpacity, makeStyleSheetFromTheme} from '@utils/theme';
import {typography} from '@utils/typography';
import CompassIcon from '../compass_icon';
import FormattedText from '../formatted_text';
import ProgressBar from '../progress_bar';
type Props = {
file: FileInfo;
canDownloadFiles: boolean;
}
const getStyleSheet = makeStyleSheetFromTheme((theme: Theme) => ({
audioFileWrapper: {
position: 'relative',
borderColor: changeOpacity(theme.centerChannelColor, 0.2),
borderRadius: 4,
borderWidth: 1,
padding: 12,
overflow: 'hidden',
flexDirection: 'row',
gap: 12,
alignItems: 'center',
},
playButton: {
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: changeOpacity(theme.buttonBg, 0.12),
borderRadius: 100,
width: 40,
height: 40,
},
playIcon: {
color: theme.buttonBg,
},
downloadIcon: {
color: changeOpacity(theme.centerChannelColor, 0.56),
},
progressBar: {
flex: 1,
},
timerText: {
color: theme.centerChannelColor,
...typography('Body', 75, 'SemiBold'),
},
}));
const AudioFile = ({file, canDownloadFiles}: Props) => {
const intl = useIntl();
const theme = useTheme();
const style = getStyleSheet(theme);
const [hasPaused, setHasPaused] = useState<boolean>(true);
const [hasError, setHasError] = useState<boolean>(false);
const [hasEnded, setHasEnded] = useState<boolean>(false);
const [progress, setProgress] = useState<number>(0);
const [timeInMinutes, setTimeInMinutes] = useState<string>('0:00');
const [duration, setDuration] = useState<number>(0);
const videoRef = useRef<VideoRef>(null);
useEffect(() => {
if (hasEnded) {
const timer = setTimeout(() => {
setHasPaused(true);
setProgress(0);
setHasEnded(false);
}, 100);
return () => clearTimeout(timer);
}
return () => null;
}, [hasEnded]);
const source = useMemo(() => ({uri: file.uri}), [file.uri]);
const {toggleDownloadAndPreview} = useDownloadFileAndPreview();
const onPlayPress = () => {
setHasPaused(!hasPaused);
};
const onDownloadPress = useCallback(async () => {
if (!canDownloadFiles) {
alertDownloadDocumentDisabled(intl);
return;
}
toggleDownloadAndPreview(file);
}, [canDownloadFiles, intl, file, toggleDownloadAndPreview]);
const loadTimeInMinutes = useCallback((timeInSeconds: number) => {
const minutes = Math.floor(timeInSeconds / 60);
const seconds = Math.floor(timeInSeconds % 60);
setTimeInMinutes(`${minutes}:${seconds.toString().padStart(2, '0')}`);
}, []);
const onLoad = useCallback((loadData: OnLoadData) => {
loadTimeInMinutes(loadData.duration);
setDuration(loadData.duration);
}, [loadTimeInMinutes]);
const throttledLoadTimeOneSecond = useThrottled(loadTimeInMinutes, 1000);
const throttledLoadTime100Milliseconds = useThrottled(loadTimeInMinutes, 100);
const onProgress = useCallback((progressData: OnProgressData) => {
if (hasPaused) {
return;
}
const {currentTime, playableDuration} = progressData;
setProgress(currentTime / playableDuration);
throttledLoadTimeOneSecond(currentTime);
}, [hasPaused, throttledLoadTimeOneSecond]);
const onEnd = useCallback(() => {
if (videoRef.current) {
videoRef.current.seek(0);
}
setHasEnded(true);
}, []);
const onError = useCallback(({error}: OnVideoErrorData) => {
setHasError(true);
logDebug((error && typeof error === 'object' && 'errorString' in error) ? error.errorString : error);
}, []);
const onSeek = useCallback((seekPosition: number) => {
if (videoRef.current && duration > 0) {
const newTime = seekPosition * duration;
videoRef.current.seek(newTime);
setProgress(seekPosition);
throttledLoadTime100Milliseconds(newTime);
}
}, [duration, throttledLoadTime100Milliseconds]);
const onAudioFocusChanged = useCallback(({hasAudioFocus}: {hasAudioFocus: boolean}) => {
if (!hasAudioFocus) {
setHasPaused(true);
}
}, []);
const stopPropagation = useCallback((e: GestureResponderEvent) => {
e.stopPropagation();
}, []);
if (hasError) {
return (
<FormattedText
id={'audio.loading_error'}
defaultMessage={'Error loading audio.'}
/>
);
}
return (
<TouchableWithoutFeedback
onPress={stopPropagation}
>
<View style={style.audioFileWrapper}>
<TouchableOpacity
style={style.playButton}
onPress={onPlayPress}
>
<CompassIcon
name={hasPaused ? 'play' : 'pause'}
size={24}
style={style.playIcon}
/>
</TouchableOpacity>
<Video
ref={videoRef}
source={source}
paused={hasPaused}
onLoad={onLoad}
onProgress={onProgress}
onError={onError}
onEnd={onEnd}
onAudioFocusChanged={onAudioFocusChanged}
/>
<View style={style.progressBar}>
<ProgressBar
progress={progress}
color={theme.buttonBg}
withCursor={true}
onSeek={onSeek}
/>
</View>
<Text style={style.timerText}>{timeInMinutes}</Text>
<TouchableOpacity
onPress={onDownloadPress}
>
<CompassIcon
name='download-outline'
size={24}
style={style.downloadIcon}
/>
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
);
};
export default AudioFile;