From 976cd172f413fcea4b522fcaa0365e8ab7768a04 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 19 Aug 2021 14:23:09 +0900 Subject: [PATCH] bugfixes - fixed image path for RNTP notification - fixed overwhelming number of promises/requests generated when scrolling through artists (could still delay loading those further...) - fixed spinner not spinning while artistInfo is fetched for image url --- app/components/CoverArt.tsx | 10 ++- app/hooks/cache.ts | 29 ++++++-- app/screens/LibraryAlbums.tsx | 5 -- app/screens/SearchResultsView.tsx | 5 +- app/state/cache.ts | 106 +++++++++++++++++------------- app/state/trackplayermap.ts | 2 + res/fallback.png | Bin 5050 -> 7077 bytes 7 files changed, 93 insertions(+), 64 deletions(-) diff --git a/app/components/CoverArt.tsx b/app/components/CoverArt.tsx index 35b2e40..32ec099 100644 --- a/app/components/CoverArt.tsx +++ b/app/components/CoverArt.tsx @@ -27,11 +27,6 @@ const ImageSource = React.memo<{ cache?: { file?: CacheFile; request?: CacheRequ ({ cache, style, imageStyle, resizeMode }) => { const [error, setError] = useState(false) - if (error) { - console.log('error!') - console.log(cache?.file?.path) - } - let source: ImageSourcePropType if (!error && cache?.file && !cache?.request?.promise) { source = { uri: `file://${cache.file.path}`, cache: 'reload' } @@ -62,7 +57,7 @@ const ImageSource = React.memo<{ cache?: { file?: CacheFile; request?: CacheRequ const ArtistImage = React.memo(props => { const cache = useArtistArtFile(props.artistId, props.size) - return + return }) const CoverArtImage = React.memo(props => { @@ -100,6 +95,9 @@ const styles = StyleSheet.create({ width: '100%', position: 'absolute', }, + artistImage: { + backgroundColor: 'rgba(81, 28, 99, 0.4)', + }, }) export default CoverArt diff --git a/app/hooks/cache.ts b/app/hooks/cache.ts index b86ea1f..71e74ca 100644 --- a/app/hooks/cache.ts +++ b/app/hooks/cache.ts @@ -1,9 +1,10 @@ import { CacheImageSize, CacheItemTypeKey } from '@app/models/cache' +import { ArtistInfo } from '@app/models/music' import { selectCache } from '@app/state/cache' +import { selectMusic } from '@app/state/music' import { selectSettings } from '@app/state/settings' import { useStore, Store } from '@app/state/store' import { useCallback, useEffect } from 'react' -import { useArtistInfo } from './music' const useFileRequest = (key: CacheItemTypeKey, id: string) => { const file = useStore( @@ -51,23 +52,37 @@ export const useCoverArtFile = (coverArt = '-1', size: CacheImageSize = 'thumbna }), ) } - }, [cacheItem, client, coverArt, file, type]) + // intentionally leaving file out so it doesn't re-render if the request fails + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [cacheItem, client, coverArt, type]) return { file, request } } export const useArtistArtFile = (artistId: string, size: CacheImageSize = 'thumbnail') => { const type: CacheItemTypeKey = size === 'original' ? 'artistArt' : 'artistArtThumb' - const artistInfo = useArtistInfo(artistId) + const fetchArtistInfo = useStore(selectMusic.fetchArtistInfo) const { file, request } = useFileRequest(type, artistId) const cacheItem = useStore(selectCache.cacheItem) useEffect(() => { - const url = type === 'artistArtThumb' ? artistInfo?.smallImageUrl : artistInfo?.largeImageUrl - if (!file && artistInfo && url) { - cacheItem(type, artistId, url) + if (!file) { + cacheItem(type, artistId, async () => { + let artistInfo: ArtistInfo | undefined + const cachedArtistInfo = useStore.getState().artistInfo[artistId] + + if (cachedArtistInfo) { + artistInfo = cachedArtistInfo + } else { + artistInfo = await fetchArtistInfo(artistId) + } + + return type === 'artistArtThumb' ? artistInfo?.smallImageUrl : artistInfo?.largeImageUrl + }) } - }, [artistId, artistInfo, cacheItem, file, type]) + // intentionally leaving file out so it doesn't re-render if the request fails + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [artistId, cacheItem, fetchArtistInfo, type]) return { file, request } } diff --git a/app/screens/LibraryAlbums.tsx b/app/screens/LibraryAlbums.tsx index 6dfd9f3..7cf94c5 100644 --- a/app/screens/LibraryAlbums.tsx +++ b/app/screens/LibraryAlbums.tsx @@ -70,11 +70,6 @@ const AlbumsList = () => { overScrollMode="never" onEndReached={fetchNextPage} onEndReachedThreshold={6} - // getItemLayout={(_data, index) => ({ - // length: height, - // offset: height * Math.floor(index / 3), - // index, - // })} /> ) diff --git a/app/screens/SearchResultsView.tsx b/app/screens/SearchResultsView.tsx index 6e223ae..bfa8b45 100644 --- a/app/screens/SearchResultsView.tsx +++ b/app/screens/SearchResultsView.tsx @@ -57,7 +57,7 @@ const SearchResultsView: React.FC<{ }), [fetchSearchResults, query, type], ), - 50, + 100, ) useEffect(() => { @@ -77,7 +77,8 @@ const SearchResultsView: React.FC<{ refreshing={refreshing} overScrollMode="never" onEndReached={fetchNextPage} - onEndReachedThreshold={1} + removeClippedSubviews={true} + onEndReachedThreshold={2} /> ) } diff --git a/app/state/cache.ts b/app/state/cache.ts index c3aac7d..266e7bf 100644 --- a/app/state/cache.ts +++ b/app/state/cache.ts @@ -1,4 +1,4 @@ -import { CacheFile, CacheItemType, CacheItemTypeKey, CacheRequest } from '@app/models/cache' +import { CacheFile, CacheImageSize, CacheItemType, CacheItemTypeKey, CacheRequest } from '@app/models/cache' import { mkdir, rmdir } from '@app/util/fs' import PromiseQueue from '@app/util/PromiseQueue' import produce from 'immer' @@ -6,8 +6,13 @@ import RNFS from 'react-native-fs' import { GetState, SetState } from 'zustand' import { Store } from './store' -const imageDownloadQueue = new PromiseQueue(50) -const songDownloadQueue = new PromiseQueue(1) +const queues: Record = { + coverArt: new PromiseQueue(5), + coverArtThumb: new PromiseQueue(50), + artistArt: new PromiseQueue(5), + artistArtThumb: new PromiseQueue(50), + song: new PromiseQueue(1), +} export type CacheDownload = CacheFile & CacheRequest @@ -26,14 +31,18 @@ export type CacheRequestsByServer = Record export type CacheSlice = { - cacheItem: (key: CacheItemTypeKey, itemId: string, url: string | (() => string | Promise)) => Promise + cacheItem: ( + key: CacheItemTypeKey, + itemId: string, + url: string | (() => string | Promise), + ) => Promise // cache: DownloadedItemsByServer cacheDirs: CacheDirsByServer cacheFiles: CacheFilesByServer cacheRequests: CacheRequestsByServer - fetchCoverArtFilePath: (coverArt: string) => Promise + fetchCoverArtFilePath: (coverArt: string, size?: CacheImageSize) => Promise createCache: (serverId: string) => Promise prepareCache: (serverId: string) => void @@ -80,44 +89,47 @@ export const createCacheSlice = (set: SetState, get: GetState): Ca } const path = `${get().cacheDirs[activeServerId][key]}/${itemId}` - const urlResult = typeof url === 'string' ? url : url() - const fromUrl = typeof urlResult === 'string' ? urlResult : await urlResult - const queue = key === 'song' ? songDownloadQueue : imageDownloadQueue + const promise = queues[key].enqueue(async () => { + const urlResult = typeof url === 'string' ? url : url() + const fromUrl = typeof urlResult === 'string' ? urlResult : await urlResult - const promise = queue.enqueue(() => - RNFS.downloadFile({ - fromUrl, - toFile: path, - progressInterval: 100, - progress: res => { - set( - produce(state => { - state.cacheRequests[activeServerId][key][itemId].progress = Math.max( - 1, - res.bytesWritten / (res.contentLength || 1), - ) - }), - ) - }, - }) - .promise.then(() => { - set( - produce(state => { - state.cacheRequests[activeServerId][key][itemId].progress = 1 - delete state.cacheRequests[activeServerId][key][itemId].promise - }), - ) - }) - .catch(() => { - set( - produce(state => { - delete state.cacheFiles[activeServerId][key][itemId] - delete state.cacheRequests[activeServerId][key][itemId] - }), - ) - }), - ) + try { + if (!fromUrl) { + throw new Error('cannot resolve url for cache request') + } + + await RNFS.downloadFile({ + fromUrl, + toFile: path, + // progressInterval: 100, + // progress: res => { + // set( + // produce(state => { + // state.cacheRequests[activeServerId][key][itemId].progress = Math.max( + // 1, + // res.bytesWritten / (res.contentLength || 1), + // ) + // }), + // ) + // }, + }).promise + + set( + produce(state => { + state.cacheRequests[activeServerId][key][itemId].progress = 1 + delete state.cacheRequests[activeServerId][key][itemId].promise + }), + ) + } catch { + set( + produce(state => { + delete state.cacheFiles[activeServerId][key][itemId] + delete state.cacheRequests[activeServerId][key][itemId] + }), + ) + } + }) set( produce(state => { state.cacheFiles[activeServerId][key][itemId] = { @@ -134,7 +146,7 @@ export const createCacheSlice = (set: SetState, get: GetState): Ca return await promise }, - fetchCoverArtFilePath: async coverArt => { + fetchCoverArtFilePath: async (coverArt, size = 'thumbnail') => { const client = get().client if (!client) { return @@ -151,10 +163,16 @@ export const createCacheSlice = (set: SetState, get: GetState): Ca if (inProgress.promise) { await inProgress.promise } - return existing.path + return `file://${existing.path}` } - await get().cacheItem('coverArt', coverArt, () => client.getCoverArtUri({ id: coverArt })) + await get().cacheItem('coverArt', coverArt, () => + client.getCoverArtUri({ + id: coverArt, + size: size === 'thumbnail' ? '256' : undefined, + }), + ) + return `file://${get().cacheFiles[activeServerId].coverArt[coverArt].path}` }, diff --git a/app/state/trackplayermap.ts b/app/state/trackplayermap.ts index b409ee4..2a34d75 100644 --- a/app/state/trackplayermap.ts +++ b/app/state/trackplayermap.ts @@ -23,6 +23,8 @@ export const createTrackPlayerMapSlice = (set: SetState, get: GetStatecOLt;q2j#0)H3^i;IIwCJOzZE)sR5qTo({`FFYU_YCVILrt_z_%e3orUiK_ zPLCHow7x^3O`w9uqbv1HXOh&u$FJoWx7|~XnD&3Cz5D#A4I&B(0_U~bGKKR6{PSY(tNaI)ypa8 zpW1{;<{MsHRl2m+|J$Jq$N0!JtFwtqHgu?V2q-M><65<0;(}21qfTw#Cfof}yskgV zTP>WG%Tg?$neF7)=D!Eu=dFG8q-DB@FO#i)-)BRnmh#51!-f0$WtW#SvCMDO-urmH zY2!@)OVYBsbu3@Wm`rc#KD{nZc?rfwQ&UNWYm+OPSJ^M8IkK{GpGhM&^o1LG0 zgHGwxeT;m3jTePd*ZRzu$OrHy0Smz5N72RUlX10!oVQ8$kW9!q~g}wyVYOv zOJkpZd_HmZo%czv$gNCPb~{wtG4^% z6J1x=wLT}gA24ufWmNcn@ak%qaOX+ElQZ)3Yu!XlT0j5(yYK$rTAORPUZ1ekJNN$g zuYW(@UN3)t{`p>R2AY9}xA~Vh)jd?d_xI*KTL!Z`ne+d4Fa5LY{gbuK4ObU4Ff=eR zFldlTaWOC~U|?V%p1R*9>&mdf$jtY+S@d#dR)&U|H&F_Lnw3v0`+Zd|rPE7)RDaLA!}p`Vm$gZQ zfnm+Y+SSRcEEwq5nm6zV!fJ{-_68u;M?}<$CLgAulgNUZMyAs?w04F z%R|JgxUmEkFOpU|B`)oXI0qO%~#Lfvtngv$p8QR?ZL~1HD7-m z^ZK3o;qg`du=sCgS_}*|*?gzPY@Hwcdp-8 zX0CK#WJpU5PTqJg?kaz{eWb06&H|8&es0cHIPWYy`NzI^u)vqvUPT-CF#dIYv(|yq zsDUy68oApMC(1oazh|j~-Edy;YJa$Y4$qUDhEQW~JIwoS7WVdkMzW*)0tN=-&H2d+ z=WC^>f0LisXtRKUVe^*wnTg@d1-{;$zu3+7-p{`xafVrg;Q){9HHl-N7aVh#$KJ^z!jLep z@6BrEa{kPohj(4UPXF9+Y~P>dCSf0?PcUmR7#Qz13wtYXHuuB)7+DQ52gZh#*>t(GupV4#wt~PTe11m#X>TQR4 zdsnH4oS$R&7G&h%|8nmyRV+B?EZu+6bOL(-L&7qiCyXKt5rXb!X>aA@@9y0dy>)X0 z11rO`=PQme?Nc}3_VVq7Mw!Ff0cPPSo-%@MiGV?GuO+X_`KrHS#}YI z8B24SEBn^+<-a*^@1fJc#PE4@dTCJd?0XY0+e1AwTT1(jeC*$((jE_x@wxY3eZ2br zOz>9*R)(-c53jxt?H6EOz;Ga|!r|+$S8|IOL>MA?e}^v*XYXK|vD&(mA@Av0zkdx( z46C2EUKLCNNz1Z_{P*Mm3w6$X#IC`xp{Q@&I~_I$#)hkf1&V3iz2Z&+hM7B7?ppLKr}WvE%>Ox%JnVY4dimo!$Kq-O7#PxC+`T$mYV|k$wRTr; z%;x7}IB;f8{NMShY zbsEnTP*DO(5a;IDy_G)E%*0^u*#B4lnz+!~9UH^%JIIMJG)Uewt9agZ?9tx+tIi36 z-1#RbZ_TCuHDA9SQ`uL16qNbT`~MO*O?$I{wUs_3m%iR}?C+mqslyd^mnBYegUXIM z(R&wd?0A0*k^$%H`ycHojJkV6 z;>72Dudcj2Qol9>tm4$GNw1o(-d<&H{_Qn07lVOud`8ZB>tw5s^$T{zfXjj6(9)={ zw~n3pn{70KU4$WFoBH42)%N*0mv3tc1u!&J-ZY!=d~>h&@8zM-J6J&l$-kAm7F4}2 zIb;sW()VKbuCyxtb>#m^Xy*L9YX7>p`#ZMpw+#LPDi)G&JIs@oy8SusX2q&|Ul)Ng z^L%aT>7Vp-bRHQ)%+OnZFLG{Z>=a1e|F`6S+pDWr-KD;N<^`1(GhV9yy}WAws&k_3 z3m6XksoD7}Df!;N+tQOkK?N$Qc5VE%_T`fQiG>W(PyLh{O1IuT3Ce}0Z&jEGvh?W@Ad1d>Px?s)GXMw@s^G30tSXPlbKgJuc{6| z9(p|Fd;H@4uh(5JJfvIqe(}|rS8uQC51+qw--BF7Muv#l{=bgXex_vxqGdIJG+0Lt{hlhvgui}e7F!}%W+uL{Le{DMEbm+Q?m_id1!w26t*LKXW ze^tU%drN5lG~cVjtLoSN+g4Svb@z4&R)z=Ja|8ZnCNKRfoc`^WT=d+)y(PcyFJNHM ze!t;W=vD32%g_ILezWuH=~d=o{GYEaW?)$T%Jr({)yb=C!=9MFkF{O5G2lb_<_>9A zh6usgtExlA!}dqL-MPr}P2}I2WbTI#>-vhTGbbF_H0Mj*s;Z3||Mpj#FXP_lFQUP4 zK<4i9UwdC&T(ra2y^DdBVa9CXRl%#8T^VmruXaDM=-2wIv*-MAWMl|CyzXj#*#3(V zKiJnWZf9t4-4y+m{pw~{MlOa8Qe}BO-wZ$Ui7+I%z6qLpwIW7;1K%1(2D4l5uD*uo zKEAHE`ro%7%}fky8gf_v{_p-(r?7#EVa>6V_1Veu|8pM&o3qVqUe@>5ix^lLB4od> zi<_iq4+?|i{$tIx@3goA7#gl_SXt%wRU#0iwXZr|ZVck~_|MbumP)Z1udjx5{9{3irRtOlml~Widy@;iAfn(*9Qy zULDBV|MEuiug`Z^>0af%`t|a9^N{!zdTZ^1Ys;)6?Cjq=vuQA#xvzMYKZJj!oXOwM zeU~>LE>XJi?RDZzMP+nN95>D}hl^Jn&Due!Y4s35WQWC+I%iQmuf*=sQ9?0vsNF5Y%gVdeh^ ztbe4wubF2cJAci&mFL#Pg~vYBjVV7b&Zxnlv)*}C{3^M-6(z6Fd4DRczg{vYWbX#6 zwZG=RGRl9s>&8y2K%Nah)?9e{`tI5)*RLyIRbDOEyLj^dw0ALgH*XaBJL#3bl(j%a z^l`IwQjA;--U*k#to#|ix_kBVZ(F-RO}m?SwRpAm>iFy&&pS_YU;TC6t0(V%oMYX0jq&QmBnH-oqu-b8tLp!gr~PHu`^dj_U!U!( z+JEcY!MDL`{Nl_S3_9y#S6>fVzeaAyAxoL75$`{2I+okC{>QZ)&hIYo*vq8DxSJvD z{NYvmSAP?7H5KEw+gnwX{7PZ_`E$Lj4Gr21Iy3f4b^ke6^l87ZwduN^g5RHie@|iv zXNb^g539d1;~sl<;`d{h|1+>QuztL7Z12C_-A$$?^@0bA3b_;R&5PK3_2m504+4S> zOb3qL4zXQcRr&e;_47#!;J|2RdH{goVVJ&n!|xH z;qjmCucmL_G5Kq02bgPf{^We#B7d+^77KrUjro?IETh8|zz|V?|8m8(z3R6&2J#6V z^tz$L#M*FlWAfKsudH1UvIW_NA2wAAV2F^(U$ZZqCuhT5mkmN?0*y=uk}49vHYX)8 zN|-&Zbz@AJD--;8k3@ukb*BTP17pJ5x0fr{yl-5T;Fu$L&}#!n0AoV)+z{I!9-RsL z-3Na6FdevMwW6w1q97@O`Hlh;YeVnn^qF6dKiV0@qW!Q)P=g_-M|kz|&PLXfnH+o0 zYcQUB()dDv>&=WYgrgpU17kw%-!&W&LeD!BuIU9Zh%j6`FL!nGkpq(~;#oF-V+1MqYh(WXU!>gi5P8AF zI<;EHAVpU{yBrX@88lbBE%H}D=~f95hHJ+sUR6v==*(GnPT;UkY(#A!4;RDh$4hL! zv_IWc`fEwWRE@N!yI$2M1u#U|^RF%wVD^4yeAP8+#?H_kZ&oz0-LO zJAY=(V_d+nA@5zdtP0z-E0U20X5!|d>&2Uy4*c5bpUh&Qvv_;@=^HV3BIbhqw9);s z7=zC0*Q?As6UFX@{9O%pkKNV1M-NQ;T`PFz(E2V1#)Q9bLS?nsrrlY-dcXMLAnV;9 zSsX+duAQvAdM_z~tVN%uK}?4$0Ikv5MvKetEZY*J7pvyTlu+yBZ~b z<_hX8oo*slyog~#AM1g?_0OK{d1Yd#GxdAO{|WB@wHVe^Ga4{Xu-v(>YNh{V@8sgI zU9Ubb0;M+Dq8-SP7qRhrO<8Hz_PT|8Z8l4bFqnPeYWRNmw@vu|3pb=rJleU&Dw2nb zVZ*nw$b;AS?5vu#y-v|~`L9J1A`A&{*E77?v1t9@zccmsXZWrB2a1jj-?sV9ZwtS8 z_NshH`cJb64*At}tFJ~+Zen8KI>5GL)5cqORG;_$x3k&#D?LzyLFZWg1oQ4h)jK;L zt^dF0)z5vrW>-#LjXtt~VS`1D)~x-rSFb*L;q9 z_?ooh)pM!e=OgX&*Ui&r6KObFyptpCyvv^TU-!JqKe|Eh+-$#p=ABHe2_M%n)VT3% z{>~|QXO)2Maw~U{>{pYo2Cw$-W@1e+#b|Xop(&k!No{;B*$x?j8N z_R}fHW_6wKVq#6GjG0<}r1+beV9n{gydIvqq-|?!t>?wtdZ%eHoPNIR)#0QWYcIQg z$gR#T^||KvC)zG-?uCjQLK+O-uatuY6YB0MHr;+&dv&pq)emXQrByj;AouL8&wA6H z7H2ohv%mbv)2sC><>G2RWK93g`SI}8^3NgmA`LfV7&oMs*ZJjdm1o{#o3rlRs=gxA z1=B)>`at^+l50&87)Hh=o2bLLiDUWSdw3&pEHwM&@p+}ZK# zOYL*-aMxEo_BuUh&+e+Sug<-<<<{{mVp!8)FMLoZXWhM-zyD{yUG?f}FQdirkpCN= ztbhLI)s9yadqe-t|17qcVNKuvyA^f}r{Bh}+An^1*Uq|MSN^({-zHlwdRD%twkbg?I| zcdz^PBSfISN=v6d|IYh%)9s5n#BR;^u|E(I_v~i=9%-rD&*H-FF1FfawW!J=S^Mt( z=;Q03UcSn{nmezsB7vWaVS7>G*Ilnv0(F)eOLH?s%FLhFcRl=f$nP-rkaqv24?13L zc=`SPitJ^R|F&LbUpD#g)81&IZ66jgeDIBp-@COt^C3l3EUMP`?d21xQ+do_^FXPU--!-K-OKSEQ&U}CD*x%pLatjzf=vr6*QmI^{ zG40gtnqTo+@%2sjf?`c%4ZOt|_4b`!9iAe6bJI^zK?W{{@++E>JX?I?u2wY3HZUEK z-L%vylE?dD*D-a$AA6>3>)s+Y(FZQbZBktwCev2Ob4WX zI6Xd@uCpvYGl!8mfZ@hFzqR)i+FC!i9ed)&z}jG1nEQ2hujO-{HtkjOMVJ>bY`AtV z+eW&kTf^+m*~=&Ya|JSRF}!|w^y>YNMCaVKb@u(gSw$GG-7J>A&3sehxrx}nokGkS z3~N@uUM-(%bEf5MY3gIm-s>Hps+eQ`x_LJ%(imPpUiNCnv28vKtPQ-yWnT{+<72#b zZR=IeoA3E`Ec=<~^#@CxH>mH_xOS%W>gQG4yO|1D8(cpvziNDBLt5Rks+|&`rh?1* zoI8u_rfO_ky*%x<>H{tY?l;d~UA?+_RlVqO*)r#^5$g30j0ur*g8sTBPiNSEe)%f- za|`4h7!yv;37Z?S*InfPw(oxG|1X1rOlR@;(Ege0I6kbc^!@tbl@rL1r{8e*vS0gC z_IRGz_FV6;e_na^>ee%GF?hd!_G-G+<7|7CzUZ9`szSc9gGOhby*PWh^WAr&C?mXGuH50)VCJ@IQ}ZJS6g}duWbw>3}FY)UbSAmSo$*a{yk3X z|K57FJTI_e-=o`%0SpnY`s?{}dW7~B9cz7l>Q(=$=GFH3K0Yh~3=x^v!_KemTl;Uv zuM<6Sw~tGA|L)5>^LhIr`H0GStOu^|Uh(VYtLoK{r7pAdM_syJ7bP1vH^w&AbKkf9^Xxvx$Hcx}%d*3CJHwB(ZD!lJ<^})t*y^3U;6k-D zbL%#r|MPqQ{Mu&n?&v<+5@|*3$K=k-4DIH)oO*+gR+0J z@iY3)ht8kV=N}@UduQH`d$$=DFl;DXmu*v6a66d2eBHNMT?g|nhc@0_{dbu*d$oC< z{oAnIRr{{&nDc+>%gdmw$8+6(f1LT>n`wdI2I<*e?=RtZj?K@V)pvW-*524)h6M}< uqzWgoM=+Ez(szI#G*3pesSJh#yMERe9(a@E`OJM0$QDmmKbLh*2~7ZqYIIgBD`ZepU&g1Z?%sU?gKEJ)b&iZN5U3vRm_v>f*pLai| zA@TIvq2GU3_f`J=crck?%KrTFs`|rU^)8C(v$$#z5U+zoaAH)mn&(ZPk{qbDfU{|fd zbH-1RU+2_rUMJ4HsOD+?{X zA2PCeOckHLxZo+ z+LCF$F)1r8Z{m|(-QwZqB! ze&&JVVjfwunkyIG<`>M5T;@01?sd*){kTmN)TZgimu|gkzO>h8Gt@(H~WX)&w>>0lA_yt8?x-=Ub9BVzWgPpk`gYR6? z(jbc&+~1c6SB-Lu;KT+O(}EU z#s=JEn=^BIpsnoDW8s#bUTPKU3y*#><<8rE{-tak`}A!+?*)QluWb!|&b`$vb&*F%ycmp)1+rp?2Y#EZQ{JqMncNBH^3qLQLZ(tj_O9(`oGvm* z+j*_NPxPu@xvlw;Bq;Ldh#>RRSu)81~OZI zX@+0W%4L|ieI3{4=I_@Iw<> z{vxH^uND(aY9}t}p*T1kFyiwsAb$!ia`;P5%ZueNw7zo~CKm+LcsZ^PasU-d^xW;e35d^3CZ z{$9XbF`+s)uhA?+cbh7Ueo|||=fsM&-DxhiQml+ybSGS8{615;CQ0Y$!jz+VOY0e` zMH~YinrhlhGk1y{;a%1A_U&qZhlm-gwfH3N+|KKWpEmJ9LvZQd)nVJ06nk4*U0_oF z<~Lu=jyqa>@3PxpW|i-kXnxT-ZCRLo9RKYJ`(`cf`l^%pFyZ#~+rKQEUKwAw>;87t z{8{-M^3`296uDRvZ#<98*lDDD;BChCfYy6zM?0qp9S#0=vGLQ9on>BW(o5Cn^O^rz zul0EDl$MZZEuZb8_6VoHOf5LSD?nypz+K@|zNG?^63Y+8Ix5&)v1>92$b8xpIU!>4 z9KL6{tgOZ7HSc=2q}N?wzHQn!nOA-BQHQ#>zRyyx`I@#K-@4+0u%CzSpJRWy-it-b zzIEw6CM?|`t2_DpvJ0}cpZZb-iwj*m0_8R-8(weClRD1p#=+obA^ze{$F7ZqMboD4 zJ-`+3y?lG+s(B|=lEwj&9|LfYeFt!~Zem#-Csph^~;ow?_om~aV3yw1XwSDWe zx&8TqR`<8g{&}sbk9j6k>K{4Xvmj>W!q-f}$x8bdzd0y4OKFMH?jFOcKlP2lw=?di zn*Y7g`sY^fh53E;+`r~?H(l1BGWARTyEkw0a<6G0WME)y$#ixO@N{;DRbLDY6?1AQ z+Ik!g5NY)fUaBO@ZtYRX5j0n)qst;ov@1aI3fEez8GF9)&oWWb)az@?K6r5c(N)de zo7eHJYf|{Z{OH-UB`=gz?@bqpI#TiQqusgP&p+I&ZMd+);Or|Yc9Xkimru6LIJ>rM zVR(yxE5l)q6K9N`sc5p^|Fvtm->Tgf=Rdvwz4_ObJx3cFJZw($TvofMFz?4xeN_C&aI&7qpA)4=ADf?Zoz6N>($M&2GPRU7rrj;v*Dt%gl8I%0qxRmn>&+Tw z`d@mTt6RqMC7)URdamzHX1(%;gX+#^so`9ggt%BA{O#GN$azuJ5kRqY8i}7 zR$t*QDB$Q3)2zPx{I2cX;@{hso9>sFEG}w(oTV+pz`($k+vUFtC?+`ns||WtU=9S9rc*^8*G3t{n`lX{q^=DO6gl%rfgyXw)BScG$94Fb9{kjqpE~oC&2+FI z80PU+d_TaG{kD7l1ZIH-R{pB;^EH!H_J3`WX<*Q4sdn<4wQSC8cE%5}BCe-4y{XEZ zS6eC2Ajd zfdBAxUCD?cmFUXvA!+?ds?%bz$3UQ&KyJGatd}0BG+kE*0uXpuD z&SN-`e%Rne(ase*r(bWH=jhMy;N_({8TYTX>$l~GhEAQeEb*`a!;d#V1%K~*A9+id zVNK$)^{=B+BaKdj*zxydcVy3U%e`F`$j8`l{*KOjzsrSr^B4|%W_+JkRUXL4EXQ)- zv*Ue}-dW26ua{n*DW||NZ`LwT-^(v*?2J!0GptFRR{s3i-JJ6}{7f5~BLDqlv|+R5 zIN)`qbgh2;z72{W(keDc;mcXr^PBlmW5$n$#=a6-T}rNjOB6WPtrvO!dmj62A%-7H z98BO0GD#(Hl1kU(h(M9r@85R_>~Np6d%NK0c}`3Xth|2~X{4;3YUq|Ip~cY@-_>U5 z<{FqG(f0cF$mFFIO7jX~%;OsdmWB+~QADj$f-=A_g-@SWx<9UCE4aW}FTgc=UZINeTFi%?> zIxRJagQ>yv+`rjp+y2%uHJG0I{3llb{n45NsSoN+3IPHvT!#ZPXK8s(di{3YyZO7T zZ{Lm0$-TX5UG~@7`oHDgzbe1~ww1SGjQH8snY=W!O7=_Mf&V%}$0Q=Nx88qm%F}#n z+unQ6?`?bgCi%eDZ!8I4jsMl{51;pZe~JI^?Jj{T*=yHql{y}nd;3*|&8o~{Ijc zjdr!aj@Z2qXJlZEIQqa|hR^Hrl>7tXw^?6r+9hjO-fP|bfB)^)*>w?&750bk-o0!1 zBi!V-{!1mDFoyRhY-6WM$L!nB#K8Dr+SV}Fzz?1O6(%S!#2Fp?;IVqD-qf3{5tR&0 z3K#OWCwKl9-nXy4pJ9y+L*}ea_y05P+gH|oy>qP%!@Fg2e`*%|*v3_`JtKK3V8%M@sc=U^z`w%0Fm zTDN_Xo$utnqk`eX;%Uzg8|ajUFIbn&_iwE&gC0}G-p@bx>v-|u`jpW)AM@7IsTABA1tw)^kv z#~&?LM{diMvbC`>_)+id!!T|0pZ#AM^~4*_rWIf2Ub`&w($k`gmoE!9&u6Il&Hd>1 z#RrymZaynxf3@q{!-5?rXSdwVdQ!A=!+C#(I-7)LnV+6jHCW!gb!*$&f4^Sqzb)I% zU;X5kiSPBzLU*1xGsJDFx9D3e{+*@gvBmD{+wF-L9zR~ZYE{s@=b&to^qQ?Ma@wj@ zU6Jz{_S9Sm30-*-p`eA1kJ84fC9ILSnH-mq^<~{>2acZtqeT+M2t1)vAbB!VUK? zf8V=mRn(WwjMK~Ve;e>z=Wf6ApnU(l;|F5bJZ1a)c|RLtg>h=+si@sILJzzwiF&gj z!QuS;@8--~b9diZezz=k`;YeG28KH|oO`cCyPV#X!p6#2(Y^G=;lqa)DFs|N_20(G zSfL#ns+;Ce8oRu`ojw2l4Q{4_$+3-(KU(~r?z-c0rp{kShI@$xwLd?lZWEu@^LWMb z-e&`|kbwt0rf4_;|k6u5WK=e;;>_lgZ%s z-Nwr=OD;F(zUwrzIVH7y!z=L@w)dag$?(b7J%8}AV$R**G6#mR^ccD>7OasIincb!1P&b4fJ_A=-Hy|FrT);j+iZ+G<-|50ElHujyoYhCd5QmeUs zo35?W;F3(PtMY!m{hv;Gdvxse?wZSd90yh<7wn7)37xt~W0K*ss`}rbZ%56)f9uzu zY1j6>UvHQwFVB*2Q1Qj@f4_1VUp{{?n{uF;p+b)J0Q(*p5qdNg8+>s$6Ty|)295s= ZkIvdw6@OHYW?*1o@O1TaS?83{1ORwU7>@t|