Repository navigation
feat: new audio player #5160
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: new audio player #5160
Changes from all commits
fb664a1
ab56ecd
871dc7f
f5d6fce
b73cf46
7fc975f
d138a74
27eacaa
07e0970
cdad288
991cdf1
389057a
6c49c57
acb280f
7108e35
d3c87a1
370f893
154deb6
f136dfb
95cbe25
aa2c288
02ba6e9
99973c0
ce67d05
41bce65
789ee3e
74e6e8f
4dcb128
58f73e1
67ba313
1dc038d
c244dd4
6f3dc60
8c0c1d7
989e609
420bd49
b341528
b4a9b8a
07f0fd5
c1fbc20
e7244d9
ce48ef0
63b49ab
e0942b2
332e51f
469d42b
2d2988a
e0b86c0
a92d88c
1be2326
b6ae55e
b886031
d39a5c7
53ed61d
c85f989
b3f4605
b6dceec
3072196
fb7da4f
f5c3c7a
edd4fb6
42507b6
6ed1c7e
abb7df7
8ed68ef
5b57b35
65346b0
c1b16d0
71ec969
7e81d41
3e75699
3ada875
fb576ef
892e7ba
29b61e0
c469d0f
425522a
186ab33
f344c9e
5bb14cb
dfa3688
40b416f
cd91664
55afcaf
7e65c9f
bf77b6c
9c00ce0
9066432
defd97c
7900fad
927225c
e50e59d
b88c3b1
1462582
a026f2b
fe18a1a
67b3200
0738f60
8cab77f
e84a5b3
e735cef
9854f16
9094b73
182af31
8b053f6
2b379ee
037a74f
86daf8c
24ccc33
f447757
5625d53
c157552
023cd8f
23e029d
80f4f68
dcf6c76
b1e0e12
174ca91
8468c1c
0154c5a
eb33ce3
780156f
3805bc9
6b6904e
10216f5
7e11357
9b3986f
5910c8b
339769b
db2061c
602d660
5b0bca8
4a9c83a
d2bb297
669224b
00fbe7b
fb78b5c
f63d631
64a17ed
13b42ff
ca985e8
8a79d66
2a7b49d
4f0a92a
32b6b46
f59d475
a6990d5
657f7a5
f897e3d
2b75b4b
1b6d50b
0b62210
d08f5d2
4bbe553
f48532e
13a82f4
11f3b21
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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; |
| 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; |
| 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; | ||
| 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; |
| 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]); | ||
|
Comment on lines
+132
to
+140
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Need this to keep the ex: Screen.Recording.2023-10-05.at.16.49.04.mov
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
|
|
||
| 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; | ||
Uh oh!
There was an error while loading. Please reload this page.