From 9cd8becb0850e53ecd1d6991c862d1cd9aa018c4 Mon Sep 17 00:00:00 2001 From: nilesh-simform Date: Tue, 16 Jul 2024 15:45:16 +0530 Subject: [PATCH] feat(UNT-T27088): external url support --- README.md | 38 +++- example/src/App.tsx | 49 +++++- example/src/assets/icons/download.png | Bin 0 -> 8598 bytes example/src/assets/icons/index.ts | 1 + example/src/constants/Audios.ts | 18 +- example/src/styles.ts | 17 +- package.json | 5 +- src/components/Waveform/Waveform.tsx | 213 +++++++++++++++++------ src/components/Waveform/WaveformTypes.ts | 4 + 9 files changed, 281 insertions(+), 64 deletions(-) create mode 100644 example/src/assets/icons/download.png diff --git a/README.md b/README.md index bf98167..93b4940 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,13 @@ Here's how to get started with react-native-audio-waveform in your React Native ##### 1. Install the package ```sh -npm install @simform_solutions/react-native-audio-waveform react-native-gesture-handler +npm install @simform_solutions/react-native-audio-waveform react-native-gesture-handler rn-fetch-blob ``` ###### --- or --- ```sh -yarn add @simform_solutions/react-native-audio-waveform react-native-gesture-handler +yarn add @simform_solutions/react-native-audio-waveform react-native-gesture-handler rn-fetch-blob ``` ##### 2. Install CocoaPods in the iOS project @@ -90,7 +90,34 @@ 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://www2.cs.uic.edu/~i101/SoundFiles/taunt.wav'; // 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" @@ -133,6 +160,9 @@ You can check out the full example at [Example](./example/src/App.tsx). | 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. | | playbackSpeed | 1.0 | ✅ | ❌ | 1.0 / 1.5 / 2.0 | The playback speed of the audio player. Note: Currently playback speed only supports, Normal (1x) Faster(1.5x) and Fastest(2.0x), any value passed to playback speed greater than 2.0 will be automatically adjusted to normal playback speed | +| 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 | @@ -145,6 +175,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 33a33b0..33e1a60 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -25,6 +25,7 @@ import { ScrollView, StatusBar, Text, + TouchableOpacity, View, } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; @@ -49,6 +50,7 @@ const RenderListItem = React.memo( onPanStateChange, currentPlaybackSpeed, changeSpeed, + isExternalUrl = false, }: { item: ListItem; currentPlaying: string; @@ -56,13 +58,16 @@ const RenderListItem = React.memo( onPanStateChange: (value: boolean) => void; currentPlaybackSpeed: PlaybackSpeedType; changeSpeed: () => 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 [isLoading, setIsLoading] = useState(isExternalUrl ? false : true); + const [downloadExternalAudio, setDownloadExternalAudio] = useState(false); + const [isAudioDownloaded, setIsAudioDownloaded] = useState(false); - const handleButtonAction = () => { + const handleButtonAction = (): void => { if (playerState === PlayerState.stopped) { setCurrentPlaying(item.path); } else { @@ -70,6 +75,15 @@ const RenderListItem = React.memo( } }; + const handleDownloadPress = (): void => { + setDownloadExternalAudio(true); + if (currentPlaying === item.path) { + setCurrentPlaying(''); + } + + setIsLoading(true); + }; + useEffect(() => { if (currentPlaying !== item.path) { ref.current?.stopPlayer(); @@ -79,7 +93,15 @@ const RenderListItem = React.memo( }, [currentPlaying]); return ( - + { setPlayerState(state); if ( @@ -127,10 +150,20 @@ const RenderListItem = 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 ', @@ -154,6 +187,15 @@ const RenderListItem = React.memo( )} + {isExternalUrl && !downloadExternalAudio && !isAudioDownloaded ? ( + + + + ) : null} ); } @@ -281,6 +323,7 @@ const AppContainer = () => { currentPlaying={currentPlaying} setCurrentPlaying={setCurrentPlaying} item={item} + isExternalUrl={item.isExternalUrl} onPanStateChange={value => setShouldScroll(!value)} {...{ currentPlaybackSpeed, changeSpeed }} /> diff --git a/example/src/assets/icons/download.png b/example/src/assets/icons/download.png new file mode 100644 index 0000000000000000000000000000000000000000..6948d47ac92517cf3422997e296198f13abd2eda GIT binary patch literal 8598 zcmeHtc{tSF`~MjwebkfYiR{Vv6qEh2J%$=;Eb(MSDald_l_*AbGo_fw_GD=>C?;BL zNl2Eb7(&#LY(v?`*ru@#X89dH-+z98f4~2Huj@P4HP@W$eeQGL_qmtXectz(yY_Zg zzlqC<0|0(IYK?OMKm-m&z}7ABXCb6(75@C|nzeHv06RXee-Tao1sC8$S%SrBf}_6| zA^2RtMGzbutaZunN}%VtYZtZr1H2#4n#uvN7aYZzp9smEVT3=(yBNCv7qf77Qg(|Z z;R|_dmi^uR%3=u~Ti^X#H(vetts`1ex!eEfWNejFnL5Xa{}d#f)VtVx?#t|!#?Bm6 zvh^)_2{SXJIOA6hju&!|M}K{_S?1Y`cT)_k!mfLIt_(fb#VGg4x*_2WMh4*&!|~g4 zClYG;Kl$l8dO#7#6!qTW?P%La4g*F>@jYB<}m;cChpGvoYl z%x_|L5p$pOso`;3#A0j^BwfPN#n2JD1G}n_%p4;(u|H{XJXxW)vRi2Gze!Zf`UpabucjZkhTDcoa&?x zv#3WvE40h-8Dxh#<=nS`B6lwz86||-ovXcB5|4eBYo!*~oIi)2=aA#G>wdbdvl)kM zNb%He#Z{NA_xy7;p6Aht$IyxGXsl^Km343A+VWJ26`1C4sX)kemCZaO|z{G(1PH}Ceir#nPrIZi{1N` zb8~=>jrQs0DG738Z}UnfIiyD}vR6A#!9VneQGbSifBNc`1q0_zQDQmgy>^GyVN8bj zt=9Y`3p;#5IoCgRBqK1cb$?%ti#OAxV^Ek{U-^*2HeDkPtObnnu;*vocq*tU<^0O& zC6#cZUfGw>ak(hhrTaJBkj%4L?CPGG8ckG`Q4DWrpGCy`@;7f56}=U&_3J3b@*y0? z%-mtOVGWE+D-T*k(R}vkx|V7k^9ya2Wr^j6nl8~yfRs+)rR9v>)iv6D>`*xv)v%#Mkbs*^&NT@!3j@cp?rr*!8JKyG?_Tq{#4mttxJIPH!8<+Y(!p zM7FxeeImiKdKcjJWcdXWt7VT^FDNePqNL_n=@4$LMIyefeKG6IkJ8>_2sIzC+Q6{U zg`eaed$q%HnZj#cwc$UXDTY3^-{R5Wwfh}2FZBkl0U_$CknC7{`Nosw>}_5?`l30> ztg3c2@{;lk&XC9W7ZYjhLbyr5C)G_JJpp!56(h^U3JZg*@NQ;i@c`z#Nf9A=|BDYs zW+T3osXg}{!tf_pI~`j(27*qp?=#z&VyTHDuWF8&%ymsuYnE|#%(V77rC0t`fz>nZ ze&^v_vZW)i^j*3YVXxk3g&MA>J1_mXYx}i2D(q)gY}yWySCl}lj%7xOH`~*=DbUut zw`?J&EBirGW}&5}HA1E|!i=4uyXww$DOI>mi0eL9wvCakO%d%OYCo!8`zM|;xQK2n zPHTVtDl-ccd5L|GuKNgwka@xi4k9X}-ln(f+S|pBDHyf2X=a_kh?lV6wg>O2PVFPQe+|dO+b_ zk88wH*3M(cGo*9-9#hx{(WtDmn5F~AEb@XoG#1?4Fk4Fk*8Qf!oI(vpK_PBYr51D_ zHK6jh%4FYV$5GyD@5*d{#nskY_ncvPnqbZja*MSo7nK;q@2)kSHnBb;_z9I?f8U9) z#vQ66B=S>K+z4wa@u%K4@mHvu^vxM{M=w9QAKpP;eai{| zR2OMB8_8=69$U+>6X8{_yQb|hdrsVsxBQKR<;Cv$mdMf)cYYFZ%tt=DKY9 zhOl$*`PQX~Uo?|&rFb$kwf%KWU-c^Edf4Ere~WrZI%L{oKFEdp))|C$c31gnKC{p% za?=l9j#k}&rGzsiR_J6}ONhCi54QKtR+LWae{_Dr`{5KfmCp=UO}7&{w%{)>l*FFG zwCaqK-8CrZ2GHP0cWX@(uAwrk;=O-d#7x^^@65rl+bu6yG-6P|obE{SIthNIgf1K7 zdm!Ll#Eqh|Lc&g;TkGMQGM{asX(V|0$7~0s5e+`3cUB6MEpd67^p&!CM#VcR@Ubj> zMEHd4^Wlkt>dn};`-{oc6Hl-!CKrr{2iSY6xXRF)31gXFOiVxR!U*1iYbp0Ss=QZK@DNlP4`ShJ(?Zrgf( zY_9FK^a#Gsw`M6w&6&iDEeg7Z)}FDV-f)tvwbd!q!?jB!|G!)vF}PhYHRy36fl&T@ zs%up+-Gj(fZO+pD&Gy7D74giOjdLpZ%2_K&Lj!@pbI#^y)*8wV{8JmX+m>1I zKxLIr7-@@Of1*a$tMzc%C$ScowYW})-1htom>^$7wNEcMHm@YWy?#+fu5@_q#yqwW zyLs+4XQ*0XB}vP)EYg#uaO^3#*FzufdQ0Z|!i~Ps3Rtb5KD^urnd-E)2WLqcyGt7i z!sl3QCH=)A+gKktQI7_#-bZ0I)FnMs<5g0&D9 zBUu_!3*WkNYhCJgwhX%YBTIzZ4_9w$e};q5D3w%mRQs{pL``sKqUlM@zos)7^1{p! z&7Uhb@U@t#HzC8K9rz(_#k{)_M|KB%+SV=PaHu;l^O4=+$Os7BO8R;grfHz?u?(G&?Ya!6)F`pjH?bF zndY^tK!bdHP_tKwFyYF0>DW+a>;1d{Ux=UC1-Dr1h_Swp26sMZKb++a-gH#8YFM*@ z^Vy%i3ff>v)P**(@r4?+iY@q&t?QgIw)?0HKf@i?@+vY@qUSX%|B1@{hj(;&s$wpvY@RZNR@}UFVd^Zq(kTxgn;^^@m1K=c`(=fd z>eYRF0#39wKF%LZ4~%lc4LD+KMGIR~2C;hIO^OLSD=XFks4!J%irS$`q29t6k1@|y zR?TcZ)2Ifd2%LvgMbpfME68(l^YO-9=)MAQg0*Y-??Oj&3LK|7egCAm0WQa8y(E9l&UYd+Lw|KOIXGR zgi)W?K6n~Dy`+~fct_X?-^U6nZQnj)69U#YSBNZ4Hb7I-0tc(+m8sERwnN;rY6x6+ z8NSeVW<-17{m;kL=n_-WoGk<5G85jrRrmX6MtU>zZN<*L@8wE(jJT_dn;ol^()n^^ z9=96*@Xlay`i>7FuQ~2n2}Hgc2pV>(-@6>2i9K}iyD`LaT6}`=wJ-0A4WdA~LL|Lh zi@H$zuxhrK8m+U(mzQyI#N89}tCA{tb=R7+qe7&|-B8f_np13yPc#^Js-J@ljQev%mV7ITyJh5o;+)()|f}LsWT2{Ul$ZK}dwWFZaDZsPtBAv8aMGv`t=E znhWkBp^UH|oE=fu@q>MvoAStss;L!z}h=4FE6~<%Y`B-aN-eJQu=dSup8UStfq&n-Acr;nXPuV^Mq!ll>MfSthaJf zBn1h!cDdgpc#3;YNPSke9;n#Xo-Qh<5u04wdbo0qsi@;!P4~-}bHzg)Yii8>X)|9rx;gq*~U+93t z7@Vu)U~CUx-bR73Guzzu(1U}D8>CiP$m$9rbQ+!Lm_&KKDVr7G@E zSn*@%<}c2}qGnL@CA>I6=+?{ClN1M6+6zhMbNdwM zKoWg%a0KQpFpdhit+#!e9u{3o4Ku2=39+)!PlKz$iEv?3%+k-oT z#nbm&BmKjF81>b8k6T?52Wh$ohYlK9yf@}Ol^H?jTNmrh=N^y*2QG#c1=`rKYBtJK zNerxO(hpXS?}H1h$uTXEn;xYZ)CY zmjtQWCr8Vi4qRFr530~w>}#@YaEE8IwTXCiSW_4${{@sY^>-o5gfehQ*`c7 znIo1du8?}mbVNKq2! zrxQ2=lcDlBkb(S4XudpITXEp|sStFN6R@7P2oK-SZOd5-DOfpVY-;;qO&DO^l7RNe z1p$$aNXzc9mc0TOO8D|FZ5Ab!GM&InsH!J-D~WbZodh-M)DZ`thn+w;e7p+{^#6t{ zy&we~rn?{#yhrf4aM;&fHF&)+wfHVMfm){HU?|Yf5-}U8lKP@=DA@UP9zs-(voG(Q zktq03NQx=p#D>GJ??Ai@!n1{@6m?4k2vgv=k*2it7lKp+sh1$NtT@p4SV-!7&51?8 z7CS6=eWjdODSO}{Iv`%KO`dofoqhF#I7HcPhJq4taqueH1(D`8BJpxm1)SbjA##8Q z8Qv8EYfz{S6dIWi>Z2fY8F^w4UK~uTR)|=()0DbNBH#+-o&vcWJO%Y~FfV$be9e`C z-@9RDIdXAC;tJHuVAQGJzK8p9CwD7|@ldM}3D9S&cGHw%o?XJL>R|jtr={hIGFqY_ zrmc{4vWydpgsN_a>7)xEBSnE_0|Y(D_cq)NJSHqb?JPfg|E+Tvu1rb5O6Guia+TCC zPhZ}Xov%$YbMS#>-(t3W{E5q(_v&@h< z++rMTv!AE2Hz>YV5(l@I>v>g(K#uT|q98}N3MRUQ)VK<4YWEGU)3?BW_~hjq5N4Dn z%@L$W1^*PnI4gkKUe5clvCs=Hd?>y_Eaw1k4PFFp5Wjely-%dIr#6&Pc;7a@?r#-8 zbegi&z#mmz;ZLQB1*(w2hNIP1ml+Es>R-6<8aw?-IQMKLe9z5lgw;xBI&I}t4cJTJoo zR#xU&9=<9Ke^)^j*MM*Ok-ZXy^6IF zzf}|tOQ)sMZz7RjJA%iY1Ol90MY-cztG}@dKj+$s|L&#L0aV%!9dUg4$W@6)6U%n@ z2Exwc4q;GxkVtuz;T*D0#xXH7^I%*9)+(_dH9T*8#z>(gYV!W6_Y3538!`E}rJ5lB z{Sw6lNZIX&M@kpl78<#8;;aUbW7^KhJr*gsxW?=oS|N%Fqeer5a0;g}62Nn84NZUR zK5aVp;cCA_9pOQ_&Ti2k6@$O1O{0PaF_qy5bY~!QW&QXsP%VyF_--rK>t0AlEPek} zyw>u(3d^bbH7rs8OYyr6YhJZ(Ngc1Pc!Bus9Wdnwxj#&_R=TGoFvNoG?ukcG)FeUo zRP9?TA(el;KMB}H@lU(g z&lsU7gwN_pt2ox)mnW-5O4g(WholFW#B$_jea1rQCyT?n3aQbrEDNK=JUZWM+sz2B z614^3MB_hvNp}C6nRv&IS@HU!MMv%5>SiR&78L?J-aS8+zN1vGd}ZqoR`JT*&r44i z$F7ZvkuLZ3p0TlVU!2UXb;dN?bTJi4ckj-Bh{u9&?JXgXrc^C(2~br;rFIP0#d=#jj>v?F7E`tRXZs>*hU6_MmKD>S++ zWag&@30kB{L5^Y(DAbMgb_7sQRaK?u+r-M`&58bq%h+=rk2~vv5d*Kjc>kB#bgjp> znc;AGJ;&Fz#o1jCWo=hK9xqZ-sazCC?zd$E5@ObGSsH7PX|U;f3eJYl=ED1cVY)H! zAjKC)e8Us#2;{T^3j~>bI^&FgBKZt2Tii^AQKhfDVg)^L;;MMPRB_0tqH*I2H4xNi zV@QQM^;^chQ5k3zjD@-Q@*bo<%%*zxTvD1b5HRDqf6dz5- z@90@~%r<~)aD86BzsDOC^p3a%BZsdIHiURHO!&R`|giO9Ksn zM1Fri`IVT7`fQ1V%?%Q*#W?@rOZUZsmoTZM%h$I47|as%6(3TkhTw6pU@w_u=^U5Z zQ9CY`H0XwDu6+>%1^l@Zjd0YFHv{};f4;m%W`<-kxoyZFM2@vgR$Kc-Ns$s02R-GdY~ zy>&bcFAtE?9CTCA^Iav18t9_foYa+s4N_DgvN7WMcfXgaY4c_nyB;1j)EbO&aTGpm+`t4)SZc=#o>Xq7v zkjv$E_nPtK)IHKjE6WBNxzZo^S1N=?Wk&P$Q+W#=$d$L&9BO8#ci(-^C-n}K$!h9H j#D5o?|Nr&%HGHDx!*iblel(t1pE+t_hbublaqIs9wDOn! literal 0 HcmV?d00001 diff --git a/example/src/assets/icons/index.ts b/example/src/assets/icons/index.ts index c28aa2c..62e4d9d 100644 --- a/example/src/assets/icons/index.ts +++ b/example/src/assets/icons/index.ts @@ -4,4 +4,5 @@ export const Icons = { simform: require('./simform.png'), mic: require('./mic.png'), logo: require('./logo.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 8589dea..b46f8b5 100644 --- a/example/src/styles.ts +++ b/example/src/styles.ts @@ -39,10 +39,16 @@ 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', + }, + listItemReverseContainer: { + flexDirection: 'row-reverse', + alignSelf: 'flex-end', }, listItemWidth: { - width: '90%', + width: '88%', }, buttonImage: { height: scale(22), @@ -107,6 +113,13 @@ const styles = (params: StyleSheetParams = {}) => textAlign: 'center', fontWeight: '600', }, + 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 0fb180f..6227e0f 100644 --- a/src/components/Waveform/Waveform.tsx +++ b/src/components/Waveform/Waveform.tsx @@ -8,6 +8,7 @@ import React, { } from 'react'; import { PanResponder, + Platform, ScrollView, View, type LayoutRectangle, @@ -37,6 +38,13 @@ import { type LiveWaveform, type StaticWaveform, } from './WaveformTypes'; +import RNFetchBlob from 'rn-fetch-blob'; + +// Cache directory based on the platform +const cacheDir = + Platform.OS === 'ios' + ? RNFetchBlob.fs.dirs.DocumentDir + : RNFetchBlob.fs.dirs.CacheDir; export const Waveform = forwardRef((props, ref) => { const { @@ -47,6 +55,8 @@ export const Waveform = forwardRef((props, ref) => { volume = 3, // The playback speed of the audio player. A value of 1.0 represents normal playback speed. playbackSpeed = 1.0, + isExternalUrl = false, + downloadExternalAudio = true, candleSpace = 2, candleWidth = 5, containerStyle = {}, @@ -59,10 +69,16 @@ 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 isLayoutCalculated = useRef(false); + const audioPathRef = useRef(undefined); const [waveform, setWaveform] = useState([]); const [viewLayout, setViewLayout] = useState(null); const [seekPosition, setSeekPosition] = useState( @@ -105,23 +121,101 @@ export const Waveform = forwardRef((props, ref) => { */ const updatePlaybackSpeed = async (speed: number) => { try { - await setPlaybackSpeed({ speed, playerKey: `PlayerFor${path}` }); + await setPlaybackSpeed({ speed, playerKey: `PlayerFor${audioPath}` }); } catch (error) { console.error('Error updating playback speed', error); } }; useEffect(() => { - updatePlaybackSpeed(audioSpeed); + if (audioPath !== undefined) { + updatePlaybackSpeed(audioSpeed); + } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [audioSpeed]); + }, [audioSpeed, audioPath]); + + const setExternalAudioPath = (filePath: string): void => { + setAudioPath(filePath); + audioPathRef.current = filePath; + (onDownloadStateChange as Function)?.(false); + (onDownloadProgressChange as Function)?.(100); + }; + + const downloadAndCacheFile = async ( + fileUrl: string, + fileName: string + ): Promise => { + const filePath: string = `${cacheDir}/${fileName}`; + + try { + const fileExists: boolean = await RNFetchBlob.fs.exists(filePath); + + if (fileExists) { + setExternalAudioPath(filePath); + return; + } + + // File doesn't exist, download it + (onDownloadStateChange as Function)?.(true); + await RNFetchBlob.config({ + path: filePath, + fileCache: true, + }) + .fetch('GET', fileUrl) + .progress((received, total) => { + let progressPercentage: number = Number( + ((received / total) * 100)?.toFixed?.(2) + ); + (onDownloadProgressChange as Function)?.(progressPercentage); + }) + .then(response => { + const tempFilePath: string = response.path(); + setExternalAudioPath(tempFilePath); + }) + .catch(error => { + console.error(error); + (onDownloadStateChange as Function)?.(false); + }); + } catch (error) { + console.error(error); + } + }; + + const checkIsFileDownloaded = async (fileName: string): Promise => { + const filePath: string = `${cacheDir}/${fileName}`; + const fileExists: boolean = await RNFetchBlob.fs.exists(filePath); + if (fileExists) { + setExternalAudioPath(filePath); + } + }; + + // Replace special characters with _ and remove extension from the URL and make file name lowercase + const formatUrlToFileName = (url: string): string => { + return url + ?.replace?.(/[:\/\.\%20\-~\?=&@#\!\$\^\*\(\)\{\}\[\],\'"]/g, '_') + ?.replace?.(/\.[^/.]+$/, '') + ?.toLowerCase?.(); + }; + + useEffect(() => { + const fileName: string = formatUrlToFileName(path); + + if (isExternalUrl && path && downloadExternalAudio) { + downloadAndCacheFile(path, fileName); + } else if (isExternalUrl && path) { + checkIsFileDownloaded(fileName); + } else { + (onDownloadStateChange as Function)?.(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isExternalUrl, path, downloadExternalAudio]); const preparePlayerForPath = async (progress?: number) => { - 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, progress, @@ -132,7 +226,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}`) ); } }; @@ -140,7 +234,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)) { @@ -149,7 +243,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) { @@ -173,12 +267,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); @@ -197,9 +291,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}`); } }; @@ -207,7 +303,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); @@ -215,7 +311,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) { @@ -237,8 +333,8 @@ export const Waveform = forwardRef((props, ref) => { const play = await playPlayer({ finishMode: FinishMode.stop, - playerKey: `PlayerFor${path}`, - path: path, + playerKey: `PlayerFor${audioPath}`, + path: audioPath, speed: audioSpeed, ...args, }); @@ -248,7 +344,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) { @@ -265,14 +361,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) { @@ -399,7 +495,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) ); @@ -415,10 +511,10 @@ export const Waveform = forwardRef((props, ref) => { } } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [viewLayout?.width, mode, candleWidth, candleSpace]); + }, [viewLayout?.width, mode, candleWidth, candleSpace, audioPath]); useEffect(() => { - if (!isNil(seekPosition)) { + if (!isNil(seekPosition) && audioPath !== undefined) { if (mode === 'static') { const seekAmount = (seekPosition?.pageX - (viewLayout?.x ?? 0)) / @@ -427,7 +523,7 @@ export const Waveform = forwardRef((props, ref) => { if (!panMoving) { seekToPlayer({ - playerKey: `PlayerFor${path}`, + playerKey: `PlayerFor${audioPath}`, progress: clampedSeekAmount * songDuration, }); if (playerState === PlayerState.playing) { @@ -439,31 +535,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') { @@ -487,9 +593,8 @@ export const Waveform = forwardRef((props, ref) => { } } ); + return () => { - tracePlayerState.remove(); - tracePlaybackValue.remove(); traceRecorderWaveformValue.remove(); }; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -510,17 +615,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 calculateLayout = (): void => { viewRef.current?.measureInWindow((x, y, width, height) => { diff --git a/src/components/Waveform/WaveformTypes.ts b/src/components/Waveform/WaveformTypes.ts index f3a5dd3..404ec61 100644 --- a/src/components/Waveform/WaveformTypes.ts +++ b/src/components/Waveform/WaveformTypes.ts @@ -20,6 +20,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; @@ -29,6 +31,8 @@ export interface StaticWaveform extends BaseWaveform { ) => void; onChangeWaveformLoadState?: (state: boolean) => void; playbackSpeed?: PlaybackSpeedType; + onDownloadStateChange?: (state: boolean) => void; + onDownloadProgressChange?: (currentProgress: number) => void; } export interface LiveWaveform extends BaseWaveform {