diff --git a/README.md b/README.md index 8f75eed..063be27 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,31 @@ const ref = useRef(null); console.log(playerState)} + onPanStateChange={isMoving=>console.log(isMoving)} +/>; +``` + +When you want to show a waveform for a external audio URL, you need to use `static` mode for the waveform and set isExternalUrl to true. + +Check the example below for more information. + +```tsx +import { Waveform, type IWaveformRef } from '@simform_solutions/react-native-audio-waveform'; + +const url = 'https://dl.espressif.com/dl/audio/gs-16b-2c-44100hz.mp3'; // URL to the audio file for which you want to show waveform +const ref = useRef(null); +console.log(state)} + onDownloadProgressChange={progress=>console.log(progress)} candleSpace={2} candleWidth={4} scrubColor="white" @@ -127,6 +151,9 @@ You can check out the full example at [Example](./example/src/App.tsx). | mode\* | - | ✅ | ✅ | 'live' or 'static' | Type of waveform. It can be either `static` for the resource file or `live` if you want to record audio | | ref\* | - | ✅ | ✅ | IWaveformRef | Type of ref provided to waveform component. If waveform mode is `static`, some methods from ref will throw error and same for `live`.
Check [IWaveformRef](#iwaveformref-methods) for more details about which methods these refs provides. | | path\* | - | ✅ | ❌ | string | Used for `static` type. It is the resource path of an audio source file. | +| volume | 3 | ✅ | ❌ | number | Used for `static` type. It is a volume level for the media player, ranging from 1 to 10. | +| isExternalUrl | false | ✅ | ❌ | boolean | Used for `static` type. If the resource path of an audio file is a URL, then pass true; otherwise, pass false. | +| downloadExternalAudio | true | ✅ | ❌ | boolean | Used for `static` type. Indicates whether the external media should be downloaded. | | candleSpace | 2 | ✅ | ✅ | number | Space between two candlesticks of waveform | | candleWidth | 5 | ✅ | ✅ | number | Width of single candlestick of waveform | | candleHeightScale | 3 | ✅ | ✅ | number | Scaling height of candlestick of waveform | @@ -138,6 +165,8 @@ You can check out the full example at [Example](./example/src/App.tsx). | onRecorderStateChange | - | ❌ | ✅ | ( recorderState : RecorderState ) => void | callback function which returns the recorder state whenever the recorder state changes. Check RecorderState for more details | | onCurrentProgressChange | - | ✅ | ❌ | ( currentProgress : number, songDuration: number ) => void | callback function, which returns current progress of audio and total song duration. | | onChangeWaveformLoadState | - | ✅ | ❌ | ( state : boolean ) => void | callback function which returns the loading state of waveform candlestick. | +| onDownloadStateChange | - | ✅ | ❌ | ( state : boolean ) => void | A callback function that returns the loading state of a file download from an external URL. | +| onDownloadProgressChange | - | ✅ | ❌ | ( currentProgress : number ) => void | Used when isExternalUrl is true; a callback function that returns the current progress of a file download from an external URL | | onError | - | ✅ | ❌ | ( error : Error ) => void | callback function which returns the error for static audio waveform | ##### Know more about [ViewStyle](https://reactnative.dev/docs/view-style-props), [PlayerState](#playerstate), and [RecorderState](#recorderstate) diff --git a/example/src/App.tsx b/example/src/App.tsx index c4b0699..c8cc1be 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -13,6 +13,7 @@ import { Pressable, ScrollView, StatusBar, + TouchableOpacity, View, } from 'react-native'; import { @@ -41,16 +42,20 @@ const ListItem = React.memo( currentPlaying, setCurrentPlaying, onPanStateChange, + isExternalUrl = false, }: { item: ListItem; currentPlaying: string; setCurrentPlaying: Dispatch>; onPanStateChange: (value: boolean) => void; + isExternalUrl?: boolean; }) => { const ref = useRef(null); const [playerState, setPlayerState] = useState(PlayerState.stopped); const styles = stylesheet({ currentUser: item.fromCurrentUser }); const [isLoading, setIsLoading] = useState(true); + const [downloadExternalAudio, setDownloadExternalAudio] = useState(false); + const [isAudioDownloaded, setIsAudioDownloaded] = useState(false); const handleButtonAction = () => { if (playerState === PlayerState.stopped) { @@ -69,7 +74,16 @@ const ListItem = React.memo( }, [currentPlaying]); return ( - + - {isLoading ? ( + {(isLoading && (!isExternalUrl || isAudioDownloaded)) || + (isExternalUrl && downloadExternalAudio && !isAudioDownloaded) ? ( ) : ( { setPlayerState(state); if ( @@ -116,10 +132,20 @@ const ListItem = React.memo( setCurrentPlaying(''); } }} + isExternalUrl={isExternalUrl} onPanStateChange={onPanStateChange} onError={error => { console.log(error, 'we are in example'); }} + onDownloadStateChange={state => { + console.log('Download State', state); + }} + onDownloadProgressChange={progress => { + console.log('Download Progress', `${progress}%`); + if (progress === 100) { + setIsAudioDownloaded(true); + } + }} onCurrentProgressChange={(currentProgress, songDuration) => { console.log( 'currentProgress ', @@ -134,6 +160,15 @@ const ListItem = React.memo( /> + {isExternalUrl && !downloadExternalAudio ? ( + setDownloadExternalAudio(true)}> + + + ) : null} ); } @@ -249,6 +284,7 @@ const AppContainer = () => { currentPlaying={currentPlaying} setCurrentPlaying={setCurrentPlaying} item={item} + isExternalUrl={item.isExternalUrl} onPanStateChange={value => setShouldScroll(!value)} /> ))} diff --git a/example/src/assets/icons/download.png b/example/src/assets/icons/download.png new file mode 100644 index 0000000..6948d47 Binary files /dev/null and b/example/src/assets/icons/download.png differ diff --git a/example/src/assets/icons/index.ts b/example/src/assets/icons/index.ts index 5300a39..d304770 100644 --- a/example/src/assets/icons/index.ts +++ b/example/src/assets/icons/index.ts @@ -3,4 +3,5 @@ export const Icons = { stop: require('./stop.png'), simform: require('./simform.png'), mic: require('./mic.png'), + download: require('./download.png'), }; diff --git a/example/src/constants/Audios.ts b/example/src/constants/Audios.ts index 4ae6b64..ca50fb7 100644 --- a/example/src/constants/Audios.ts +++ b/example/src/constants/Audios.ts @@ -5,6 +5,7 @@ import { globalMetrics } from '../../src/theme'; export interface ListItem { fromCurrentUser: boolean; path: string; + isExternalUrl?: boolean; } /** @@ -69,6 +70,11 @@ const audioAssetArray = [ 'file_example_mp3_15s.mp3', ]; +const externalAudioAssetArray = [ + 'https://codeskulptor-demos.commondatastorage.googleapis.com/GalaxyInvaders/theme_01.mp3', + 'https://codeskulptor-demos.commondatastorage.googleapis.com/pang/paza-moduless.mp3', +]; + /** * Generate a list of file objects with information about successfully copied files (Android) * or all files (iOS). @@ -78,8 +84,18 @@ export const generateAudioList = async (): Promise => { const audioAssets = await copyFilesToNativeResources(); // Generate the final list based on the copied or available files - return audioAssets?.map?.((value, index) => ({ + const localAssetList = audioAssets?.map?.((value, index) => ({ fromCurrentUser: index % 2 !== 0, path: `${filePath}/${value}`, })); + + const externalAudioList: ListItem[] = externalAudioAssetArray.map( + (value, index) => ({ + fromCurrentUser: index % 2 !== 0, + path: value, + isExternalUrl: true, + }) + ); + + return [...localAssetList, ...externalAudioList]; }; diff --git a/example/src/styles.ts b/example/src/styles.ts index a62f002..8cb485c 100644 --- a/example/src/styles.ts +++ b/example/src/styles.ts @@ -37,10 +37,12 @@ const styles = (params: StyleSheetParams = {}) => }, listItemContainer: { marginTop: scale(16), - alignItems: params.currentUser ? 'flex-end' : 'flex-start', + flexDirection: 'row', + justifyContent: params.currentUser ? 'flex-end' : 'flex-start', + alignItems: 'center', }, listItemWidth: { - width: '90%', + width: '89%', }, buttonImage: { height: '100%', @@ -90,6 +92,13 @@ const styles = (params: StyleSheetParams = {}) => loadingText: { color: Colors.black, }, + downloadIcon: { + width: 20, + height: 20, + tintColor: Colors.pink, + marginLeft: 10, + marginRight: 10, + }, }); export default styles; diff --git a/package.json b/package.json index 2736127..24d7ecc 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ ] }, "dependencies": { - "lodash": "^4.17.21" + "lodash": "^4.17.21", + "rn-fetch-blob": "^0.12.0" } -} \ No newline at end of file +} diff --git a/src/components/Waveform/Waveform.tsx b/src/components/Waveform/Waveform.tsx index 9c4cd37..8835b3a 100644 --- a/src/components/Waveform/Waveform.tsx +++ b/src/components/Waveform/Waveform.tsx @@ -37,12 +37,15 @@ import { type LiveWaveform, type StaticWaveform, } from './WaveformTypes'; +import RNFetchBlob from 'rn-fetch-blob'; export const Waveform = forwardRef((props, ref) => { const { mode, path, volume = 3, + isExternalUrl = false, + downloadExternalAudio = true, candleSpace = 2, candleWidth = 5, containerStyle = {}, @@ -55,9 +58,15 @@ export const Waveform = forwardRef((props, ref) => { onCurrentProgressChange = () => {}, candleHeightScale = 3, onChangeWaveformLoadState, + onDownloadStateChange, + onDownloadProgressChange, } = props as StaticWaveform & LiveWaveform; const viewRef = useRef(null); + const [audioPath, setAudioPath] = useState( + !isExternalUrl ? path : undefined + ); const scrollRef = useRef(null); + const audioPathRef = useRef(undefined); const [waveform, setWaveform] = useState([]); const [viewLayout, setViewLayout] = useState(null); const [seekPosition, setSeekPosition] = useState( @@ -88,12 +97,48 @@ export const Waveform = forwardRef((props, ref) => { const { checkHasAudioRecorderPermission } = useAudioPermission(); + const downloadExternalAudioFile = () => { + (onDownloadStateChange as Function)?.(true); + + RNFetchBlob.config({ + fileCache: true, + }) + .fetch('GET', path) + .progress((received, total) => { + let progressPercentage: number = Number( + ((received / total) * 100).toFixed(2) + ); + (onDownloadProgressChange as Function)?.(progressPercentage); + }) + .then(res => { + // the temp file path + const tempFilePath = res.path(); + setAudioPath(tempFilePath); + audioPathRef.current = tempFilePath; + (onDownloadStateChange as Function)?.(false); + (onDownloadProgressChange as Function)?.(100); + }) + .catch(e => { + console.log(e); + (onDownloadStateChange as Function)?.(false); + }); + }; + + useEffect(() => { + if (isExternalUrl && path && downloadExternalAudio) { + downloadExternalAudioFile(); + } else { + (onDownloadStateChange as Function)?.(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isExternalUrl, path, downloadExternalAudio]); + const preparePlayerForPath = async () => { - if (!isNil(path) && !isEmpty(path)) { + if (!isNil(audioPath) && !isEmpty(audioPath)) { try { const prepare = await preparePlayer({ - path, - playerKey: `PlayerFor${path}`, + path: audioPath, + playerKey: `PlayerFor${audioPath}`, updateFrequency: UpdateFrequency.medium, volume: volume, }); @@ -103,7 +148,7 @@ export const Waveform = forwardRef((props, ref) => { } } else { return Promise.reject( - new Error(`Can not start player for path: ${path}`) + new Error(`Can not start player for path: ${audioPath}`) ); } }; @@ -111,7 +156,7 @@ export const Waveform = forwardRef((props, ref) => { const getAudioDuration = async () => { try { const duration = await getDuration({ - playerKey: `PlayerFor${path}`, + playerKey: `PlayerFor${audioPath}`, durationType: DurationType.max, }); if (!isNil(duration)) { @@ -120,7 +165,7 @@ export const Waveform = forwardRef((props, ref) => { return Promise.resolve(audioDuration); } else { return Promise.reject( - new Error(`Could not get duration for path: ${path}`) + new Error(`Could not get duration for path: ${audioPath}`) ); } } catch (err) { @@ -144,12 +189,12 @@ export const Waveform = forwardRef((props, ref) => { }; const getAudioWaveFormForPath = async (noOfSample: number) => { - if (!isNil(path) && !isEmpty(path)) { + if (!isNil(audioPath) && !isEmpty(audioPath)) { try { (onChangeWaveformLoadState as Function)?.(true); const result = await extractWaveformData({ - path: path, - playerKey: `PlayerFor${path}`, + path: audioPath, + playerKey: `PlayerFor${audioPath}`, noOfSamples: noOfSample, }); (onChangeWaveformLoadState as Function)?.(false); @@ -168,9 +213,11 @@ export const Waveform = forwardRef((props, ref) => { } } else { (onError as Function)( - `Can not find waveform for mode ${mode} path: ${path}` + `Can not find waveform for mode ${mode} path: ${audioPath}` + ); + console.error( + `Can not find waveform for mode ${mode} path: ${audioPath}` ); - console.error(`Can not find waveform for mode ${mode} path: ${path}`); } }; @@ -178,7 +225,7 @@ export const Waveform = forwardRef((props, ref) => { if (mode === 'static') { try { const result = await stopPlayer({ - playerKey: `PlayerFor${path}`, + playerKey: `PlayerFor${audioPath}`, }); if (!isNil(result) && result) { setCurrentProgress(0); @@ -186,7 +233,7 @@ export const Waveform = forwardRef((props, ref) => { return Promise.resolve(result); } else { return Promise.reject( - new Error(`error in stopping player for path: ${path}`) + new Error(`error in stopping player for path: ${audioPath}`) ); } } catch (err) { @@ -208,8 +255,8 @@ export const Waveform = forwardRef((props, ref) => { const play = await playPlayer({ finishMode: FinishMode.stop, - playerKey: `PlayerFor${path}`, - path: path, + playerKey: `PlayerFor${audioPath}`, + path: audioPath, ...args, }); @@ -218,7 +265,7 @@ export const Waveform = forwardRef((props, ref) => { return Promise.resolve(true); } else { return Promise.reject( - new Error(`error in starting player for path: ${path}`) + new Error(`error in starting player for path: ${audioPath}`) ); } } catch (error) { @@ -235,14 +282,14 @@ export const Waveform = forwardRef((props, ref) => { if (mode === 'static') { try { const pause = await pausePlayer({ - playerKey: `PlayerFor${path}`, + playerKey: `PlayerFor${audioPath}`, }); if (pause) { setPlayerState(PlayerState.paused); return Promise.resolve(true); } else { return Promise.reject( - new Error(`error in pause player for path: ${path}`) + new Error(`error in pause player for path: ${audioPath}`) ); } } catch (error) { @@ -369,7 +416,7 @@ export const Waveform = forwardRef((props, ref) => { }; useEffect(() => { - if (!isNil(viewLayout?.width)) { + if (!isNil(viewLayout?.width) && audioPath !== undefined) { const getNumberOfSamples = floor( (viewLayout?.width ?? 0) / (candleWidth + candleSpace) ); @@ -379,10 +426,10 @@ export const Waveform = forwardRef((props, ref) => { } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [viewLayout, mode, candleWidth, candleSpace]); + }, [viewLayout, mode, candleWidth, candleSpace, audioPath]); useEffect(() => { - if (!isNil(seekPosition)) { + if (!isNil(seekPosition) && audioPath !== undefined) { if (mode === 'static') { const seekAmount = (seekPosition?.pageX - (viewLayout?.x ?? 0)) / @@ -391,7 +438,7 @@ export const Waveform = forwardRef((props, ref) => { if (!panMoving) { seekToPlayer({ - playerKey: `PlayerFor${path}`, + playerKey: `PlayerFor${audioPath}`, progress: clampedSeekAmount * songDuration, }); if (playerState === PlayerState.playing) { @@ -403,31 +450,41 @@ export const Waveform = forwardRef((props, ref) => { } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [seekPosition, panMoving, mode, songDuration]); + }, [seekPosition, panMoving, mode, songDuration, audioPath]); useEffect(() => { - const tracePlayerState = onDidFinishPlayingAudio(async data => { - if (data.playerKey === `PlayerFor${path}`) { - if (data.finishType === FinishMode.stop) { - setPlayerState(PlayerState.stopped); - setCurrentProgress(0); - await preparePlayerForPath(); + if (audioPath !== undefined) { + const tracePlayerState = onDidFinishPlayingAudio(async data => { + if (data.playerKey === `PlayerFor${audioPath}`) { + if (data.finishType === FinishMode.stop) { + setPlayerState(PlayerState.stopped); + setCurrentProgress(0); + await preparePlayerForPath(); + } } - } - }); + }); - const tracePlaybackValue = onCurrentDuration(data => { - if (data.playerKey === `PlayerFor${path}`) { - const currentAudioDuration = Number(data.currentDuration); + const tracePlaybackValue = onCurrentDuration(data => { + if (data.playerKey === `PlayerFor${audioPath}`) { + const currentAudioDuration = Number(data.currentDuration); - if (!isNaN(currentAudioDuration)) { - setCurrentProgress(currentAudioDuration); - } else { - setCurrentProgress(0); + if (!isNaN(currentAudioDuration)) { + setCurrentProgress(currentAudioDuration); + } else { + setCurrentProgress(0); + } } - } - }); + }); + + return () => { + tracePlayerState.remove(); + tracePlaybackValue.remove(); + }; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [audioPath]); + useEffect(() => { const traceRecorderWaveformValue = onCurrentRecordingWaveformData( result => { if (mode === 'live') { @@ -440,9 +497,8 @@ export const Waveform = forwardRef((props, ref) => { } } ); + return () => { - tracePlayerState.remove(); - tracePlaybackValue.remove(); traceRecorderWaveformValue.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -463,17 +519,19 @@ export const Waveform = forwardRef((props, ref) => { }, [recorderState]); useEffect(() => { - if (panMoving) { - if (playerState === PlayerState.playing) { - pausePlayerAction(); - } - } else { - if (playerState === PlayerState.paused) { - startPlayerAction(); + if (audioPath !== undefined) { + if (panMoving) { + if (playerState === PlayerState.playing) { + pausePlayerAction(); + } + } else { + if (playerState === PlayerState.paused) { + startPlayerAction(); + } } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [panMoving]); + }, [panMoving, audioPath]); const panResponder = useRef( PanResponder.create({ @@ -499,6 +557,15 @@ export const Waveform = forwardRef((props, ref) => { } }, [currentProgress, songDuration, onCurrentProgressChange]); + useEffect(() => { + return () => { + // clean up temporary audio files + if (!isNil(audioPathRef.current)) { + RNFetchBlob.fs.unlink(audioPathRef.current); + } + }; + }, []); + useImperativeHandle(ref, () => ({ startPlayer: startPlayerAction, stopPlayer: stopPlayerAction, diff --git a/src/components/Waveform/WaveformTypes.ts b/src/components/Waveform/WaveformTypes.ts index 6ace129..aab20d9 100644 --- a/src/components/Waveform/WaveformTypes.ts +++ b/src/components/Waveform/WaveformTypes.ts @@ -18,6 +18,8 @@ export interface StaticWaveform extends BaseWaveform { path: string; volume?: number; scrubColor?: string; + isExternalUrl?: boolean; + downloadExternalAudio?: boolean; onPlayerStateChange?: (playerState: PlayerState) => void; onPanStateChange?: (panMoving: boolean) => void; onError?: (error: string) => void; @@ -26,6 +28,8 @@ export interface StaticWaveform extends BaseWaveform { songDuration: number ) => void; onChangeWaveformLoadState?: (state: boolean) => void; + onDownloadStateChange?: (state: boolean) => void; + onDownloadProgressChange?: (currentProgress: number) => void; } export interface LiveWaveform extends BaseWaveform {