From df2bab398def5c27112b84724aec0640e5c38fa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Chris=20Dos=C3=A9?= Date: Mon, 4 Nov 2024 12:13:29 -0800 Subject: [PATCH] Player features (#25) * Mockup of sleep timer, playback rate, and chapter selection buttons * Playback rate modal + streaming/downloaded indicator * Refactor store names * Refactor book, person, and series screens to use better queries and fade in content when loaded * Refactor media screen to use better queries and fade in content when loaded * Re-arrange routes to make more sense * Smoother sign-out process * Sign-out if we ever get a 401 * useFadeInQuery for downloads * Refactor player and library screen to use better queries and fade in content when loaded * Current chapter display and chapter skip buttons * Chapter select modal * Hide scrubber timecode unless scrubbing * Add new local user settings database table * Separate actions from state * Add sleep timer enabled boolean * Move player state updates into player service * Cleanup/refactor player store + playback service * Sleep timer * Tweak media loading; add some more forced syncs; style sleep timer and rate modals better --- .github/workflows/ci.yml | 6 +- .gitignore | 1 + bun.lockb | Bin 661112 -> 660392 bytes drizzle/0007_numerous_wong.sql | 5 + drizzle/0008_hard_raider.sql | 1 + drizzle/meta/0007_snapshot.json | 1323 ++++++++++++++++ drizzle/meta/0008_snapshot.json | 1331 +++++++++++++++++ drizzle/meta/_journal.json | 14 + drizzle/migrations.js | 4 + package.json | 8 +- .../{ => (app)}/(tabs)/(library)/_layout.tsx | 11 +- src/app/(app)/(tabs)/(library)/book/[id].tsx | 104 ++ src/app/(app)/(tabs)/(library)/index.tsx | 53 + src/app/(app)/(tabs)/(library)/media/[id].tsx | 757 ++++++++++ .../(app)/(tabs)/(library)/person/[id].tsx | 269 ++++ .../(tabs)/(library)/series/[id].tsx | 86 +- src/app/{ => (app)}/(tabs)/_layout.tsx | 22 +- src/app/{ => (app)}/(tabs)/downloads.tsx | 43 +- src/app/{ => (app)}/(tabs)/settings.tsx | 15 +- src/app/{ => (app)}/(tabs)/shelf.tsx | 0 src/app/(app)/_layout.tsx | 28 + src/app/(app)/chapter-select.tsx | 140 ++ src/app/(app)/playback-rate.tsx | 181 +++ src/app/(app)/sleep-timer.tsx | 185 +++ src/app/(tabs)/(library)/book/[id].tsx | 178 --- src/app/(tabs)/(library)/index.tsx | 89 -- src/app/(tabs)/(library)/media/[id].tsx | 1253 ---------------- src/app/(tabs)/(library)/person/[id].tsx | 472 ------ src/app/+native-intent.tsx | 4 +- src/app/_layout.tsx | 20 +- src/app/sign-in.tsx | 6 +- src/app/sign-out.tsx | 22 + src/components/Button.tsx | 36 + src/components/Description.tsx | 11 +- src/components/IconButton.tsx | 29 +- src/components/MeasureScreenHeight.tsx | 4 +- src/components/PlayButton.tsx | 52 +- src/components/PlayerButtons.tsx | 5 +- src/components/PlayerChapterControls.tsx | 87 ++ src/components/PlayerProgressBar.tsx | 27 +- src/components/PlayerScrubber.tsx | 19 +- src/components/PlayerSettingButtons.tsx | 110 ++ src/components/Scrubber.tsx | 26 +- src/components/SeekButton.tsx | 10 +- src/components/TabBarWithPlayer.tsx | 150 +- src/components/ThumbnailImage.tsx | 4 +- src/components/Tiles.tsx | 5 +- src/db/downloads.ts | 70 +- src/db/library.ts | 1283 ++++++++++++++-- src/db/playerStates.ts | 3 + src/db/schema.ts | 17 + src/db/settings.ts | 75 + src/db/sync.ts | 8 +- src/graphql/client/execute.ts | 5 + src/hooks/use.app.boot.ts | 18 +- src/hooks/use.fade.in.query.ts | 27 + src/hooks/use.media.details.ts | 23 - src/hooks/use.sync.on.focus.ts | 4 +- src/services/PlaybackService.ts | 91 +- src/stores/downloads.ts | 203 +-- src/stores/player.ts | 724 +++++++++ src/stores/screen.ts | 9 +- src/stores/session.ts | 62 +- src/stores/trackPlayer.ts | 346 ----- src/types/router.ts | 4 + src/utils/rate.ts | 11 + 66 files changed, 7200 insertions(+), 2989 deletions(-) create mode 100644 drizzle/0007_numerous_wong.sql create mode 100644 drizzle/0008_hard_raider.sql create mode 100644 drizzle/meta/0007_snapshot.json create mode 100644 drizzle/meta/0008_snapshot.json rename src/app/{ => (app)}/(tabs)/(library)/_layout.tsx (61%) create mode 100644 src/app/(app)/(tabs)/(library)/book/[id].tsx create mode 100644 src/app/(app)/(tabs)/(library)/index.tsx create mode 100644 src/app/(app)/(tabs)/(library)/media/[id].tsx create mode 100644 src/app/(app)/(tabs)/(library)/person/[id].tsx rename src/app/{ => (app)}/(tabs)/(library)/series/[id].tsx (67%) rename src/app/{ => (app)}/(tabs)/_layout.tsx (71%) rename src/app/{ => (app)}/(tabs)/downloads.tsx (88%) rename src/app/{ => (app)}/(tabs)/settings.tsx (68%) rename src/app/{ => (app)}/(tabs)/shelf.tsx (100%) create mode 100644 src/app/(app)/_layout.tsx create mode 100644 src/app/(app)/chapter-select.tsx create mode 100644 src/app/(app)/playback-rate.tsx create mode 100644 src/app/(app)/sleep-timer.tsx delete mode 100644 src/app/(tabs)/(library)/book/[id].tsx delete mode 100644 src/app/(tabs)/(library)/index.tsx delete mode 100644 src/app/(tabs)/(library)/media/[id].tsx delete mode 100644 src/app/(tabs)/(library)/person/[id].tsx create mode 100644 src/app/sign-out.tsx create mode 100644 src/components/Button.tsx create mode 100644 src/components/PlayerChapterControls.tsx create mode 100644 src/components/PlayerSettingButtons.tsx create mode 100644 src/db/settings.ts create mode 100644 src/hooks/use.fade.in.query.ts delete mode 100644 src/hooks/use.media.details.ts create mode 100644 src/stores/player.ts delete mode 100644 src/stores/trackPlayer.ts create mode 100644 src/types/router.ts create mode 100644 src/utils/rate.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 883e6ea..c48a98d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,10 +30,10 @@ jobs: - name: Check dependencies run: bun expo install --check - name: Run expo-doctor - run: bun run doctor + run: bun doctor - name: Run eslint - run: bun run lint + run: bun lint - name: Run tsc run: bun tsc - name: Run prebuild - run: bun run prebuild + run: bun prebuild diff --git a/.gitignore b/.gitignore index a55e649..f95ee80 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ npm-debug.* *.orig.* web-build/ *.apk +*.aab # macOS .DS_Store diff --git a/bun.lockb b/bun.lockb index ebd57457aef87b2cd5cb6e1f4f784fe6a7da4a4c..6cc592490857dfcdbb4780709f4c5bbac364c1c8 100755 GIT binary patch delta 110333 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 awk9Pk%keRo*p(|zXRDbz&~2OItNsrc+LW09 delta 110900 zcmeFacVJb;+V8*CCL6MlB8Y-0qM~Ae08t@eAe$;+14U6$kdOi!(i5s^V!?vlr7pXO z3I;@tii!})mYzO) zUw%T@n!VdJH^lSuN~TuMBRqdrRdrrzfmhnh^ICacZDnCzel_x(G|M9}K%P(hPT<8xC%E|XWRcqC z4TpC?UggT04%VmzlL!ctRhiR@%cgoi&}RJ<$pkV%U2s;Vo{w6btkm6U!Rp-dRH zv!?$Lr0m+bOP@czY-X|`ud=ABaCTv7b=BSNtpWqXF|n>SA&pgg%afIP)#zC=Usj;> zU2Su#rx%v0mWhrRf@6tSN=XG`&dUbYoNR}qc^Qx-aA(ve~K(6Lx?_ryf?&L4QRq>Oc z6nu0~oBmQzcAwV8;wf->>{fV3ug0t0OhB6M?eP7*Yyo|7A7%XC6*PTstH@u=nTLgp z1AypQ``JF8hi$nV=kbVGrwnhtdGGtCj}Hz)xp-UN3M)Z_lI2 zwcmioBOJa1%05NO(t^xcGt0g2`q%^w4$Cu(%kptbFjiPAc6nMyC$`$W|%QClz74feAapC3hJ{d3evsPONwjpj6sJzQTDZ3a{ zo~*DT-hJTM0oK;HflB=Ys8q?)>4lZa>Z+-;ii)IJvaql)yWb(+-92p+PY%a4?|S&W z9NS~l!FJ?236v)v1**=Tpz5k0WOEI5@_r7jp~}66KHW%>pRmPS$MD50s(T})9?KQ(ZkFKs=0LOJ_)HV$FJe;le z(h;7A@6?_RsvT8@EDU_=M_RrVl$8n#GmDayRn^x!zUDBOzB;dZ7Ry0BTvjb8EI0X2 zb?GLA>spMenE;bIJNL4^-2ZS}>K!0My7qEV!>srS&trMHNCWW)@6s?-i3!&8R=x7O*V5Hobehea2bouAqut z8or#~b@$q1oGW6Y{gDnk)+)6$41zv27f-Y!>P%31Mx0{#z95#ZZ2|59RuV4<(Hxss zT;<(KDdL65TZ2EGWF2YcWIJ`_PtPl!iQ%(OwN8E9A=aR*d6`AUdDB{uZ)Xkl8FumY zPCy`w4*;3%YDY7iRB8JK?hu>G&j(rhS`fn?(7W2_9B$6`lZBc|E}z zwZJFP1Kc>l;!20-fK0fxSB$s5aL*iTiBphk6dvNRmBUByiQSQZbi9p!)Zr_~S;ek& zeD?*GmmH7%yOZHCB7|KX{^T;;1jLXr7lX3M%tcm#ju&Eod2u-r>a+0TwT!?lwu{9+44R)&!&6K;iWYMRNzcdEg1@`D>}IZAD?TT=1EY)aWSX@ z=Yj`<$2;B)ly7{s(DM!f`(lZ`!RO&BcO@vrYC-wJF<>9C=KE_rZvcUw2o3-@pW}JC z;4`53t)L3{hL&kq&Vx(AV?Zg;4O9cZIoo>m^I&)Q?V$44I6T_Le|eVY4Mu(sNIf;R z`2>za&;uL@enX#0p?*}@8a)11i&r4mFq?9l=XC<_cYQtjcB|NPPJT4lp7_CFJFsr4 z&DRaC{JlYS`31|At`)>og1kDr z7F4)m+ODwr&eT?-tjP1=sxMhpHg!f}esw3OM;lPp{y_Tzu(YgzxjngWjDjx%W7S?LhTe7lwmOdB<=&v6O_D zx9wQdaGy0$NnYj5dCb%tCgxNXW0X}^q14R%*Tw2+@~PBZkQuzT52yz252`_P9<*2l zsspMEOUh-*vhu=Gs_||>i&pCSV$@PkHFN5%d&sJpT2;5Kwmj!yTTp6qkXkKoaq+3u zH?_*9_AJFZV^HA(l&hYf_NZNb&qINhn)y3CW;JgPs>@fngz?913yuKgYK4`RWtEwQ zbH9bFpcz$}=4_Z(Ijuaeva0amH8%ZEltasEH#@v=t>v|#^6k3L);9wTY7mqZR#%px z;pI=*{la{QfA#$WfxoP%Nsh8A?8NLCVFe zr&pHEnl}BQxz&ZGT?)vM+C+|g#x9b{s=Ug|ym{Wha9z8OHQmUro;?;+N40y_w(}q- z-xZYIzC^Cs@^{*$cBa<)E1$FKdRHMA-t$p5>H;1i)nsew2HhpzT zc`?cz>IGCFgMLTBa;=J4g|iBY`CJBsrwUfP@)#QP=#eVoj|wku-?ip)`dQWCF9pTP z(!xoZ&T$>44iEdlyO6Fqs2#`WSWlSvhn;gD0JSklS+R?g|MIe17C~tER5NaM`1~u@zK?;jV`1rRW2d($a3A<;hpBEZs=^7YEAzhGXw5RC zs;smqSzM@Nl(vg^ykX0qSzI`)5(oEQrX|w+5m0`y6jVo*zGeO5HLwFb>usxOEhvkv zckx~SX*<3HD0ym1s4C8@nodiPBb{14$)!&nzs{hZUGT5klL@o~Z+^!nJlydfpe*xa zPur5zLGlZ@DoPzV8xM%3)@6D$v-u{xZ(F)Fyu3rlb~iXS?E`Dalfu;Cfq)X62`a-huroLgRLe(# zvi51Nf;C(0gnJ*@3i(pUC!vsft8i|)W-D*Wx3-+T46AtgtkQY;EMJR|Yrfp=dm5(s z?ZUOTpuakB#t*ilzdG`43Q(6#`_Wc#JlGz77^sOTb;!tpOYx7k+OByEl*OuwXHC-u z(cF^agC^+!+8B{HSBiVn3Yqz4d){2B3t#rLRj}L~CAOtc73}|F75x=dh2N51zOX19 z*s*I(0qRPp*(7^fl58pK9eB58TPgUu;9eSs={ey_Iy94y)kL+EubpA#$k~b{=EF@6LzubE6Jz$&*3%F zbV>)``23p)QVG)i-1A6BN?eTf3WY#$IMe-&o=Nt3%-Ji)(H72K09X3d)}RQkh8?+gjg45gw{7uBMBtURLqPR$e^7ni(Pd}> zO7mY(L|*-cQ|Mh#`3~x4<*&h&Zw;u5?f})0Ye6Ztd_PhbbY?i0xP z*HvHpY3jGtqG< zp}rapsY92h`k8w6fKK>aAl2+s+sUno&`1M@8zsH+8?8`o`d| zUop6^78<`#0uOWpsL&nhX$)ZVNiM*pd`N9EcIM>xDa%g%r|IfbqSH98hK ztaX?=U41spI)CabpAzEbP4^GC4G6-#z53LgJ<`sze4nA(y}58Tx0#c(@0!N9S6rp> zzv+eE*ip9IO57J93Alz>Ur=T#A8qsR3s-k_1XW&ZP}-K8A>?_tj4|o=t@RJL{q+L^ zC9JBdG@4ZBRT_mlhFM+vjQMviUQk$UQso?FtLqD@6b;;|8XHGj&Z*OUL3U@}_c+_4nFXQ_|X5smyS4Ab2x)S%iIa6(erh=M$$K~7j!{M^H zU%+y$0*)e}jJnp7#-GBK;cZay*7^H_MQo4h{%)o(TO&|kUYWEO@WQ-(J7&%!o75-;Ww2w4tr{Cax!{A~ z-rx$bRe15f-D>)lS%tcS@{u?wMbgVHvNg=)zNoOY%z*c(lmGROV?l-Woxk2$;7+e?LY#V2zCYc1GiLJd=l&pzsg}L*d2bji?5nJtxT3Lnj2Pj z>)oy`<;uW&%(r^~5!QF>ShMIf>)EMq5vt)b<9Q2gt2cl$zqx+t#}`0V1!W}^I^+y% zr=Fnf`PS*y%2R4==?{SF#@&fmYw|MeP)>)-id#vKHZ`@q&a{FCSHK2Pju<=37PJno zf>I}#^O38gI-hORwF1@6<+@Shv&yAk4yuDwUudSj$XtwE-Djuk4t#@-Km{&YXd^B; zJ$!4w+?pvELjF6WP*;I`BY(ct<2z90rfx0A!&TnUMRs8Q>pW|Sk1w$Lopzx$)LR$X zURwe7A^jq7AC(X84Gss@dwYTV%P`FdXj7G>09h~rO3&nFw!tSj`DjoBp)CpsZ@%32 z=ps-(bHf$3oFKIQzYwln-h*((SHV^OG*E`N*SXAL-_QbCHuEZ*fI}&_s@!H%kuvNB zsw9Z zoi%acT)q$H^96EgTFCdqT+q#Xfr`aTuD1nEaySlD{*lZTieKx}ZGfxdQN+ultB|Wp z?M1a$d)JN3VQS$m1XRF{H(5(8cL@jHY<(lOxc7#ufb7M#3wnbpcz=flg#}zicwW94 zkDfQ?G~1vjZn2g;Y>DlXzMuwQ=Or9D6sRYGG`-?hn?TN(nO~e7c$=;8uNF#O7p3-O zQ+UfM-DY$QxKup(eU+vRrM zx&y9dlU=&hmz$|gV_{W!Wg*MUfR)yA zc{P7o=#C|$`tUG^SKeh44uWg=^}X9Fz*TgqDm(x#`7RE-fa;QhLS7amtGt6;z6{XF z%krx$xwhr%q^*lT-xY8&*a7)NkJx_CdDP}hdA#m3nasTv#2W{9=`IA-pmJ~zU2GLNvz&xL z{k-CF+mexRRd66E1v-P8D|QBD_R%Py7I$7}^ZB4!mU^i5$y%HKO;8Fv04n_+Pg(`$ zfxGF7?L#L>uD7Op9#jSE!ulQ^YmS9Ut)ZaC$xRd^JDuyWKWeBwx6@V)(EFdU={x+* zVj+(=lEnqyiO*R+3{=PVa=4pA&*A6K+BtJ0Nb_rIpR|G6yB%Kd@H~fA4o`J>gu?-# z+PlBQ_749*G08WBQv7X)&pLe2;bI!nK^=4%0lC!wuqFR@4bevPf7+5WU$!m1kS@}` zZvGo~ikh3Llb+`_d((D!g^OpBFhk%i7H%ZFC& zmq7K$)1dtHQHS?|nrH3+<%!pTN_Qcs0dR)HDi>e+iIo?EO`_!zms32dKc$EU>y1$2K_U>5o{g>7rsr?}P%gO>wwh6hK@jfVfyy37Y zIc*k`gZDJ0Xd;=x#l1?s1g?o=(l^##<3KfY8FCGfvESNS2Y{LhI(=u-)zp>~P{ygV zs-~9B;~M^Z+rD2&C^r~IK~mrlP-^%3!4B>3zqTFy_!e8>gdeTFhk=^$dV;E`EhsnI zh5Y+~KayU2zn`QaBa^3`WmUA`H6r#UqS?>3pcQacc&fA45Kta8t28-RSL&%ZsLOw` z4cLo#<)8nnRd6+27APp3S~jaRzpxUniXZ;XTD&TsdoV_(H-qaXneeYSL2m0^yTWC- z5tPYeuAmNH%nYQ42CKkxpcGsNb_1^fwQ;NlHLzZf#mobQ8=J+9*d^_an^sJ_XZ4u&LpuMQmmb^w{x7=hyWy!mJ?jgP`s$2@_tlO+ zux&7(ug3aQLOk3uJ==dKY?zwx)5DDXgg+`w<|l&1Y5Mk+Nk{RqkJ7?T`MI%;@vuih zuAdVo3lhNv&FwprW=at}20L2mqxup4fb*2pgs+{Ms-hnF#J7W*jkTsyL7v zNDbSF5wP4wc9vt!qb*0!B$y1A7Ht_1!)*q^Vqtwzb}-1Xw6K0^cCZkpx`~@A(7L`mhT=qUxs%1htZCW) z^I@_i;qM>Tl_Y|ba8BtRH?57rjM7A`Vdrp0X>PD5ZX&h)C~HrDK-f^42yR6*L?UDQ zZ`y@3%5sUSD@z1(n7IZJ)hw(i%=RA(Gs+Xe_sFD}sWyH%GsNLxW-=$Zl2BvX_zPmu z=BfUmFj@~Yh62!`WvrVv^BX4q&`NX6CxdU>t#>7Zp8kYOX%BCME^ z9V~&dlsM;jwUNnln==_7X5z$7f~oVYLtGEDZImXjgc-9Fe!nm|I}wz#A}gkaOdi|; zQ+EYneOY#}1!haEC?4SN5zZ*c4K84KD+QLFogI5`k8snR++bg(M+wu7&wk>V>GPl` zYk|C@xx6S|Ar`KjniH%fq~1#lE2ygNULNo9DK^TQN~2+Fbh;Umw>pNWW7IEU+r0A7 zFgZUF%x3nq_2W^&JuvH!VqZAM0EHd6x2@H9STGwVm+;IOdKji!gK$f6cCgj4=3xaR zqdzN|m8+aak+&VYhJk8M*PZ_u~n8n3{$Q(!{tS}K_xbgpsmfCAOTZ37?O1eeJ)MJS0L-J5f*!YU^wHl+#mx3YObR73$lYU7zUs+ zx%Is;72t(iE@fcDFsc?}nte%*9YnU5-h*ivo8>UxgDyHOTv?VAloQgL-`))1hhgeH znps6N2~woY$e`&v*!qB3G=uU+CU3b1Ci9p^2cN)XQtO8M5tNQ5E`AEEzgeBGASAQJ zqO9|&%P~6v`~XwUX73U2o1n+Tk{LPvl(6oqL~tcs-odbA0{a{$o#RnOSIWUn#ms06 z@*T52wgNVRIAf8xPqT-}5rUzFs8BQiKUrlXwkk-B9b}dGZ?HqdlKh-_HbWv$!!`Z{ zp~A3a;h?bLnnb)U>P#~72MC?2kiTnKcWuI-5jI?#h(E^uXoxyB_>GY2!iw}!4jn7o zu=Y(!L@@Sb zD`W0pq?`%EV780m8(@cqo0sMU6HaL=Uji%A`V~)~Z&& zxU9h`glt!+XD@-7H8uVMp($bJ6NA$7Qp!C-Xkxe$Wv22+O{zFpMM%zQSGKQUw(GRC z?jL5{l?WclciYrxOAJ>oNNK@(6F&krJlwn_$G;@ZxH}Phw;-HxcW(TELeHCQT6hkj z!%VB6Ga>cv&P7ZvCbo#sT%$(%H0ud;5axdZHrtqAi`szc9J3A6VE-CcYU5Z$Cnr7c z48!D4pTH6@*00%8KY4E=m_$}uP}|ZhkZWOfy4tclJN_%o=;==kllLY3UBbHi67i_m z^UgIok19bAqv<57JlBLiB!ms=y}@N@VnQnj%~wcuu1feT!n#$7c&`f2J1Z=?FURj3 zW;~FHFRVLQYNvP6jyVpF0O70nyW&&zTY(9N#A}0_s$^5^C z4G$;cho0tnC8mI7gvf?!i3O%1RJ4*%xyje%^i=3#6H>b02w4Noq>qYCx{ZyY;Wa7Q z-Ho9=YUyf|ZXuy*syFsUZMf;0-1w1a8c$-3-$jUh3D$lz%vh5M{6*G3n7o)lm&52b zO=Xv4#}Bwrxk_f{_{Cwy+JygNm|UCKedI+fJxasJ=cl;>d=3iNt?eGHAy$Kc)3=rd zn6>oGi}AH^^D~3e2-sGibcxzf^5UR00@X(GftRLyKO$tCAHU4=rYqJzE6jK@5q}MC zQ;)e^A@$y4geaX*&J`&m+)ik=k?j$t(p^gE{4n#mL1|Z}I{9=$)5Fbo8c}7qxo%L} zRj$JL8bUbkV}sJJ_Pj-=tQQGQHM(X+s!4TTPsongp9n25vRT()giur0wY;7(8P*Ug zHIbRudEQM%_BPzxRqs?9XL1~1p3`-s! zl(xh*LsR|>34f~j{a~1}ArYT@8(TnA#0P{JJNFMtyFJy0X9(HI-Ito(AoJ#_gbue( zGCMoCAC?PapEW-__RZ3;$BVh~tYxXC;QFxc#YF74W#Oh5bAy3*q?SM}mUCd5)$K*d z>agymM6elIUu5rAHX6xH?gg;e@oZF%a)%TLF_M zR?Bx`vJq4L16&>4ZP$17I*&acOmW#+8x1pUjlT}dH5-Z!_gL*YjIeV(5_X8$ z+UN^MV%5KYIk_&x_S%WAy$z0P@9 z7?W=$;xEIGGdq-v?$ZWXo9Ukk9YS6<(>Ox!`z=EqOgj~JxReN4r~Es^hPM;JJ1zx| z&<@2`H7dr6c|2?!>CM5~?h^OxEFI2gQ(I0kT5`5l%Ev!|}Z*LYqwjBY+B+vn}x z$BAG)GWor4FJRZ&%@3o1-RTg=ti2=0?6}EL3F#P%5ocJA2 z7%d5TPr8tQVwn6a;a{)c@pq6;Feyi^*XEf#{w-m$ArbpxeYm_K*Y6uPG$j1|Fk=&E z?x*lelh}WnP7g~y9F#_2P?-5-t-`m3+p!syQ9U z>tOvg9OCa18WLvSFev?Rshct;h4@i0jc4<@NB9Q z-A>#nX3{o$&K~Z$htfEKCCF)A@p7S@k#5Fao4=46zZDFf;bGm^iQo)mO3!d&BDxDU#HME|`Qrt1otLx0T8d*06G#b+ zGbpFuWrQ@_*}nZ3OjCuuCusL~&)iez653_i{Mv6d1N(~icfk;uA@n06`=_xwA0I479~p( zVmjflpZ5<=r3wWv5jutZW>N@xy^^w3{gUkXbeKsMe=8LV`n+o0mjQ_Vm{@*f#zUs2 zStE3P&GwyJm>grqE6omWgvnfi8T_v}#zex+QV=$5P59%&jGq$0&9B?R$aUF3*yuvR0zw)_+yHavdH^Qtq^qRhXPE85EWF{+H*B1lbK{jT9C>+8a4#V^RRSkY3 zScOcwn29v@?Yq$o-_PBB?0fbW!{gIQzuc#Pw9fat2e-XvyA|^%2l($rJz{=t(E5EI zcTrMXvrJ5Z$=ar`gS%nU$c)f<1B`nF*4lkOuzh8ggJ2fSDvj%%x!+`{STuaaX;7Z93|s4we}1QPiV3 zNl%TE%}M&e$Lteqjq?Yje`1Fm%3qruUkYPhF3E}g`bjjS1$p{MbuIjae@@f@1nWPw zD`?wr%ff8mkCN#Kvp-{RPkAjO9f;C!c`HJMBjB6PWB0 zi?*Ce8#mdM)|V#2jwTKZa`1l`cBHXv^UqV|X$zMdW$c8CH%Cc9T2u$bCvWB&D_Xge zpA&zO;Hfsa+m{^7ZEzmJqz!&RFwX{uf8}{6+u%(EU0VO^#uBCwoMaO`M(_j^3=Z64 z9nIY81x2tSsLddKJ3Dv*Hq114%ZuEIe`Dj!fQTOr8y1zcVc-!OL#oyqvavV6i8i&x z`j18#J7dkx-`TatOvUjc7)u`K%g3SyMQ41U`fflA#vGVxxAXRcFnI!dr!xkmL(~t< zSj;T_e{hOLS#7aAOxes589W42O8f2A*D#HD)~t`S<7fV8W@9{jBcV~q50=uwUR!Mm z44m@p_+%J2PWR;mO9-hf`<2o+Tca5r7*TzGvaZxd9^w~A4IOC75`>4Cr23BYb(dr( zGyUxHGfTADNe(4sFX(0wk}KISeC~s(31%l5wE4xwMfJ^T9&9ji+`j8O(I}%M)jf<* zU1K&uev7D1!oz;G1JZmU5nSUKXC)Tg23TJ*@I}?x1ERW4Bp>sewE^wG7cYx4c1PIp zca|>F*=er~HUfrTteyA4h8nizTE6&!B^%kKKkUU31BJM?u)M~&eYiF_wUO2SiMkQGFAKs=G=yR*j=gzVva-?)*TkrTg_(1NIbTG}rDxzW14m|72R?z`1MYvkEc zMpr7o7oqH7zfJlLrs=>=4g*{Grhl>H4cWm=m=x<2R(!?Q1~xFo;-A9CMw@#vlG1&% zth5g6zvhcy*l2Ue?hyFy2H*-p3nUBLwDirWV^_lg=mePDxplPVK0F4N3u`H-5B>?$ z^0c!#IPJ5OZ(N!MmQ7Y6%=(?CsV8CfwCA_-&Gcoy{R+myWO=)*z5u3nwlYKNHP~n^ z2Yz-`w;x9NvJLhm8qX}tjvw9D*GcZ|ocIz#?3K>W34SGXyh*X8A=|Ht8uq7ORz?{I z0PUhA5MR{J*M^j<^S249Em-yJ0qO01yFuH_1pKJtY|EshoyR^R&N8{hUc1;f zGcFk3c`z={&&`S7Oo*H6X*u!V2^}47F3yS1?cm!}O8g~4X3~!DxhtI?mdwwI7Zaj6 z{>1gea=A83tVH^PXGWdlk*^?&j(b+XIekP$~6%}v3 z2MR@-H?Xf!$W%0+&}fB%CkP#GD$;(c)t;mO zmqt}*X2;Khaofyc;%|gFrxfMH+wY}vR{?L@C=Qx4wKInx%5o~-}J!R+Fxv*fujt>CxS_3vy#TU^pQWSM~^4uY{3M63rtqEFLc`W zWiEr+2{8#%{dVfP1!molaT9zBQ|b2W=Y6tlZCiY2WEp)%l- zCE3l^9qNZO=MN6L<=7d+dj33^T4Rnl!5Wx!;W-xj=-*&6vb9dXK`H0WIyXC*2J2(W z&iWwRzdFh|oC?GSD2)KqDdXa0Le^uHJQ!lT z$J%)$OatEB(fVga8AmghuSdvI#xnDJDijPD>KdWp>{mt^V`+8TFk2ShM^lD0GJW^B zKrGC>b5J?~JEtHHw!+MMU=C|ThTBr@o_c;GQ=6ZJnf{5kyurjh!pvVZ=VU@^4t-0d zp<{M{KHnIp=WV-nB?nNA!BYfKf~ z1hYM$CE;~gHafJ|$`wo*o~p|HiCRi z9dy%gF3w!51UX~urrBQdmBBO(**4z;lbxuKG1ca9>-jcr985LXN#+t*7L0>3<8%#7 z(~*7rw|QGiO6hq-sz$e`ga+}Fg4#c_+r>L#h-`iT(FaA;!a05#-ZvMm^HTyya=YTW}Ckr zCRNRyPHgMZQIAttnQ&e6r?BZ!)+vm~2~b-xbCQ2u)Nl&Ay^k&`gKFFfDaGCgsZ-^d~vSTtk)+W^RG~XQPJ6l)u}tZVA<1EvSa+%*%nC zua4G4b*GY~ZoHiV%$tr_!}zGj6i(@fM#(Acx|cOnXTJ$H zKW819a0^U5K<_Xzw!pM(*aar%gp}vWgKA)El{rboAAubfW>(~I1CxYpdSyjepvtEfOLoyYGD`DH=*)1FUmR^Kic&lJJ?tova{Sw?raE=bv2_sn@PT@9hiSK0pZ zsG*qk;8%p>!<8TB#K#tr*c>)4C8YDQopt`vl*M-~qKC{@d=eqGg#`k~zA>sRrC0ue zP!lVCPW^jN^UY-mlXOFNFbQ^wt)F+ptEWYq$}rGL)7@4-tY4KK+zqp*QC%aZB^f)> z<}yFW9~(84Q~s?;bhP%2yO>R3^h}f4_i`a@rgDwJ`09ENfqG%fWKYbw|VO%qMm( zOet;L(=Mgiss*tM>&2A9k~9RS0gg6YAzTaNwFEcqzYtPqHH)@%$DJxIvoq=Wuwlg6 z_B;o(HqeBTR%M5U)$y`w-@I+I@7kNs;`)PpHtsCg48yc2eDNnXVK!GLjd8F3i5)w~ z_sSdNo`5x$67N5k?S-z9gDOI@huMw9SBROL%MYXEeB5Hsd3GPdOE6}vBVkI6ftb6` zf$4sSNvVvX{1{ZNwOa2#-wkYI^*os7FWbK7VYUx6vf7+x2lBxt^4O0TMLr~xxBaN>6^jqdmZzHcILG=r%R?2*Ficmm+%FP$HLNepSSbFq z;=+>IIYG09*8cVoJl!#~B@ga|xzWP1{+WxTbS$^;xwbqkz=hGVFkVgY=QfK7*>fUa z2Ni~6WW7ixARi|r>*nJFq0vw*K2E-rfch)`<`!iZT1VW`(b8~nl1MqFfC8E z%LiXzrvSU$Rl&4JwfQD4viG!>t%AwL%=?1)uP|OY{yr%ELg!CrlmB~EcLB5i^oxD| zeT$VB@a@KD1oO>fxg#zyvxc6?5h^#)u`ZWJn-=-G@kN(%3#??p`-HUA*jjsEW;@ri zIWYSroLurznAM246!B)4<4k&I9gHEQ8(W)aHOvkvO+33^VIAH+s(jEfyX5U2T35xb zNSOzlKq=}bvN@jD3(w%>yi+SbH#B757!=zkGcfJt#S>Fh=h zy2h=XQI=+CnC2ckHXnj%gs^h+==awsc?CW1gq`c60qKx!x@~}InzSox(ER!)k@3@E+mftx1iuIce+V2s?gdBQ`@OXs|`$JrgfNgS2l;Sj3s19TWObA zt|MRMAp%TYZVrvS;Iq5N=26yIM)`WEZ3NrKpu=6ZY(^k2bB}Y3m6K`ZMwp7BNy%(~ zW0Y|lH4VAjw#6(M!TB&d(ki$GhNxkTUv_2RiJANTzV}d0xcTjzU*cqM1Z_SR4dNi7`lJR`z zqtGsdmpSDY}*=@#;hw(I#FZfoP zkj6*+8LJno6XU)WEOn0S%w*9sTs$@1e?H210Q>g*8$M{>p49v;TK)ibDSHmji|{&3 zNRYK#!JVFO44$|xc)JN^KEjuNgpROD2fsj(HhAu~;EUUWoi;Qk$lDfNzAgCew&0+@ zH>O=kaFi|gg>AuIUTl<3+!nlr;7FVHlWoDCFEvWb8-u|rf?A}pR&xf}R;asGDa?+I zc{$qjC{_tymQ^-CiYY!Ic$5wH`A1`L!M5PDCRnl#tGr^4U{-ZNV$I1>bB8 z27A3>_nLM&HrU*KO0x@ zC-wyFl*W{Mz2h$1Oeu3=CpN~t1e3P5lmp&v$`-&hsBPR!e`5Q+XV-WeHybvBlr79x zGY`YG-)GTd^Ax>rCqafRdzxoqno?RuTheLnkPqCPXqNS}U|I?}SFuXp3)7~8b0$lE zuMg=}m~XG$-h;|hnKHCt`Kal1Qs5YU_S=ATh*n^m@GY3kYd0#LK5kmb6qr=ADVO|- zeFRfJnnZP{eUh3NrP*UJ)j)NyA7S<|rj-3YwTjue3YhI&rMw$vS0u5oV5;4w%=*mc zv-Oq0Z0&4>V=F$3HvJu!>epcBHkMFk)IT<*=1_e-b@1n@Z>#Gm`$Cu;m6I)(9`D2K zUP#@u&limvvdNwVvvJyT-{|7(+2YNnEVk?BXvWKEF>bT%Gxj?y_qAf?$<#fB%+rjZ z!$qEcN z%4zX+s$VrUhB}7z(EBu)^097RKOh|<-((Za($sg06wEB=j~WQsCuUptX1wGZCYW&L z*Ezuhgwzq{dc)ruWxUR6KlNL?c`>gG{rad*!UMmv?!ywu(})XT6U`R_1Y~Vm$fm2? z_o-gS+VOlC4;SX=_zy-IZ&1eP2z2(bZ+?dUV0EP$x@I9vae>(sJRN1cNrovurd(b7 z%Nt;79PMTmeh+5Gh`D3$zSVB7gJ{cs9PMByn@ihQoSgfjD;R=VKiMU~UXE15%$CLM z{+7b*E3bM?-uh>IqKQXaP9o#+P)%v(%Z=bVm{wYD((%SOVLG~`M_YPQ_b;05v7SxNv(CHW6lb6kUF`l~DpC=TewfCjPty_79jk#hk$69e%@y%+>Odgmi$Q9v&gE zm9KjT8}0miVojLXSEroGVJ6Mw6twD z2c@7GwrvIZ0;YA3J6n#UNAfQ#O*Z-TG;}$Pal3I)I{&beJcI(6D~7@3jb?6)o!Kht z(Llq$b7}0{Gp2R4yum-ThSgUqu$RUU{p|VtFwYpI41+^JeTML}8$T?Wbgp@F$?rd- z%3<*}pY5UY(EzU*KY8Xks15Mj2pUlZ74oBm(~b1cC`IuM&yOzNUAMw-q(MKqG4L_3C@}p0! z`E9VqONX5A1VSaez;U7YB7RiRMf|kjC*((;rYP%N&5zPY4zB_A*&a4Wev?tn1Z)M1 z3G1_%9~F2jKPvDxe)JKF-!8(ZDN4a*M*3$|`aAd$*1340(yx$d^jX1=G^*!E72nT~ zGOXf9AED$AiSTKPO1GLH;lupsBUJjw_>uf^e)JJazD|TsO)9WGRKoQpnMG;*wB!FL z*pjp_@uPBH;YW(R%8xz{Yf=G&s^E2glyDq@XScnheHP{AdR3*+!*j{i5%^zNTBs*F2aLH`|;qIWt)>Ro=J zg7-SS&*A+N^0}XXa@r(V7R@Ac9ypP2315m7cZ2b)O2uy?V%Lg!^I1g zVJ}bxbp;i*55J_){-ELyl+dBlWjHQWx=hD~{8!`k;kTUU5G(NdI~?HTgFzLP3+f|O zLBl{*IMT`gJE$z9OukVzpaPFTpwV!QD^RH5c*mQf6gtkyg?_Z*TfbwC^f?Ktn3MUX zVowFNgP7*zNhjC;51Wc9b@Fmh`KuhC1pUzZz6~Z*cr3hl?F90hN9!s0!T6ovhlWndA68ho?FD8IIRFem1D#yU6j29bN{i zjw?Xvc(s#X>*UuvyirC_V{dW^ZUL3yc9&q8!#Wp#C#du*L4AZBz;&Q{=^2L`Kq>H| z!ZT(eIik$;bRs{hv7e6jbsCMLJacCdY*e zZswQdUpl!^{42)|M&DD7S+BoAtYW`&(L$-e6;!sLoV+PY{)M+-6QL zRK7Ha@if;T&0PYa60`tSU~5nv-oeESwPx?*;=6-Nf1ryOs=^+Q3l-nf@unzwuQa<< z6Mz}PHk7C@zf|BsE?%gN2RklQd_Pdbda#oVB~O5A$VgB|91SY}7*L-vHL1XMP#MR% z4C7pUQ&fTDom?nB!EvD~IsugEWPVAZNg@svUo+VWgi0_4R79SWH$}zgJGoHl3LF+X zxv&TFOF=c@Do|0^@=NKj6LCma)_BVhsNgzKaVuSdrl|P4k!v6K5GbEm=!ReT z%gaZ9@zZOh*;g*1P<{4|<3hFMJI9-%rqrL1tKgqq{PwUN@jH=NN!o%+zq2x1JY4?@ zwzm%gY24jK2o>z*cvDmXnNBVg?=2oJ{FUiL^&IS?g=$ZK$AwCs4XRxOoqT(!e1nLW zTEiTU29cEq9?m}p%j_~s)ET*-V`N2)ycPq zik}j9JMBD|P^gLuL5XJYO9^L+IFv%APG08ZLdh!~Z;Dd1+R23~e=ewU=he7~d7u)W z2I|OmE~tcyK)KxIpfX$m>LU~n9T!T0s~s1rD{cUl?nY2iOZ2M=)<~cPx4H!T zQk?ZhzKPW}lxOD%QSo0qXP}%EU1=~X@{uJ>#W4sDV@r^EhQSS{V#!9 z_JK>#6xAgkAy)<8y7;E33V%Q@MScXue|G7%hbr$^7jN*Y%vi@PGXDv5P8XXI95#I^?8gIyip+u=T-Mp_S0pZ^J}xBF3!j5(+|BUkMh>M9!MDiBJc z;h@~)C{V^d22|t6gZcB% z6+hd>&v7^xR6z?s6(ln%e=Vr=XMy@OMa7@(;upI3h04esyY~+l@hYgT&HFBaP&EgGCDsa7vZ;Ddr8RXhBy#i_&|I4KlD&IRT^sd4N zjHy0w34|)(W0&v~C*K~bf^UeIsef?ke{|`EepC`?o>M!1apI;ZJ$`j^pYhZPLwNnEww|1S)7}7tzjTY>KfcvpM>z{tiT|S)E++ z-Cc5_irK?q7Z<;mix(EuG?%XGXcDt&L{!anI{8888;#aQ&O=CQ73=X$71 zm*dJ2YG@8|@}Ulgx%j52@`odr9wS}4!(6)Uq0)_Z@nAHwMJzDJ8G}$pKGH=tMajn^ zS7qZ|{Ps}k#=CUKfim`qF8zPN&Lp!5W!(Z-K%p?|k{(MKXDf5%G)3uCj$BSQ+hv;r zs*&?t`t6~NdxlG2HJ2%!|X#PO!6CFgqNQshQZhFR>=2_;_w zN|9ww{_oM&ZxSkF9T{Y(RiG4j0Mz;88Bp=hg0lPuP$ThGP>R3h`1_#pecBK^f(nxKEmjZoku{Qrl<_(BNtxa;)N>kVo(Z&PA-%@0%d_4Kw0J{ zhl?F90af0upwis|W+~&H1oROq<6VvmmEms3g+X+2E2a&J*FrT2o^jDaWq;Ojq0&9) z51j#1=L5VyWw(hXRy}A zpXKlzhv&9rnpVN*JHaA{7dgBHl-^f3yc*O;C`BU2g*sR+bzCULS2(=K$%T@ya(q=w zH%&_*Lp=map@%_v%G01KdInU+7eJ+Z5tL%DfcgkEa^3`Y2fqQ8?t4)Aes=sfQ04s& z>hpUI0aa*j030e~GtP*T$8}cx3#w~cJGoHLfVw#@RQ>}%DcA#4IlVwRbw5xF^au5+ zQD9dBM}SIrl*6&05{?7)5q1KngDSY#$%X3cTF0BBTD}mu$~o7?3)O&2K;mn>&?UIi z;Z>j%i9ogFI#3nf1gcAxgPI)bL8ZG7)MtCB{13Qv4}nr-HK?vy10JAl!p8*kX^JZN zQzsXypiPc%4^{BzF5QNv*auu8g zswFL5I-%lQx%k#j{-4nJzY?gxHV)f@DyW?+K&bfkjtj+i0o9j#fNIPEPTt+&fuKG@ zrOyCWZcir%?f8>G1@s5iqCubv*8i!VG8_i#BUC}7oO}!@Cp+5Vco%;hD5snb8fOLd zDQGT-3>hi5pf1(oh>hvz!^`Jg^caew$qCvS?H znIF*q$E_-Q$QAU6ODI%>);ReSPA*h~o^)KO{7*UF6wS>#aw)nIRK7P{x@|Zf>0ct0 z@J$jb?I$jSP}9YCjtiCUdr%7e=;Xq*==4spj?oRBV(F&0e{*p{<@p`d)R0Dj>ZJgb zlC3~Z!W~r6Ur^~gyYyW_@%>%;OqX7$@_U1t%KEjzx)Stv5!s+B9OQ7QlMiz^9Mngs z3dVryg`=FjDe5de*~zzuGRl-Tv|kG3kx+tsP%SNT8HH+bsl(ZzG(R1b0%wC#_RD6c1ZzN@)ZTVnD24vzxKJ711Eui$PTmxi{zE4h zD&0p8KX&q_s0P%0PJ{wm9RBF=H&7LtU9>~-W_Yczxx)ZdMf&Kax_;E+!Td;`>oDPP z2&m5xeppp%{=a;#u5!IR^ZU;zg$ntR;?rHcMYLBynkI!io~t`+?0Bxe&NnHB!@Y+cjC_MWLLrR0j=@mzh!bM+n1)fou?C(qb*wY}rH`i|%7JD#iWc&@(V zxw^g}v`Tz$uL z_3b@V*Km`Tsbc*I)m!0(t3< z=juGD)~l}_&((K4SH}&SK2wJpzT>&NW{UsabM>MBLX&+Lwb$9sR@49JE{~BJ@rc=KeKNS?dQF3xx z)^)uec)Nbw?gw6cUdfTM2TP7@Ho78e{dlt@{C?3%k2gCZI`Q#lt)s^z$c}r_+Q^}`1TXFbw#lUJV6{Ex}sU3t@!CD|{XnE&Rd zmD4u-?Z%r9%1ylV#{I8eIeOx~mvTX@^t?0qKByyTeD#Kf+jjJ&qlm2Z#U=fkz5hEDB0DgPgrw0SnG&HX=pb?xmh zee~q`j3(+0Z>ru+yL~z7_oow8|0rrPEI)70OP~LCB6LixX zPtr~Oo@nHtn~;6JcZz@_5X{uH;>Qhc;mlwM{>tb5E8M)Qc8s+ zLQvJT6t&e(RWDQf3=f9^B4r9b`t zp68F}e)*U=pL5MVbIqJBoH|ZkIh^V~@qM=FJ1TpCsi0dL6o7i6<>i!Bb=NE`3 zCPTzc5iNd&Xl9asg_yq?!hbVF3)5sXMC&aOD@3$1t}PImw?cH@0@21S7qLo2&{l}H zCT=T4w`~xcM6@%3+aQ9sLk!vm(cYwr*es&dc8ECBZ#%@`9T2-kbTnZ*AjD7! zXOkvkuZZa1AiA2w-ylZpgg7apyNTEd5wiyWp7QaIbFv-6|%>M(z{||^krpX@=t^b5rA>v)*`V%7aZivo* zLcC{|i&!NhXg9DHsqV@rZ zF(&B%#H51|*F}squ?HdQ9)g&25F*KBh`1@D#UY6CCixJ={KF9bhao1KCWj$fAAwjQ z!Wh>Ph|EVJIv;_U%(3>&kNfohKM5$vC z$)?{ih{4Apc8i#0!j40fIRP>3IK&*2CStFM=o1k0OyUWM5ho!|idbMGPC~?-f|z&` zVv$J~aYjV#QxJAb6=ym~OlZbUD@B&2eMTkKc zAl92y5t~Jnx(JbK`dx$=da%f5=5EH5W_A(Y%*yg_KJwU4DqW;ybLko3dBhf zTTH|ih?oqBiB}-DnRF3nMAXiJ*kO_~ASV3{ab3ht6Z9BU5Cj04@Bqd5c|w>5vxQ5{R45p#Qg)&?FPgq z5r<6R4T#{I5QA<&95JaPHj5~A6C&O8y9qJ)7Q}85$4%HRh%&b!hTVcVY0^aO6%l^+FO_aWxo zgUB!$B5sOkaUbHUNxly;{{e*m1BmOU$%9O-CbssYI&^(wqRWrkaQWq!*x9eX>(<2O zT(08M1i84rW8!qbn=iyB5%)}>FGO%Ah(W#(4@|0v%_2%=@~iLb@;Ci6`L#5I{a|*( zxH6eAKSav-Lk#nS@Hc59_KJx1hX^o<{tzSF5GO@sF%fQvm;i{0Zis9qUBnp?wF4k> zn4|!RNtq$8i^yeSGegwP0x>5uL?8#8LEIG4A`3)blbi)&epU$otPnw_Nmhu~*&tSk zC}3RKATno%=$s8A#4H!FN<>h0h)@%k9im$fh)p62o4_0p!8suY<$x$^QblYQQ7R`y znCX`jVsI{q-6Beuuv`#jazhNu1yRbRiP$S5IyXdVlb9P~L?FaT5zm^4K!}(;5EBC- z%9(T#XGGM_15w^2<$;)#7vj2z3MMu$MBRK4bMit&m<$m&MYPBV5oMC|LCg<=@DGBB zHcf&cTIYvYA)<Ai!m zw$GL&;Yf!w_iN;OYz5ZDWDu>Zsi{{Kri{>@zrBO`@D`W}lqy99K=|V#-hhJ|BGlW0e?$#VLc3_1Nxy|Oq1OtPGT=9Zj6rb#)RcgzAg z?;6*0ID<_aIq#X}a^5#t%Hs?%adJK|tL1!X0>g2Jn(lJqO{$y(6H);u(e#t^k=Y{W zV-r@9;+Kh_ZH85(ZHAdN5qm{MM?efWi4hPZA|XzS_{>B^s>Y%qCPqSxGU*Vm(WYt? z&KQ#<=W}yb&R7#$3E{fY2+yg6aFWS@aE&wdqH)HXWH}SeEjbfSlgc<>ngw!q6hlN)Ok4~^x2h1EL`*Y*RUv|_K@6%2F~g*a*es${HHc)>uNuVQ z>JYm{%rarsA<8@tF|0bo9FrzuuZZa9A?BIH=OISafH)~)fr+RA5%U7X#2OHbOuC3O zB5J9kwx-UY^sR^;%WQe#aqQ#34-TVF>p+aC3vp7!786ky zBBmb1#JUjMOuC3OB5K!z*kO|DK}>oT;<|{PCiYc`y7eLEyb6(KGDO@I(V{-YA11jz z#QX*j{tX~@nuFNY6y|J5k%*P5c|w>5vxQ5HG()`;u=A8YYeeT#32*d z7$W#Jh(V1Zj+j&tn?;m*4Imn|j*cK3VTSClf0g+)c zMBEh7q9w#tliU(wek%z7RuI=slU5L|Jxdl5H;k(_MCLX~bZ!lC%PbeMN<>f_h&v{( z4MexsAvTG)X98b`2yP28=yiw(CRM~{5vAH{)#5h&+iKPF2Fz|4tyJ=G3hOc>mqWQ z*ta0+#zD+^3nI{Dh`1@DMI1z4lN<*zzXODS2Z$ikqyt3jju0zE6fmxi5ScqcbnXZd zVwQ_oB_gO3M5u}D1ktTC#3m7iO<-p9F_bw6|XZNK7XLk~YcGrEgk zE!1gR7r*cP*YfUHPSfNIztAlGI}hmF>z&R%aVhD)`PFfm7eDm-;K~15EBpqHo}PYf zbBy5IS$yg%VH|Gml;vIh*86e*w4b@M&o8ro{StgXA|iPUd!a=^NkX6|@;P+%z{Htc1%W>UH<`|dggq$;r z4`w(OV3a@C(v+kTe)D|&&n?r3VZ50zyGHpH@;$fATpH!~ihmwXq~lEAxBdEha`D4T z&lfclw&4b=rs{ZVI7^QK<=>0z-HX#8`z`jHC($guI}hyNml7{Fsbl>{G3zw^f?6!S z$re~gbNl6p{mF^%I&^l$m(3Gj)@!LwOJ3TZ^4mDSrY={zl&}eYzxErQrEm_Hz6X;~HYM$Azawt{gLl}ctVUzpCC(^hO18IG{3E%6zb=Q%auK)$x^@5**tYnkmJYLmrf{!n_bbA z^9+iV*Vb^VrvGz;IXzA6dQZlR{Yb}J!&zf|YsiVY^MHXWNv0twFZ}4&CsRnKAx?Bk zHfe-QpXf@MgX`(L|L!zzDAKSV594?gwCZffUkf*n2^{M;&urMtFgiV#N>NSTamLp6 zZIGim8!W5Hot{=O-|T?gkGar8hB{62#AqJt$~17ZD`ja{zmraoH-7ia?4cS@^gHyZ zYd3J}BKKauGcNyP8I)TMP~Fc{bG11Sm2avfG{g0Dg2ukXCmr=mnRL|u^Ku#;9kO=I zsXuq+%h9Dw#q93qwYA3Y{sZSHf2{p$6}=arpZ8kzr>_j^_YyL?mP~OG{A$^7GKxOu zVffB4oMdo(G}`s^o+_byWbu5k7mxRd34Qox6n#uT9W%j7*Yk0l(Uz-kIlc5W#&QiT zr>`4+X}N}$(_axxvs@!MWtu+d^R?yTU$cU`j<#eI%Tbj+`6SJ+sSQm3_6f3FbIa*7 zSAQu-{93}P#M*1wXSp^uK7F0%faP9y%colY9}7Vl&;}$`yrM1*cjX5O2w1T+FkAI%GqE z!@zv}`n_Yh;`na@but~oA-NLZ9?!ON;-$=_befx}6FXlJOa*_DQTW+-X{FErj zFD*I73RZ$MmQ%@9AsWMzEH~D2mEk5^?hDIRf%ATL%6s%l4BQzz!o0_yR8{{wXGxub zqC{9%cs}Zj<1@i>)$ykzr|~?|a?j)6V#DgZ6ou6%q0%g;rV*#lLw#?#NtUY#ca~11 z^nE5<@(mBemYb>b zb0kPd@R_5c@k_RW)eq`QrSbdPa_R^5EvJKC6k$Ekz;bh}+^cYnET;otltz8f)N=D| z*aj+pGfOV8f(;SWw|DhhXt_rCN81c8vRq@haFmp^-#3;x>>IEcv62@O8K^E%%cR+!k&UoD$w>xi|1nw{kk-MpfMoEV7)` zv$!|G_m=wwP8n>k@~^VwW-Is>H-5C-7R$xKrCLt9T}rA0*a)Z0?67hj@&97wezROB zxNVlx9H6kB!FEqr+F$x!IqU*{vx0wEt}EOw%l!$bTz3O?EVsvU-Qn`UX` z;MZ?IuF~iU&Rgz)IE}wvU>_Hnd=BC&k={UGB-f;(1AY{^4+w>Mtl&*6_&(eRN{!zw zI7K)FBwFsCmHPnhBREZ5_u*8!4?!L)=b~xk9SS}N&jDXJ9q#EFg2l;!CJ>!oq`(QF zF%jt(U;`)O*Y{dq#LWz+j`tC`VD&-~EB7(ni^$c&^`2++3H};Zu9%e@23Id0QkpKz zlAq$w23HTaxaEf9KY;?Nk6XfWBk*6cil(IHK7+e#xl)!J38xQ-OQ}6$xl#DN-|UVr zZOPG)3vJ*saH@qd;9D{ytya#;srqi%&h?z-#=_mSTzSiV0e9PS(vqtFByi7i6&xYlXU$t`c;f@07`}%MUBF{F_K? zvk-*AX%1*)5=h z0<^;44Ywn%GP4r2wp>>$mjWk+rC&EVWo8xW`;@djQ%8U)!qq_Mb4a)MwA>H)gW%rA z?Pa+&_ygdi+k4vx*WzET9PsOF<<{Z%9w7U+<$i=)O7$vz&zWONcs*F9I_KBl3U0vP zo}1G511y({KNwE>exT)k!e5HgN#75GQ#2bvFr4)LU@P}C{!m*B@97jaCA& zt>7>CN5g4e7y_s2`W1Agbeb1Fv~ru}x7<+6ZGrQg3@O)gTk(&vHIrbuZ7P2Zq-Ror zRIay!{BW97KCyy3@He&@)SskF?l;f`P7{rmUW$7s=xIwg+J@Z)Cmo{6XN=|2@Joky z>R;_xaZ@dKSmnxuVo>!=v*Z!{I-zJ9?sUr?#V?r|g5}cjcSr7P+?kd;hF^yw&Bjf( z+;RLm7=AA9*OohhKLWW0s@GYNYP*v_i7W*3tl%m9I?7AhYQE)8<39l>WwpR^XYlh! z+8$-K&~j(tG?z$OErL^@IR`YCh+85#wd;AH$wcH*8~6hL6gd5sS?(hK@svc$YB?OW z>2nE$EBE}qvvQa5hrvmKY00goy8@)Jq^wrhuo?K{rLjb&Si!&X`{374OLHZ175qbW z>Gy+`yM|vU-%8=EvD|h1I-*$$XRYP_!M{&M=eN$rcLV=^xb|TTXUpBiuSM%GxSK3@55G>;m4483f=lgxp9=%I z757&w_yE5W-iEu`a;irqEN+YCWKQV`q#w4zsi3}G_p;m$E0>Au=HNH(|7JNquG?t* ziQH*PnPL`jt=}#ip&L%0?vyV0!^#D4{XkN!Kozb$)_zB z#PxC7K^^Z5oH}rRxD%E;XXOgO&GR0=ecqCq)%pOn>jle&;8zu@4KG@*Ab#ltHP?LUGhDY^ar|mdRrx=bD}i4Jx~n#BST4RK7dlE?znhjU1*ZsA8#<*E$LAUTQ)W~f zw=Gv1ziLJ$x?{OA_;p&m61xkh>eoBN3M={hR<105PLmJzQGf@Qyiy7ezbmnBb0tsK zV&-xQ_sN|49J(KH5tpy8IabnL*_15h{=l`+B$skmsa-pVa|(mZ*E*FU3h3`~blS#E za0}c9I(udV*a&_Go4_yNSFqU>e#YI?_e3@`@)>u*_z=W(WX%si=P^Zt%AgAPGYbny zAdB~2AS<`5+6RFQ*@uD7^!gl(1z!N2^)(WV0@*2}{&HW&?2oyYh2&Qt1Bi?p+hi4! zMMD-0SuE0kj1)3Td_Z6so*M!gn;V1Iz{l{v5@8+uI^|AA=hff`pbgY@;71@E^Gq<8 zxaNZwsO_`h9JmNHx-QE~pm{)gUwU48UBgLwTPpdsSzg**B3=qi9b5WIomqNCYD9fg zYC?w8&w*@{vPH_w_#qeyWcN-0G9`ZmWb+;bWIFBuHj~U2upR6GFM?Q53)BX$5M{G6 z?m*rpavp9QhFny&qedQG(i|}(G22=&rKy^?7R0I(~pTW%wasye5lfXD2 zEAd1i!>|m&GW^QW`x$6T|7r%BgJE0`1v2<1>bQljczS|5AOGmud`22=$yW|s#MAQTh^GG^xMwa>Lx%2YT2WkgI1t5s86Lf1dV{~($&C7;vEHKlP=59F9*tl z3P8q@D9tPSQ zzzqYR0zLSQ1fzhSWAxmj=ahIogiHW>$e0SWyw`Gm2ABzEf!RQ-YOSId0%j(~r6WtlNYDCC%ek#!8_-frOE0aow6aPA zgF!7I!$%$CDv$CgfHxS(aw|*i-$2$|S$}r}S$qpn@E4J3i(3ff1~S6R=z5O|x&>|n zo%(qZ=={=IU^bWo=7ROavjL=npMdP?HEB4X-xvt~H1%5$OL3Qh@4!m12Mx6s>;reW z&V-Dgzu8)zDNA^ zj=x=pryniQaVLS2efHf^f$G870gC%78nXX06NM}#^j4YN1KfTqrn*P zIT#Bvz*VpwYydxjjo@dnDZ9y8(VaDZGyc_J5m*A2f@Pow=n2Y#H%Oxsc#*uNk;m`J zR}9xOQojW1f=ZwOCrnqyKnz&Q^)j#=d<(t<--8ukCD0+OvJn3Qj?;NgfRo@9(7DM2fQ-8` z=IZ?CvEU1kl-bAaGakN_rZrCg`%$lvf;{RD;sSbkX^PT*vP$~!6pzz z9;1QIik6vGXG%8$I@nvgA=>?rS+yc43Z4O_K^gEYC<}^Y#==?<0(I5pdM(j%2xb4)1wz0bB%^z!i`I{svdUb#MdR1h>F#a0lE4_dqj} zY6BXA#vq+)DUMqLlmvQycm|XPWx%sQHrLx)Q#T|6ooieSgn<%3w%1bN8r8K5!5@L_ zr?QvIKDtcZ5PS<(0GUUB0N;SaD5N9cC`bp#z;SRAoC0USIdB17^kqemf#+{<6>vlo3G#w`AP5uy!5{<_1fif1&@-DJugZbv;`y&U z2nTv#3#X((pdgSzb_ASkup>ctYM?jh3kCz(QWL<(;1e(mj0Uo&ehy?BeGN1MZ*l!A ziT5E+nL_ta78(8GW$&B{WY`-ICV~;52hh=5hrtmbGp5d>>j8R#-k=ZY3*H9(K!2bE z%kJXd0jr5)3C-A!#OsiWmw}FZ)4B4!L7&V%jeIsh^haPULv9DpgxH-01ZJS&=|Z1s*-3maEXLEQX*M{7Jv;Tpl=1F z6UQ-d9Gn0z6IT<$H3iQTt}er>Gj124_u6EPlQB25jN4oB6hu&m_KgA@KI!u}I12PW z)3@Li5}QE8(?J{XI(Uvg5)LB3HEQfRYOW&Ci!?_`Xd>JJ5*x<-;ovi%y^u*@E|>>4 zYNFZ>PLPQlxOu=i0`3FBMA8m42Q7eJtk4S*3yD_i&{So){hSxMO0BApC4EP+xf5Lz7fHpv{7`U)Z=<5nP08obko&-9^Fc#>*#-d;` znV5&XPNytPMpoipR)W;ZyFkbK>V(}FfL=3r2I!dRdO#og$^-NU!J8WY`>8IyI*<(X z8o+!|nOe}UzO3{aBz6aA6Wt#KfZw@J1=_o~4;}!$2GS4o2im*-0JH&bfOeog_=%c$ zlZ-aSf0Q6$I{30U7X`poBGJz79`F~)1$TrFcM89DZ7%}t)XLhs8|(qCKx=TAYCQ_> zf;+%_-H}YH8MRj$1pGk&$O$|$GBT|IdPyn;tOh@TH9#*!tpiKQb$d_%1Or)divjIr z<^$Tl9L;rSO4b$h1ln%Yu3-?+PGM2d1er;cT02OkXlmKM-KF`jnZL>Uyt`$5KH9M~ zg7Xm2o6rqtf=m{-sP?Ns8Cik52x#+7n{8^QE0m}#{%1fDpl|$zfE*wYs5ZAC^DFQs zuCz13_a@GF^=LAf3vH~;gHSH};_nHxiPi;l0=oAL*aEhLR&d9VJq{x9drNqOraA_W z1MP(!21mia`;7X7(_HigMg9dC541m33XGr+yhR(wfexS}XbbLB?GHd1N~AUxyzQ=S zCv7v`0NO^9X}J>6hK)98UI!|5F2b+H^$cMxf0TjA+|(A3c6=TWpnE+)PtXfA1R|=i^!m472$dk)TsCYcr&BKVu`~^481~_PK435 zCnMTmVZXuWj4kz9++r_LKm6(reYD-+A72cQ-I9aqrqoj1o#B#t@#Y155bj& zxPt#n&;w}kTnA`cs}1(k@U?K&WozQDR0iU`c%EymQ`SBvt9z$GH7l$8-f%U{oX+l1@h@`gW$+S+wID^$gO-*w zT?wx1SGm^()C00(jD$j4G@pu8+M}AAbX|3tq-cxiVnN1(iMPuOw1< z4N?uPzrT>)%}NAK;A#<9bKGX21yB!dXLfXPhng#0+$H0+O3||DGvwESL=Z@rx8T%s zbK_SBklvQxo55~eOOGql()S|>C)3y@Tv@D+!mI8s;%>rKD^3J@9O?<`!A;D>aO%ZH zM=stZ(AS_J*ZsMU0{4kf8zp_<25{XSsDN*CEnd%6N?4sy&re10Prw}ql7QsK0zF@i z0wcj^U^w^`4AZ`4JRZ$N1HlL2eIRxE9#{}9I|V2u$?KkCPr=oonP-x^yNlLPu!TU+(+ai#|6H(?YiZ2s z`1J@p2X{7@1(Lb`TCTa+-5u&b6YmU@qlf!t{?fTg4|f6AeAB&$yL6ngrewbdT7xXc z?Mc$#;C=_b1(ZDxzfiQ5NsW zOM|EXyx=M1cL=Ck>;P(B)wQ<^Y{$P1c!%fXJ_vi1R}CPQhtd zdF6}1DGtedb$~VnlsB)=IKw?@1Orrd%H;MG_E)p657rpnI|n*YF6{|l#(xRu$*jCZ zekOA*`<)tk@F0Wx-GN?f&jykSD~0KcUu%rKxYB!?I`lYL3`f&i9{hnoLohdP7Jv4u z`?`bT1GsPlEpB{?SbF6Fs6fEmxYxm10^P#B3H|}sz*X=!(0ye}nNZ|6@ZSOVz;Ukc z;@$_!us5U1m@?Z`%RuE$0o5a1+>pxGK9VAqzXq-{<_{+&lo@w4u8;%2R@FH{cAy%` z3dHMLu4+TFvMp-1FMz8IYe(dH{k_$5cuD}7Xo>?FASDons~i`_EdrjSMhfAE0#)nd zO(NA%7=9XsQ5P#?_B*qVV2Z zsucNaTUYTYsALqsCz!pbR#F;XPGzdjbu~~G#DK~`d5*@d1XRvo%2EIn12jxo&t5ziOD6h#qz+H?l z=|l{0=XKRGwFkJ1#@0rn4ibT&0seZp^>NFQXl48_fEe%$cm+N;?yHgmbwM37XMno| zMNmm9tB`64Rl4e-7j93`12h5ZDB5pp23C;e?zr9jP0oStra9DLlK&WdWO@&Dw~8Ew z_fs$&dkX$2C|=!X$rJA*RoGAosi#qKeqT zbyLEskjjKIudZ>1>qFoaI1Y{hRpNf>&3$-Oo4W)8?ZnjsgeM^GA6)+q_JTd&PawJ7 zxPJjH`xN;Bph&%Oc>Ap4Q$}^q%X$6j@sLM>H}Dbshk+vT3M&Ey(7ne8gXFyTyqQ%v z6-b5gCUR2O@F%Ri3hRx}{5;s5JBLD?mXz+8^ucIr2`E$EOvqe*UbiiG0rw(M#E;** zZ10QL8rm!GZKm7Y_x4>Wkn_mgg1ZU+0oMUjX%A0lxyFSex{9le+`v^J!Rt??`X8?q z)rb;Mn=5g(_g&yEhlPOipy zA;z}iEr=V#wcdr1ys{mFpZ8*3^2v|K7br3X48s2%{_MC?bK1aHB-+eZVA-I&WfJda zR=n>nT1xx<$&@3SdL4qR6>b3Be%wJ^>%9Pfa|=<|Y!foX-9A1Gj4DMHtq5~-oeRja zpHufhAjk{y0dMBKrTBNIr;r(C@ZY=&@f5zXr&N#M2qh73X2g4|Q<*EmJuMoF;_6y{ zjq<@mA^3gZZ(jMpUDgczz0{D|x`Q%Bhew%PL*1dF?@DH$=nV2~FeJ<_ajn|DA#Kx%sd9K!cm#`5*Ov+I;OGF_FB<}zJAGAb`?$E*5Qqwo z4v&oT$-^BiG6`*ZW}3fl@{+sUiHP!~Rl+n(aEFAd=@m3Q*Tvy`PMk0jP&O<3yl4^; z;G=m{B-ai(OgIVk&rF)zH?r{;-iV@ndYhdI?x@gr5x~rvFeJG8?e4>ycky*a#DqtN zSA;T!6NzCo0)@CboBzcY<3leLCWgrH2&&y@zG;F0pWo_vPlB%uJ-X`Dx31NWA;yKNIs-{^^RZD=Iw7lT=xCN$%X)GS%gp?O%vYPz}mN zHFHs!Xl4bLbP5hyIm>SfkrQ*(@QA36ChQ|J(Z^K($XziLAOZd$Vo6zqwPzDZjtz zP0-LFil|bqe{RbD>Mep_wKcGKBdfuh!%=QAU^^Nv+ue8 z;N{(wJOZ9_oi1Q@edP}Fr-*+SFxyAFBgylF(X?*^({K!F=RxI3>pf=@$55lk3Yw{7 z+`*w$LY=B?)#T5H?f%|^a-&CiV%%tUBG`Br0yIy;{P*tnjhY!6>*pyVGCpshPE?wE zy?`^Fd|jJhRMo}I3b3thFat?#wp&-!shw0R6u$WC#4SkO4fKWTdo)+ z)Kv&u(ex#+(Zy^OjVk=~b;#C@`4G^YL`6Tf0z5%&F}o5}3g~s$)B~^Fn1QPDDg#Hr z_hMm_{R=YDt(a3EZGNfT^xUhrE+EK+q%?joHNT*2J56c|)p8~bTL2N>3hdnTex)6g zk&6hgM1S;|ZN?z?p8}P9%9~V0RNWl-f>GGU-f5w?~3s$Vxe`RWIbznw| z$`NPc5a1KILnQZnX{XX_Z0Rv{#{Mgsf^Dz!#Fx+Q>4BY1U%!C-=EyjAVHDs!g>PXB zj3@iM&GN}=>PVkZ(|J4tG45FwKNMtk*~6m-)q7TI$Li%_X2*C^j4kJMpK_lCHLvm8 zmR0#&FVtX~MW}^wO>jpQ)Q*mNbD4$nu3!Ild9U2AID)7TnipsD7}{-u`y;-td42-9 zSY^^K6X%cAo|IwJ<8ymX=&-bOb1)o;Q$Cb}sGz{M53P`>P%mD3e(;kv!z)`(pul11f8w;ZxXL7FX6>>=_UeTJ1S! z`2C!E@M3J^9@7b1F+8SXc$CkpCL%NQ9-X_b8S@-X*~|3%l7K_aD4ft?Wm!6sX!eD> zc2)D6+RYP?1xut)l39;H^ju=rD9yY#OM!*OPi03SvT8W1A)htek!DKjcHR^jRP-o! zSX?muS4=ZG3~`@FKwUS>Pw4}Gp8HP;)&^08L{+*?ECQ}D(^gJ(v;2*K0-37#ykN$1 z9o?=xD@ejMntQC>%g1Ba`?@NISE&>pQ^n^4?iA_1fW=OwuYE8`43w6Uj?h4`Kh zH`^z(4E!5Gwc^;?Z?^brLB?1Ft2%9Uw!)WRxr2SLRWJp=Vtzea(RBL?{X54@`3jxA zgxZ!8D3fvc@_+(YQV6FN0b|0^*}Rlj*E@P5r};2Vm~~RGJ59D}?htcjDx==< zGnz|HV`0IB^TAPfu<1X|-Hiq70XQC5a!sf8%Cpp$qW?N-Poo)gZdD;x4Ri|fPmGh3 zumcs|Ed6HKY36Xv9;CO_w43g3=zFf3`DVKND?YRxF_Yw@P23C|kKSiES9fyzSGG?- zUYP!MpeMJUN^NMCA{W{Q0WJTRc3-r6d)^=OTLBX8X438w(R=2e1d-DIlK1p7_ji0^w{ z>ZmbqS3K!OOtG(-V=6L=lvG;lg}drqn{ko>NPW}Ed?uM~3>VYuYj-0%P(s_%*;Rd= zXMfzX@#N1Fh|aSfj`Xo(C-W@YdbsH~i!68sN1C%`Tde7{v)2Q^p9rX# zjC!AH)*~D`9|5NFgqs5|wKzL+)v!mS)`^+Uo?|u(l(R3JFCNgTP`-^&Q)A&m->v1e zdc9v;kb4`UeAeWnybKPBW8qTAiuQ!SF zm>{jfuWT~>JW5&5RZf2}h2}G0-ZyapgdJv@%qONK(^JlTlXL;6mzlv;==@imikURG z?8O_Wx}5iQm8EU;%WoQ&An*cnaXu;9OwBRX$uBcfiv{i}Y^Y{%<@-;J$-a=K{c=-k zp}Sn>`;F`jwqYT82r>f}55Knik)(=NnHjR(i}iCLTc^JI354XPug}5rW(+KP}wKl5gqR0h~a>V@`B<6`uko$8tnt z%)LKwTA5f~bvEhWx(j5A@o{>m$@QJvpU1DTmE21-%fG`JV`^lhD<53$eh%%K@k$R_T@(-CSw1R>Be`Xr9Lr?PVs%sjiKCBSxuirZ2eprnv z-oLKiGwJJXyNoh@sqN4O9UNudchb&WLq{~gv_%mjnME+q%v#BseLzP?AZlF4_fx0M z{Lt6c1x5>;c#|!K7{=Snl;BMVckQ}=!nOm}z?r6I3K?>&BGLa&DYq+YCr8cyuWHZ( zmG9v$rdY{(y)fxYFUf8n9cgwer zEx*1tMn58x`t+U>_4N5i>^jqU z4FS*gHVHq_@%c{qUWCe-CO_GFcACSS`oUf4UybpvptkvJhMsIi9z-{i3S+w1TGp&% z&GHS{PnPs^wD66oxzayfwRH?NUj-{Ht97S1ECAQ6CD`?TX3wKw-U5YQ?eDDh7rm7- zXw2r_GK*9RkJOm8S$VwUJ((ZR7!C5TgshV_*DI#)ZX!hr)!EBQ?PPaZkdH{xmqJ{zAr8qM@rA9=EG*l0rw z_ja^<&MZfp6}hygows=^J9YN3sE;etC`#i zH_r2}T^3MDalepRk46hJU;M&?$u1J6nWeh(P#45_b+k>>YKFfP*;`?lUs{&LNeh9S z9(9khrv9&tw2mLK6N^B`mK7aqh5x+yk-#z&k3jS#1Zp7AvwH67YkOBI{YaqE$85-R zXLZ?$g)gs~@U5?_983wAcvJLRfHQN6JwOqM+}cs^5Mv)jMW-!_Uu`Je4@ z_~`b}yphNFICE!kbcs)R^MEMBPGmWAs#3?t@>kQYFJb~dUp+p$c%nCK6`v+%8VNcrCIxm%XHw$LyV6{kAzfy`f^Ye`t%i2ba;%AJH6sh7Tnk|6QbkB4(qUIYVafBP}3ga z=+?xhtp503m+!w@`10OI0*TzwiulK*Ki+HKeCA`lVy;TA$&WRONi2#z z^HDxCd8a!f%k0t4wD!1K)!RmoUq95n4;ASnwd$R{O|`Vg=NAM*IJIeB8WVxXN}JDI zPGd#*cntq5sIxLKhks}Ods4?c-SCN-@$pEbylZDmJ~_ELi(zN&r+MP>yZ@_{_TJNS z?0bEZ>9dE$j7{*#so#>1*QRRsDfFX7(v#)2Yb3ic8n~B*(c^2Ya5o#o6)~1Rw%qb& z#gj@%=uQ?s&(KBxEHI%|p*af+KI-fb=gUqeeIE_xnX(_tpOZ~f?7aZ3?>ud47d!Uq zv6b686LrA5AbA+YLywl$p7nMMQ~01e(f6NOX68Z0SI*gv0cZTP=FioKYE1BTJ(%TK zY3!a_xi11jiehiC9E_x_eDwmy*Xn!pZeiJ29AiQW6LyHqcQ$EP1A@$JhunUl;Rwsr z(`>-$wXNG#{oPY!Y_k!SYnZ+WM>j$sk79f0yYJ>Lj+a%K#UKJvZ*eD>J3(D@AAYUu zt1@kR?o{+CYSs}nbgg};{q_8-_kP%S$e9imG_sP(P)2f@Y=?2|!ok**T_$X`vEOJg z_Hx9D5>C3FS;S5S9veVDv*xh7sCVi4M0?- zh0Y+SR`}$>is^TPgS1yH+b7R(SLd1BU-K}~DV-h~XC|k+yUNb!d(0gvD{^IAmyhXw zjQr*@(~eg-cn%euGn$AdP-uqbc#3DNMzgxq3t}q8rqJN$U!(n{?Z}k4o#|?;f zkr`U*JExLeEb(SuR^(BeM-B zv^R3vKQLpO-mMzHIQ3C0yl);zF2clhV9{NxbU;vmXUpyEcc#%949|H?!)KWR7MM(D zX$Z%H=j+@IwxL41q&OSR2TNRRysPRAb|+|KjQ9L`iJ5Vh*sbBuiOXC#i!pP!seX=( zEn4MtkM;4B?xx4S-kzYcJoAXY()2}u6VsZ^V3n%fl}x5z28`XUaV7~jL!qKiFOzYO zre1E+2a#8g{V{~I=kCqo%3}=;F>}w;FvU#jdD=E(wR!Ub3b?wte*R&h9J6HJ%rdma z_;^L0G*Vg{q?31>k?5{#o&BPtmE+f@Old6}5o248_D+$ zm(P^H_^2pOiF_}vGap>U%5!U-`SK#2-`{MxNcQTRd&v4;{m}$p;x&OE)>{SGa-OU1 z9M@Wmw;E8gL7DhVXsS^g9E-w*n;j0d&zrfDCqJGM7nJJsmO9P*%zVkeM_uk%lTHt_ z_6oObqq3g3MozQ*wocdfqfx;Di{+Fo=i`~B-!XNfHr$0INAE!~BMzA?B z&2KQpJn{VgFsRd<-jX#o*=~~Ys;1^m602>-+*DKZn64@8Uz>Cc@YyC60oJPr9XjuqXdB(x`isuD(@Cn^fF%!Z=UUV~-@_$A5$@!q z26sj0Yu@JIwE*r&%QMiQXcRN^9_^H1X5@aT#ZBh>?x|1fIaQr`aG6A#8cBf9OUUu3HJA*+cJ`?ZdtTcxR>D`F)?8ejiCO#MN z8?W?vGC$UgYCwV4nF?$`hHm@AQ9xV9_A3=U=gKu2jCZLtojeg!=zr(cZu~nNns)#H zzAK=TH$CSyfTvHr9!+QWASatCydV%d>rbay|87*$d~XVA6WQ)jFE%?7h+c<4Zfb2{ z-t&VZ&$rHrfOhW5@eb|;awqjfl>b*NPVqjbcfy%$3TL9sj$>ZS6yV)-^}d$ydnO(s zJk6TF2%YuPU(SSZ_peudYd8OG1Tm39w90 zXv(>MF;n~kLb9CL=RAHlH|x0cjxD=nu;ZtlNu18TKBlHW&i|79*`!Kjw>f|l8gam> zh*EKbioe#c|1?_>WHQ0zatAcDul@O<0BnNJNOk2iix{wWsiNJIqwav<=%NRm8Dx9U zBa7Bno_PCF?M1YA-sAZ^`P&xvKHRCv$4=x{S3FrBTY@J^r*!66KtMsg-Elu4AUJf! zVMjx!=Ba+YeS>k^h?nqwIpz%g;aghYEm7k6tB%v z8j7RX91DG_y@(}4`HCL1Qvtv76;FO@_Gb%d@L0mCjKDzwc}<_}0q#Qoe&IOx!~N`Z zU-L!wfJpD$@UA(Oog@=aIn_1ePW9HUJNBtdh^^{4dXHo}0I|J?{+o)HV-7N&2-5&Kj#qo9Jv?21p>=RcFA~{VKHgvojJ{K~w25;ZYTN zZ%eQ5qgf8%gce3FD;aA)`PI5-cE7Sz4-Ir=?2e^PuDr-ulUw6*0sf&;2x|#E^4O>6 zGe%WEC}An|Nc*P&p)VmIwLY>&$*4nTo;@o8DcgwXW+q9obux?c(uLkJN4N_09<};S zj#?+rnYqzSniv!`!oN0o^U*VR-l5D+CbKC^H@u*)$Zv2DOMw_a6QgHve#O{YO_ zBxc=JGk2`scJutnLq(qxH_ep-0g=A@Z<><90g=)3ZaaP8z{SG7Pkqz5vm~9F?<#k+ zs;U)TVt%hI*>gK1j(qS+!>C|#KvWxo$$|62=2O$uOdjEL_m0UKg5XF~IwZhrXW>DK ziOMmQgbrQmOl_OCom(-qeS?0UTzDQT(HbEED}49f`|??gIo}kd1)Sx0Xlc4hcJi0p zui%aRE7kGt{OC8bIk5Y^&|ZuL`842KK8KpKdRmH^izPp zW4LtB+^@?%_Jez-Sbd7)thfEqQT8Q6R`k*b(NEI{o^*XyGlFHT-{LMeEL+0PQW~Jn z9gn8SYu+k^ig?;EcBDLtB*gW*nNo;W&dG3>Dk}m++%_K}GN0yT+SYMNrD)O{t;`I6KXGUQH^HDbi<| z8N>TZPoM81)n%O*nxao-a(P}$t+aB^mq&}{?(gf`2vdTbyZl_967;`*bmh`+wb-c} z$~b2^TCs@9Rg7FYo5e+?#-F-lgt0jy%CmD8SHvs9}*!`Efi1BTY6VJMKR)+Tx4!Vd*E6uXy3 zCuIewk$Kh4F~kIo{p+{W4@Rt^r+HR~tRL)z^H%YIA`IO4;-u+WC*=*O#Hza@@2`1Y zF!a2$me(9DPCk-eb!L+f^E@B9GNsoac0>}{|HpUPp0M<~TxObHaC^=4E{U_+q;chY zDzmvz0&U;j-2qk*!%Jauw1x&G00fp>1bd@%(N>Q!W`&9r@f z;q0x)*&I_btIT@IZA!UXDj-`XZ)p33&^w27UiH|?b-Mi>y08MlG!IZvYZY`1vW}w2 zWD;d4bRG^v-yx_f{Kb5qyMlvO`Rb2Zn5@xF31%m9p(Cw8{>6QAj40k+FY)WSl(_9{ z?NwwwEL@f-CzzzAI93Z!H$5R6FCe_;{N%_X-z0s$&+Y4~> zABKI8JuaH{gA}HnMmCs>#2xCo(rrV3aenjH$VH zd);Y2F|T@T>X;w=O@`9VXR$}`-?ns#Wc(AidCeX*#|Mx9xrKgaV-H~gWtNkYJ@ zsQ>eI*l1qVI-;1~O>w5nB^IUkqNB@6W(Cb&AQhJ-t6mMF>JG(nOJ2N{wJF`oA4~WeG^1+8O z%TBLl6(zJe9aYcgj8F*L7Dh+csv(Zw2QHGO9%y9<)vF6T<)jA`-6%R5fS*oMq9;C@ zP-1Pwk!ohq0*a^u>WEY|XE?I!91{5o8mzae9qwXa-Y;jsKBE4 zIFyM2>yc?|CaFYS7W<*N04^$Egc}@uTcgg=2tKjbY*cs*P-0{V;a&?V02EJ3AeJJX+R*Amkh|Vl(0nqS_=1^abZGL^U?1AN2nF z&Wrd_&9rJ7=dPnT0EmfqG42DL6X1$iFT38|%fzwfj4ovQES>NLljS`Dk}?`|c(Ao| z#hF5@L!aE2eTOaD?UvAEFNdMC{)Sc8j+R#khrMy8ll8$aDxIzlb}mlK8-TVTL}haG z18x>#`_kqk_@OQh;@^X)5ZD;2i`(yjhZ)|N_TYD8jEm4WQI#v*i>c^(TN?znTDZWB z9&^PdQ~=X!+yXeBo56KsHl&TI#U3LbEEL^gs>AayxWRY>aK!=VP|qh|*smuzFG0?s z;1lC(0N4Y-#y)cAip7o0Q;Bt^AnoUdvITGmU8{%h*lN@Q%D_m-oiWr^OsnIJD7W_) zGB}Jv()~1btqEK45&&3-7PwyPZqqplh2)qp?+;L7hZ3u{Bc^1{H@%QmL0*k~P9;H@ zcYtA^_lkx2eM_ZIdn92%Vy%CBA4L2>MSPrAwqmpMRzu!b7o@=r@vE>C zOJewG--z|_QuOG;J={VaK^RUCLdj8jjGD@y-%!CutPlan$Ka_gdaoRy9}NT0>`&=W zbsC|I;ncAaHozTdeYreXeBlVk1jBOXx^*oTfdgLQ7ge z^0dPnCmTrVyP9+ozZeVF5^LAw?lv>KHrm_^lMTz8$E5Y=pmIj3*)c)BEit0W)UqWI z37~=e(UK;%R0EAag5uh#X;7oJ_hw$><9xQF(M|(;061e{ZNXJ;5r$3=Ruujywj+t$ zT4C65etg>s80C>+iIxt~pjPlAjHJh{VB9vNI;}y7VKlV0Iubz@4_br1w*$zo4F+mY z-?RaYcJdE3>80C732lI_Ep6ZrD>}%<+MNrU_KTv*ZSli+8r2p|rsdu939YBD5P7yk zh2qo{AI36`g>GB;b;;AaN^QfTowf`^33$*L2B-r7JBUVB+3J6-+I_-&oDSkNr>*V4 zQG?0Y9_@)RB?@Yf6Qx92il2-xnh5n^m|S1IvTCKjCU%;L=;>5@bvtmF)B(d6sj5__ zBc@_G`FBKBk1BdC7vqoiXRlv*GHb4->KKaXhyiIQRDcTjYI&Hg^wZ4M-DR?gYH!=w&DM&L;_JIdblNwloy(vV6DQjs@l~xJ7f9X{O{CPF`tr z7tE!mjJy&5a=`jEloZl1- zhEyd0kXz~#`O9~M(_YWP*){{hmYqR$g3(f4V97gxp%G`Un>-S?TaJL7XlStN?epo? zIu?LesLBz|2R3&bzxT|Cs+JPzB@><>+!T}BF&M&zwX?D5lwdg!A+Sff zbr&r@ofTg8_2a3Wn!)QL*1`ew0pLE9;l*VwG2d=%J@2kbd<4o~TrW>uG^#qF#CAw8 zQ`JKG%b}zie1@nxg8~Pjr?u2?fEp;mE1bbDC-9@sh+r}Dd$rS+e-)d0Uu#cu`%ORz zv*By|`$qd6_V8)&fh8}k^Qgo?b9>(Uax@U%jMZ^}JhOlDrjg|??|ktC8sb1dfPYWR z8N4S=4+GpBk%+&x;2xA!*l61Op!$wam z0QD#lE#UfwUEEMtz_Dxh@pJd}KUgRJ%h#n~0`PAjJp#ahXaLv&V0asMoASpe&Jg#h za5D51lrR%_PBwJt6EYzbtAch%4MH1H!c5$-;dj$w9IX3`IxK9-C~-u|*zR3ss;f?x z=MoGO{|*kMX+u?KQ>KJi`6}GQ`jBgw1@T7%DGd-lDxR$1)u>4k-Ru?>wnWI*(MBnh z@X6MceW`XAFWs2PCEi-=epDz@bs5tV0L~1O4w{oZDL>7^>q(}Hfh@0oJR>5GTDGU zV82MUjQNh8T{J8b>d6MLPOv#bf8QLQd1>l2wB;uRuLw$b0;bUZMK3$n$yj4iO)(56r{U^YoPLb@6T7|S;RsSeIB(W)v|+PX_^$i=bYD}| z{lPq7Ag?@&BkvKsq3uZhM_>((iJ&DTu)b(VdoD_?A#@U7eRGND1?gFx4A?KW2KNe< zb}2BfJg>9bQU`@JluAZ{9m0p|wQT*pgRZPeUvU%mtGxJ0HxDIMFtRs$Cxo|kxU}6; zl1>AoumO4ufN}tMHLjg~%DLTJEdY*@v;vKrYDDUFl>t%X&D>FGF27+rYbH+L$doft zY72dCfqU}IPDBsEFTgj?DSe|TG8!wqC#{W!^n23tXv}-w)c-J2uZ3Xz+M$GNtwVg# zhk0DaXzDf!^ZqH%SWKrlN*JZaHE%LXFx5A;e(X@8%kw!P9&YhrBljyTp3E|zpIf&^ z9abEL9z6)E{pt#gLi52(BkB>OdYgg)z{0(L`k-Bl?kAv8-p8^KM~1>}nGdH_}>bO1n^KJNb_Vz3t8vMelNp@m%j}zDg*L;(2@zTfzU=2I0_!8_VtY)CuU)pZ?EQC4)luzHhipxUUCUn!R)XmC^(+pjsthOju#BNa?6MO zZ=%mN)#_S?><#}nkDOx0WA^3FU2NrTu$5mqk)~}!5XXCza*SQI2~b~oPk=VnfpDLk zm;l@2GF^#Bq6T+%pNim5@|uV<^;gt#B4!hbVlxpIJ7J<~uh_)Xf{AKrrFA?VTMw2I zafwRTczQTdb#|B#FUGKJrTy^DdqSKX^da%&`h!|p|BPyJAhfVFDEtR?oH>5e5?L*8 zBgz@;D8ek2^^e;h zDCc^3VUt(nN%8qYc22lH(}G5BBoJp-vUkxiB~4a?lsZ%CJ(`oGAde}~5OVl3yi|*E zaG)aV5ssWAE5?kd@w=%)${g*s?24Wl4(8O%6>yeH`l%SMM7{k~x-=I944FzTrebZ% zH6IEZ9yhoDC^C))zT8o*_LBgP$E^+$qh!=nTE=ysM~Ix;IbV(G(e`1`D-FVrPp7P= ziNSVTU!=^#(jy#X2_&H8G`f$v#`=Kx3Z%|h!Ip^0HGD}#nXZ)XU@=~5&20HSpI#G)@ z&V-*#1`P`#A|z0j_=JH3drNjkK--pCUs&0K&nzJprbZ?HszB0R1dgg70joHJR2qcD8D*$aWFax_mZ>pPfL62F*p5 zyv8zfaH^1l79T}wi=|Szk)5%@DHfd9R1orD7WB&VsP0^#BiB{gmYks6MTiQ|1I{_a zwuPoqn$D+VOVPN9Dx&kv5TUwnCcN4?p=D>Hc?zDd_BR83Nf(+ims;%=jUgC)6-n?* z;U~+db*|ixrIz<)FkqxgQ~ZdI6V}Y{09h0>^|q((b-;(>uq-tfAjE;B88A(Utt0}75R0glRB9nN>cI`d(HYV&bOxYmQW%x1!szBPER!xw6O zgAH70#X_h^fd`%8zqP9Zy-f`k3jU0m8o8&~{nyhmCXTCt7BnL-aALRjI*M3i76KQe z(;{IQL^z+!nm#X6hnjMiF_ZQ!f<>F_l*!db&|a<*eREoOO5?@kz67Y}wxA(eg4Zdf z#bQcg7`Zse#pshosOw_#05=)K77M$i$c=dw%6PPAl_0$nISj;47cDuB#sPWmIpktv zCXt^mN7{kXa0!L40g_rT-V#?jzRaS3WCL<#6{E;2)(LZxLCcq82GS^XIo$ub5?HSL zW?uiovaTd9rGmUKp>JT5lF3}i}?8O?q2k?f*f5T z2_?r?MFNzc`1_ChN}t#Z0I3FYGQ7x;$z~#HnMsBhDZa+)Xp6TB>5I>u`XO}_y!~eJ z&2<*dMhsP)2^El&e?DGQT@;^0%3O=JL9&xsu(nX=b(p1Jsp@(VXo|KFdWqm?zPeQe zFV{o@jm~wYaGn*M-LOs33FomSnmtk;vc&&4!`H11zhfJ2E@`xq+I)zs`&l!Q8pG_mOFPZ-(9)}PED#Z~oq>_p)0v68-DulAJ$_|zz`v3mXn zG$<`+vyCpTU$a=a=B=Q4J#wX(_4x1jhR?_>32L^>bJ3zw|BCIy7jkNUr{x>589eu+ zSihQ;uU>q{u-l`-0=!$qa{qa3FgulT-lG$Vny?KxUsIn>Zkxcf5!{jR7rLks@rBPha^?at=9?x9AIt z!>1A2;w|Iih2{1J&RP2mxUz2qaIIuX_`fw?1rBJ%r-{|HL>DCU;55<62D2v*Y)pAu z?%mc>OfpvLJT9Zh0af@~N(O`yyO!$i0`BtgCt)qU$Ir%fK!f)u*WxcmZV5WVE<8p9 z#=w5^+yYu%qNeyTy#^fnyK8zjZQ~kuWh3BtMFE`iIvTr0bg*cPTG7z%Ct=uQ*>;u2 zn46ZPR|GF>b4pF^_?9dG?H(F~CV1vC&Mq6s^t0MmF>Is>KdXz_t5$j|Oe`@WQajs;iSuuYW+e~TOR4-GB&7wwH;afj7-rD=IMj0({rNU+^yd4l8fUv{Bqf(z& zx2f5LecL>6@TF+O*;D}#%yNG7E6;UHw+G9`f}y!rK+HlL#+4HV#6bVK@e!vl{f;)| z8O>I@yd7j|@-x}*Kxe*N#j@3))&h?Xq?-jm42&o0yR8(w1ADU0fN(%(`EEWb*4D~l zxP}fiwx@0oN|+h?Ua0eP+sb}7QBqx?5xkt{U2?6)T&w}2lE174jhg|Nij50U|aJN#)YB7wC>?la_m8H0y@VZC= zSFBR#5@g^D@>C>+N~VHFxrzn5$#q|)h%>;1l=SqFGM@3AprBm^s%wx!p{T3$N}(T8 z;p!cpm#blE;07h^R=p|w7g+b=sRWKSkw|~i zOtw6W)@jHtNL$XHC=I`v$E?Va=|v|J6w=*wHym*Pfm6f$6Mxa`Y65AyU37If=;OLe z=wL_ZrS48jMs3UyACp1fe2o%5Q_ugcf81vMte;SV12;aeZ@7!9>;Zi`?jrYec%Xv! zsMSn^0nTx{eWn$wHT&q+o&e|2^BQobZlmNqAfNWahak;eh_5xW;>OL<44?xd}IRZbhevRAFeX1Hk|D%aXgzWdO# zjGdDah98GOW(P59L2#UflM^=kXHkhl(=a>YsS;X|hP_~(5rWEwy;N?$*0~nK_<0PW zal>9AK7j zj9*xn%B3NT-tdy0H$zB9;JjBC>n?iu8`O0}2nwtE0sR}l8gaGO zFIrn#T%Z=#?ZR^M?eDM(avcH(c5;$}40F%URgj#Z#jIlyVBVi0@TKrjy-a)NEE-cqWnyWQIje+;JKMV+{bnTu0(*9?8e3yFeFS zIgH`SAbpXmq0~D-RgOT#=Q^y(#7hgjb8C_crAn zcOG(p-lG#4x=vM&s=mhkXqJtD6-WBnJ^00%uSw!Dq1mG}<|y!#Lz833848f=u+0bmg)5SBkjdr1lyjFcwbvQ)-WI!3@9W#MR|!*uN=(rfSydv}TkA!cUeMiq@{QQ$P2Z zrKIU$DtrQUy90v9=KT7#XR9N=eJl_|Wy!C)hm9-TQR-SsCLN|OsB2s-Au8H`?X91B zBhdn}nP#5AZYNy=%&B$1?11Tu;w=Dw%92Ab#+Hg%-FA+pV@PI-bvQ!7C)Eb|>*Doy zKKze6MM1PhmcqnhB$}phmaDeznxib*8ks zLl4t2)HO|#buW(Z+sJc%=6*}v#fRtrfdie*5}>EkTiYU~99~!ea-Auy@jof>4~*#^ zs`3%!GNZ>Yr|w%jw(x zlUrFJcB6z>nej*Jv~2o|auy}*JpmDZqj!jFfpHR+2}{Z2yn519CQAVO7ogJ>9ggh* zFehDLNpjC3(*+FCFN=IHfVUcE(E$Ezkwwe!yKg7dN$x3b~@o%bf4QqpiRC^c&ju_wkxtL`t~B+$P&BN7hyyTgq2QN^z0%Q zUG0hhm}J;XdTTx~4!A~wIgZsXsn({*JA#)c8BT6mXdM@*jYZpI&%Q&mE}>t^5S+lW z7Qmbue|g{T=jqn9dCM+?Pma*#OOWL|0N|~1e&yZx!j98FSOAo}!p(7~W9<=VT09T7 zl$5+n?w8R|-MbX?H$J=HrC@yb9U^Ote0R9Wo1K-KSZd5b3HOp*eO!l2JsTuiN+fG3 zYwuFpa}4qFWgO@sQ0X!RJC&;bg^B!)n*0T)`5D^s7vkY^NxzWnDrBE?apVI~`difW zrfQ_1E9z_(iR)L?aAPhn`!LUW`s!ph_`Z9BS7vq_vg~E!9@y7vb~G07!?gUWTB+<= z0I*@3%S%8q+9T)lLd(hR8f**se4Y6vBJs+TF_%Wj)c%N;%a6f-z8^Dl>a)I z=RMWAu5LG;e<--oX>P}YLnlQZ1J)9mYgFV0%<)EsZ14Nkt}_nZ#ifU_ic486Vq%jWaIl1YyQt+cl; z5L}Qu=jfl)=D(o@|C3F?8SnlEXJ}pp7csv$Sba=mZ>kl`+WY9W>(Vzo7He*z|B0c; ziud%zP^If=FJ;^WEzSa<48}NOeVxUDj$Wq#fMgk_#Y@s>VXlfjAyXDO8{Ap=4nC_r zp%(aVYWze{dDYPcBbN{OBLo$tI0^oBR$4rvWm(W4U7wK0Jv@bXJ4>y?YS;OeTH43& zsaV?DroLXkvGDq_8e)jA^VT&-37ZLa9Zp9(I@+&hj2LGR-5UzK1rj?yqm{R?*Btyz zNRnOs_>ITXS2}2@YtvZ#IsJ1Brno#}6+>0rUr_DamXXll+wi3bpknU8 zJoRQ~k*Lz~`>MUIxz}jAb6*Yg&AT_N*J2ZoS_^-_)iGE8n z9$AQu$c2lK048gfGmk9~u|@Q;xuK=+sM|jvnIsj>`UkN<8{W}X{AhfIs5!O*yoztE z5bYVm=lD1;WVKhHiakcO_Aj*gF;WxE-7O~1Cy2d$`4|C*+TWaV$B$GpbBuigx9KM{ zW6E10NCfpl?yVej3blNyeq&}sTJjVQFR}XKT3g}BIsWj z8l|48m9p!FK7(r379=ydoVCXPeD+b}A5F#QrDj_FC-N3~{Kya#Q_oK;13j}zSojs< z(!{Bnia5*ax-ts=^+G5Iz&IOy6-5ikUgOp0tE{f&6wHM5$jLWxf)qOb0To0~&c2VU z>2(~RV$3wj2DxWA5tdmcZ z?2k`Iw^H@j(DO28hbFvMs~HbiE83dv9dvtBhYIfSPRDxV1Oe(&ruB%cS&bQhV-q|p zbVp|L+d-Mg4Pl!G#LgtQH?W-~xVQ>N9M9su_cz#5N&E1RYNQ zHbxh<*YTERwBpG%W8Tl)$yeCKOM2Dvx`$Dxdu^lW+(A&=(dn;37ti@q(Lca1Ja6-N z;U?rASvYr2dav5oHY>U~JX6mM-#2PN?WrF^_;5ms<2018F{iYiHFUUt$~Kh9;M#dA zy?>95(|JH}d_hdd23_af*f&puV1C#AXb`O~+Ia24`)PeHSXxb{rXOI1RkIZU?kjK2 z`RC1OKGBukj<%(-A7I=_iBvvO2l{jK^oEb+c3umEmsy}hp-pOjMdwXLUqPPCt(bS2 zQmK5_iYEOmeXOH$HfL+zj6Nk>#n3R@GLf2)!W2VQ)+5UlgR||yf{M01zfSuUgSX;a zh@L5iiu!|8%F5tfY-S-*ddS8A^`+AtQVrx;Fkdl>vNBZhitG^*IwB;zcjL}~RCriv z%U7A!B2R+edk2IjH?{xtR>w?h%Cs_+bhC!F!&7*%?Woo5H7 zf}hVZeN^H1{_@OqWX9&V)y~m^^ zTSH2ct?j8^DQRK0%dD&rYEC^OY{$^k2-|JN>V}5&92yZ?wO43JpYX`aBYF%CqsLVZ zrr^(iuUh$IsLEIjc-s}(FFL!8K|}hO{}$D?_A2*@8r)|{NEq7h5jtRK<(@r;_VKMo zMavnC-q~N#c=q3wSz}k{mVsGFWFM`So+05?!A*UlXiHr~N&o-q2kqsBLS+w1sA4GV zXl|kx(B`q>14Sn;87ieX4z*p{(F)mbDcg_P_Oqqa+YO~@`Z-%QrT#hFmEY#SD#)$7 S?MRR8^Si;9Z&xh;g8u{Pndfc* diff --git a/drizzle/0007_numerous_wong.sql b/drizzle/0007_numerous_wong.sql new file mode 100644 index 0000000..5356177 --- /dev/null +++ b/drizzle/0007_numerous_wong.sql @@ -0,0 +1,5 @@ +CREATE TABLE `local_user_settings` ( + `user_email` text PRIMARY KEY NOT NULL, + `preferred_playback_rate` real DEFAULT 1 NOT NULL, + `sleep_timer` integer DEFAULT 600 NOT NULL +); diff --git a/drizzle/0008_hard_raider.sql b/drizzle/0008_hard_raider.sql new file mode 100644 index 0000000..18d4eb2 --- /dev/null +++ b/drizzle/0008_hard_raider.sql @@ -0,0 +1 @@ +ALTER TABLE `local_user_settings` ADD `sleep_timer_enabled` integer DEFAULT false NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0007_snapshot.json b/drizzle/meta/0007_snapshot.json new file mode 100644 index 0000000..0f13921 --- /dev/null +++ b/drizzle/meta/0007_snapshot.json @@ -0,0 +1,1323 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "9d552b6f-d48b-446f-b1d6-26260f1950d2", + "prevId": "d50ac96f-6e17-42d5-baf4-fe98293cba27", + "tables": { + "authors": { + "name": "authors", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "authors_person_index": { + "name": "authors_person_index", + "columns": [ + "url", + "person_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "authors_url_person_id_people_url_id_fk": { + "name": "authors_url_person_id_people_url_id_fk", + "tableFrom": "authors", + "tableTo": "people", + "columnsFrom": [ + "url", + "person_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "authors_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "authors_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "book_authors": { + "name": "book_authors", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "book_authors_author_index": { + "name": "book_authors_author_index", + "columns": [ + "url", + "author_id" + ], + "isUnique": false + }, + "book_authors_book_index": { + "name": "book_authors_book_index", + "columns": [ + "url", + "book_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "book_authors_url_author_id_authors_url_id_fk": { + "name": "book_authors_url_author_id_authors_url_id_fk", + "tableFrom": "book_authors", + "tableTo": "authors", + "columnsFrom": [ + "url", + "author_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_authors_url_book_id_books_url_id_fk": { + "name": "book_authors_url_book_id_books_url_id_fk", + "tableFrom": "book_authors", + "tableTo": "books", + "columnsFrom": [ + "url", + "book_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_authors_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "book_authors_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "books": { + "name": "books", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_format": { + "name": "published_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "books_published_index": { + "name": "books_published_index", + "columns": [ + "published" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "books_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "books_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloads": { + "name": "downloads", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "downloaded_at": { + "name": "downloaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_resumable_snapshot": { + "name": "download_resumable_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "downloads_media_index": { + "name": "downloads_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + }, + "downloads_downloaded_at_index": { + "name": "downloads_downloaded_at_index", + "columns": [ + "downloaded_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "downloads_url_media_id_media_url_id_fk": { + "name": "downloads_url_media_id_media_url_id_fk", + "tableFrom": "downloads", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "downloads_url_media_id_pk": { + "columns": [ + "url", + "media_id" + ], + "name": "downloads_url_media_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "local_player_states": { + "name": "local_player_states", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "playback_rate": { + "name": "playback_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "local_player_states_media_index": { + "name": "local_player_states_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "local_player_states_url_media_id_media_url_id_fk": { + "name": "local_player_states_url_media_id_media_url_id_fk", + "tableFrom": "local_player_states", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "local_player_states_url_media_id_user_email_pk": { + "columns": [ + "url", + "media_id", + "user_email" + ], + "name": "local_player_states_url_media_id_user_email_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "local_user_settings": { + "name": "local_user_settings", + "columns": { + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "preferred_playback_rate": { + "name": "preferred_playback_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "sleep_timer": { + "name": "sleep_timer", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 600 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapters": { + "name": "chapters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplemental_files": { + "name": "supplemental_files", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "full_cast": { + "name": "full_cast", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "abridged": { + "name": "abridged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mpd_path": { + "name": "mpd_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hls_path": { + "name": "hls_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mp4_path": { + "name": "mp4_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_format": { + "name": "published_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_book_index": { + "name": "media_book_index", + "columns": [ + "url", + "book_id" + ], + "isUnique": false + }, + "media_status_index": { + "name": "media_status_index", + "columns": [ + "status" + ], + "isUnique": false + }, + "media_inserted_at_index": { + "name": "media_inserted_at_index", + "columns": [ + "inserted_at" + ], + "isUnique": false + }, + "media_published_index": { + "name": "media_published_index", + "columns": [ + "published" + ], + "isUnique": false + } + }, + "foreignKeys": { + "media_url_book_id_books_url_id_fk": { + "name": "media_url_book_id_books_url_id_fk", + "tableFrom": "media", + "tableTo": "books", + "columnsFrom": [ + "url", + "book_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "media_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_narrators": { + "name": "media_narrators", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "narrator_id": { + "name": "narrator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_narrators_media_index": { + "name": "media_narrators_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + }, + "media_narrators_narrator_index": { + "name": "media_narrators_narrator_index", + "columns": [ + "url", + "narrator_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "media_narrators_url_media_id_media_url_id_fk": { + "name": "media_narrators_url_media_id_media_url_id_fk", + "tableFrom": "media_narrators", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_narrators_url_narrator_id_narrators_url_id_fk": { + "name": "media_narrators_url_narrator_id_narrators_url_id_fk", + "tableFrom": "media_narrators", + "tableTo": "narrators", + "columnsFrom": [ + "url", + "narrator_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_narrators_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "media_narrators_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "narrators": { + "name": "narrators", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "narrators_person_index": { + "name": "narrators_person_index", + "columns": [ + "url", + "person_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "narrators_url_person_id_people_url_id_fk": { + "name": "narrators_url_person_id_people_url_id_fk", + "tableFrom": "narrators", + "tableTo": "people", + "columnsFrom": [ + "url", + "person_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "narrators_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "narrators_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "people": { + "name": "people", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "people_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "people_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "player_states": { + "name": "player_states", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "playback_rate": { + "name": "playback_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "player_states_email_index": { + "name": "player_states_email_index", + "columns": [ + "user_email" + ], + "isUnique": false + }, + "player_states_status_index": { + "name": "player_states_status_index", + "columns": [ + "status" + ], + "isUnique": false + }, + "player_states_media_index": { + "name": "player_states_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + }, + "player_states_updated_at_index": { + "name": "player_states_updated_at_index", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "player_states_url_media_id_media_url_id_fk": { + "name": "player_states_url_media_id_media_url_id_fk", + "tableFrom": "player_states", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_states_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "player_states_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "series": { + "name": "series", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "series_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "series_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "series_books": { + "name": "series_books", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "series_id": { + "name": "series_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_number": { + "name": "book_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "series_books_book_index": { + "name": "series_books_book_index", + "columns": [ + "url", + "book_id" + ], + "isUnique": false + }, + "series_books_series_index": { + "name": "series_books_series_index", + "columns": [ + "url", + "series_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "series_books_url_book_id_books_url_id_fk": { + "name": "series_books_url_book_id_books_url_id_fk", + "tableFrom": "series_books", + "tableTo": "books", + "columnsFrom": [ + "url", + "book_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "series_books_url_series_id_series_url_id_fk": { + "name": "series_books_url_series_id_series_url_id_fk", + "tableFrom": "series_books", + "tableTo": "series", + "columnsFrom": [ + "url", + "series_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "series_books_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "series_books_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "servers": { + "name": "servers", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_down_sync": { + "name": "last_down_sync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_up_sync": { + "name": "last_up_sync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "servers_url_user_email_pk": { + "columns": [ + "url", + "user_email" + ], + "name": "servers_url_user_email_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0008_snapshot.json b/drizzle/meta/0008_snapshot.json new file mode 100644 index 0000000..025e4c1 --- /dev/null +++ b/drizzle/meta/0008_snapshot.json @@ -0,0 +1,1331 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "63b439fd-1e1c-44e2-89eb-012172bd4e5f", + "prevId": "9d552b6f-d48b-446f-b1d6-26260f1950d2", + "tables": { + "authors": { + "name": "authors", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "authors_person_index": { + "name": "authors_person_index", + "columns": [ + "url", + "person_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "authors_url_person_id_people_url_id_fk": { + "name": "authors_url_person_id_people_url_id_fk", + "tableFrom": "authors", + "tableTo": "people", + "columnsFrom": [ + "url", + "person_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "authors_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "authors_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "book_authors": { + "name": "book_authors", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "book_authors_author_index": { + "name": "book_authors_author_index", + "columns": [ + "url", + "author_id" + ], + "isUnique": false + }, + "book_authors_book_index": { + "name": "book_authors_book_index", + "columns": [ + "url", + "book_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "book_authors_url_author_id_authors_url_id_fk": { + "name": "book_authors_url_author_id_authors_url_id_fk", + "tableFrom": "book_authors", + "tableTo": "authors", + "columnsFrom": [ + "url", + "author_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "book_authors_url_book_id_books_url_id_fk": { + "name": "book_authors_url_book_id_books_url_id_fk", + "tableFrom": "book_authors", + "tableTo": "books", + "columnsFrom": [ + "url", + "book_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "book_authors_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "book_authors_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "books": { + "name": "books", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_format": { + "name": "published_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "books_published_index": { + "name": "books_published_index", + "columns": [ + "published" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "books_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "books_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "downloads": { + "name": "downloads", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "downloaded_at": { + "name": "downloaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_resumable_snapshot": { + "name": "download_resumable_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "downloads_media_index": { + "name": "downloads_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + }, + "downloads_downloaded_at_index": { + "name": "downloads_downloaded_at_index", + "columns": [ + "downloaded_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "downloads_url_media_id_media_url_id_fk": { + "name": "downloads_url_media_id_media_url_id_fk", + "tableFrom": "downloads", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "downloads_url_media_id_pk": { + "columns": [ + "url", + "media_id" + ], + "name": "downloads_url_media_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "local_player_states": { + "name": "local_player_states", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "playback_rate": { + "name": "playback_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "local_player_states_media_index": { + "name": "local_player_states_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "local_player_states_url_media_id_media_url_id_fk": { + "name": "local_player_states_url_media_id_media_url_id_fk", + "tableFrom": "local_player_states", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "local_player_states_url_media_id_user_email_pk": { + "columns": [ + "url", + "media_id", + "user_email" + ], + "name": "local_player_states_url_media_id_user_email_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "local_user_settings": { + "name": "local_user_settings", + "columns": { + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "preferred_playback_rate": { + "name": "preferred_playback_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "sleep_timer": { + "name": "sleep_timer", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 600 + }, + "sleep_timer_enabled": { + "name": "sleep_timer_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapters": { + "name": "chapters", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "supplemental_files": { + "name": "supplemental_files", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "full_cast": { + "name": "full_cast", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "abridged": { + "name": "abridged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mpd_path": { + "name": "mpd_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hls_path": { + "name": "hls_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mp4_path": { + "name": "mp4_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_format": { + "name": "published_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_book_index": { + "name": "media_book_index", + "columns": [ + "url", + "book_id" + ], + "isUnique": false + }, + "media_status_index": { + "name": "media_status_index", + "columns": [ + "status" + ], + "isUnique": false + }, + "media_inserted_at_index": { + "name": "media_inserted_at_index", + "columns": [ + "inserted_at" + ], + "isUnique": false + }, + "media_published_index": { + "name": "media_published_index", + "columns": [ + "published" + ], + "isUnique": false + } + }, + "foreignKeys": { + "media_url_book_id_books_url_id_fk": { + "name": "media_url_book_id_books_url_id_fk", + "tableFrom": "media", + "tableTo": "books", + "columnsFrom": [ + "url", + "book_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "media_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media_narrators": { + "name": "media_narrators", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "narrator_id": { + "name": "narrator_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "media_narrators_media_index": { + "name": "media_narrators_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + }, + "media_narrators_narrator_index": { + "name": "media_narrators_narrator_index", + "columns": [ + "url", + "narrator_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "media_narrators_url_media_id_media_url_id_fk": { + "name": "media_narrators_url_media_id_media_url_id_fk", + "tableFrom": "media_narrators", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "media_narrators_url_narrator_id_narrators_url_id_fk": { + "name": "media_narrators_url_narrator_id_narrators_url_id_fk", + "tableFrom": "media_narrators", + "tableTo": "narrators", + "columnsFrom": [ + "url", + "narrator_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_narrators_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "media_narrators_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "narrators": { + "name": "narrators", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "person_id": { + "name": "person_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "narrators_person_index": { + "name": "narrators_person_index", + "columns": [ + "url", + "person_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "narrators_url_person_id_people_url_id_fk": { + "name": "narrators_url_person_id_people_url_id_fk", + "tableFrom": "narrators", + "tableTo": "people", + "columnsFrom": [ + "url", + "person_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "narrators_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "narrators_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "people": { + "name": "people", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "thumbnails": { + "name": "thumbnails", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "people_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "people_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "player_states": { + "name": "player_states", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "playback_rate": { + "name": "playback_rate", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "real", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "player_states_email_index": { + "name": "player_states_email_index", + "columns": [ + "user_email" + ], + "isUnique": false + }, + "player_states_status_index": { + "name": "player_states_status_index", + "columns": [ + "status" + ], + "isUnique": false + }, + "player_states_media_index": { + "name": "player_states_media_index", + "columns": [ + "url", + "media_id" + ], + "isUnique": false + }, + "player_states_updated_at_index": { + "name": "player_states_updated_at_index", + "columns": [ + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "player_states_url_media_id_media_url_id_fk": { + "name": "player_states_url_media_id_media_url_id_fk", + "tableFrom": "player_states", + "tableTo": "media", + "columnsFrom": [ + "url", + "media_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "player_states_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "player_states_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "series": { + "name": "series", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "series_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "series_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "series_books": { + "name": "series_books", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "series_id": { + "name": "series_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_number": { + "name": "book_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inserted_at": { + "name": "inserted_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "series_books_book_index": { + "name": "series_books_book_index", + "columns": [ + "url", + "book_id" + ], + "isUnique": false + }, + "series_books_series_index": { + "name": "series_books_series_index", + "columns": [ + "url", + "series_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "series_books_url_book_id_books_url_id_fk": { + "name": "series_books_url_book_id_books_url_id_fk", + "tableFrom": "series_books", + "tableTo": "books", + "columnsFrom": [ + "url", + "book_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "series_books_url_series_id_series_url_id_fk": { + "name": "series_books_url_series_id_series_url_id_fk", + "tableFrom": "series_books", + "tableTo": "series", + "columnsFrom": [ + "url", + "series_id" + ], + "columnsTo": [ + "url", + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "series_books_url_id_pk": { + "columns": [ + "url", + "id" + ], + "name": "series_books_url_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "servers": { + "name": "servers", + "columns": { + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_email": { + "name": "user_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_down_sync": { + "name": "last_down_sync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_up_sync": { + "name": "last_up_sync", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "servers_url_user_email_pk": { + "columns": [ + "url", + "user_email" + ], + "name": "servers_url_user_email_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 8c90248..56cd779 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -50,6 +50,20 @@ "when": 1729048924964, "tag": "0006_aromatic_eternals", "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1730601887354, + "tag": "0007_numerous_wong", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1730677947305, + "tag": "0008_hard_raider", + "breakpoints": true } ] } \ No newline at end of file diff --git a/drizzle/migrations.js b/drizzle/migrations.js index 1333b4e..ed6a25e 100644 --- a/drizzle/migrations.js +++ b/drizzle/migrations.js @@ -7,6 +7,8 @@ import m0003 from "./0003_overjoyed_typhoid_mary.sql"; import m0004 from "./0004_tricky_leo.sql"; import m0005 from "./0005_green_moondragon.sql"; import m0006 from "./0006_aromatic_eternals.sql"; +import m0007 from "./0007_numerous_wong.sql"; +import m0008 from "./0008_hard_raider.sql"; import journal from "./meta/_journal.json"; export default { @@ -19,5 +21,7 @@ export default { m0004, m0005, m0006, + m0007, + m0008, }, }; diff --git a/package.json b/package.json index b29d341..42a8160 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,9 @@ "dependencies": { "@expo/vector-icons": "^14.0.4", "@react-hook/previous": "^1.0.1", + "@react-native-community/slider": "4.5.2", "@react-navigation/native": "^6.1.18", - "drizzle-orm": "^0.35.3", + "drizzle-orm": "^0.36.0", "expo": "^51.0.38", "expo-build-properties": "~0.12.5", "expo-constants": "~16.0.2", @@ -39,6 +40,7 @@ "expo-drizzle-studio-plugin": "^0.0.2", "expo-file-system": "~17.0.1", "expo-font": "~12.0.10", + "expo-haptics": "~13.0.1", "expo-image": "~1.13.0", "expo-linking": "~6.3.1", "expo-navigation-bar": "~3.0.7", @@ -64,7 +66,7 @@ "react-native-web": "~0.19.13", "tailwindcss": "^3.4.14", "use-debounce": "^10.0.4", - "zustand": "^5.0.0" + "zustand": "^5.0.1" }, "devDependencies": { "@0no-co/graphqlsp": "^1.12.16", @@ -76,7 +78,7 @@ "@types/react": "~18.2.79", "@types/react-test-renderer": "^18.3.0", "babel-plugin-inline-import": "^3.0.0", - "drizzle-kit": "^0.26.2", + "drizzle-kit": "^0.27.1", "eslint": "^8.57.1", "eslint-config-expo": "^7.1.2", "eslint-config-prettier": "^9.1.0", diff --git a/src/app/(tabs)/(library)/_layout.tsx b/src/app/(app)/(tabs)/(library)/_layout.tsx similarity index 61% rename from src/app/(tabs)/(library)/_layout.tsx rename to src/app/(app)/(tabs)/(library)/_layout.tsx index bd323ea..4b99857 100644 --- a/src/app/(tabs)/(library)/_layout.tsx +++ b/src/app/(app)/(tabs)/(library)/_layout.tsx @@ -1,16 +1,9 @@ -import { useSessionStore } from "@/src/stores/session"; -import { Redirect, Stack } from "expo-router"; +import { Stack } from "expo-router"; const getId = ({ params }: { params?: Record | undefined }) => params?.id; -export default function AppLayout() { - const session = useSessionStore((state) => state.session); - - if (!session) { - return ; - } - +export default function LibraryStackLayout() { return ( diff --git a/src/app/(app)/(tabs)/(library)/book/[id].tsx b/src/app/(app)/(tabs)/(library)/book/[id].tsx new file mode 100644 index 0000000..1d7dce1 --- /dev/null +++ b/src/app/(app)/(tabs)/(library)/book/[id].tsx @@ -0,0 +1,104 @@ +import NamesList from "@/src/components/NamesList"; +import { Tile } from "@/src/components/Tiles"; +import { BookDetails, useBookDetails } from "@/src/db/library"; +import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; +import { Session, useSession } from "@/src/stores/session"; +import { RouterParams } from "@/src/types/router"; +import { formatPublished } from "@/src/utils/date"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { StyleSheet, Text, View } from "react-native"; +import Animated from "react-native-reanimated"; +import colors from "tailwindcss/colors"; + +export default function BookDetailsScreen() { + const session = useSession((state) => state.session); + const { id: bookId, title } = useLocalSearchParams(); + useSyncOnFocus(); + + if (!session) return null; + + return ( + <> + + + + ); +} + +type BookDetailsFlatListProps = { + bookId: string; + session: Session; +}; + +function BookDetailsFlatList({ bookId, session }: BookDetailsFlatListProps) { + const { data: book, opacity } = useBookDetails(session, bookId); + + if (!book) return null; + + return ( + item.id} + numColumns={2} + ListHeaderComponent={() =>
} + renderItem={({ item }) => { + return ; + }} + /> + ); +} + +type BookProp = BookDetails; +type HeaderProps = { book: BookProp }; + +function Header({ book }: HeaderProps) { + return ( + + + ba.author.name)} + /> + {book.published && ( + + First published{" "} + {formatPublished(book.published, book.publishedFormat)} + + )} + + + Editions + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 8, + }, + tile: { + padding: 8, + width: "50%", + marginBottom: 8, + }, + headerContainer: { + padding: 8, + gap: 32, + }, + headerAuthorsList: { + fontSize: 18, + fontWeight: 500, + color: colors.zinc[100], + }, + headerPublishedText: { + color: colors.zinc[300], + }, + headerEditionsText: { + fontSize: 22, + fontWeight: 500, + color: colors.zinc[100], + }, +}); diff --git a/src/app/(app)/(tabs)/(library)/index.tsx b/src/app/(app)/(tabs)/(library)/index.tsx new file mode 100644 index 0000000..623a052 --- /dev/null +++ b/src/app/(app)/(tabs)/(library)/index.tsx @@ -0,0 +1,53 @@ +import { MediaTile } from "@/src/components/Tiles"; +import { useMediaList } from "@/src/db/library"; +import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; +import { Session, useSession } from "@/src/stores/session"; +import { StyleSheet } from "react-native"; +import Animated from "react-native-reanimated"; +import colors from "tailwindcss/colors"; + +export default function LibraryScreen() { + const session = useSession((state) => state.session); + useSyncOnFocus(); + + if (!session) return null; + + return ; +} + +type LibraryFlatlistProps = { + session: Session; +}; + +function LibraryFlatlist({ session }: LibraryFlatlistProps) { + const { data: media, updatedAt, opacity } = useMediaList(session); + + if (updatedAt !== undefined && media.length === 0) { + // TODO: there are no books on this server + return null; + } + + return ( + item.id} + numColumns={2} + renderItem={({ item }) => } + /> + ); +} + +const styles = StyleSheet.create({ + flatlist: { + padding: 8, + }, + tile: { + padding: 8, + width: "50%", + marginBottom: 8, + }, + error: { + color: colors.red[500], + }, +}); diff --git a/src/app/(app)/(tabs)/(library)/media/[id].tsx b/src/app/(app)/(tabs)/(library)/media/[id].tsx new file mode 100644 index 0000000..959d82c --- /dev/null +++ b/src/app/(app)/(tabs)/(library)/media/[id].tsx @@ -0,0 +1,757 @@ +import Description from "@/src/components/Description"; +import IconButton from "@/src/components/IconButton"; +import Loading from "@/src/components/Loading"; +import NamesList from "@/src/components/NamesList"; +import ThumbnailImage from "@/src/components/ThumbnailImage"; +import { + BookTile, + MediaTile, + PersonTile, + SeriesBookTile, +} from "@/src/components/Tiles"; +import { + useMediaActionBarInfo, + useMediaAuthorsAndNarrators, + useMediaDescription, + useMediaHeaderInfo, + useMediaIds, + useMediaOtherEditions, + useOtherBooksByAuthor, + useOtherBooksInSeries, + useOtherMediaByNarrator, +} from "@/src/db/library"; +import { syncDown } from "@/src/db/sync"; +import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; +import { startDownload, useDownloads } from "@/src/stores/downloads"; +import { loadMedia, requestExpandPlayer } from "@/src/stores/player"; +import { useScreen } from "@/src/stores/screen"; +import { Session, useSession } from "@/src/stores/session"; +import { RouterParams } from "@/src/types/router"; +import { formatPublished } from "@/src/utils/date"; +import { durationDisplay } from "@/src/utils/time"; +import { Stack, router, useLocalSearchParams } from "expo-router"; +import { useEffect, useState } from "react"; +import { FlatList, Pressable, StyleSheet, Text, View } from "react-native"; +import Animated from "react-native-reanimated"; +import colors from "tailwindcss/colors"; + +export default function MediaDetailsScreen() { + const session = useSession((state) => state.session); + const { id: mediaId, title } = useLocalSearchParams(); + useSyncOnFocus(); + + if (!session) return null; + + return ( + <> + + + + ); +} + +type HeaderSection = { + id: string; + type: "header"; + mediaId: string; +}; + +type ActionBarSection = { + id: string; + type: "actionBar"; + mediaId: string; +}; + +type MediaDescriptionSection = { + id: string; + type: "mediaDescription"; + mediaId: string; +}; + +type AuthorsAndNarratorsSection = { + id: string; + type: "authorsAndNarrators"; + mediaId: string; +}; + +type OtherEditionsSection = { + id: string; + type: "otherEditions"; + bookId: string; + withoutMediaId: string; +}; + +type OtherBooksInSeriesSection = { + id: string; + type: "otherBooksInSeries"; + seriesId: string; +}; + +type OtherBooksByAuthorSection = { + id: string; + type: "otherBooksByAuthor"; + authorId: string; + withoutBookId: string; + withoutSeriesIds: string[]; +}; + +type OtherMediaByNarratorSection = { + id: string; + type: "otherMediaByNarrator"; + narratorId: string; + withoutMediaId: string; + withoutSeriesIds: string[]; + withoutAuthorIds: string[]; +}; + +type Section = + | HeaderSection + | ActionBarSection + | MediaDescriptionSection + | AuthorsAndNarratorsSection + | OtherEditionsSection + | OtherBooksInSeriesSection + | OtherBooksByAuthorSection + | OtherMediaByNarratorSection; + +function useSections(mediaId: string, session: Session) { + const { ids, opacity } = useMediaIds(session, mediaId); + + const [sections, setSections] = useState(); + + useEffect(() => { + if (!ids) return; + + const sections: Section[] = [ + { id: `header-${mediaId}`, type: "header", mediaId }, + { id: `actions-${mediaId}`, type: "actionBar", mediaId }, + { + id: `description-${mediaId}`, + type: "mediaDescription", + mediaId, + }, + { + id: `authors-narrators-${mediaId}`, + type: "authorsAndNarrators", + mediaId, + }, + { + id: `editions-${mediaId}`, + type: "otherEditions", + bookId: ids.bookId, + withoutMediaId: mediaId, + }, + ...ids.seriesIds.map( + (seriesId): OtherBooksInSeriesSection => ({ + id: `books-in-series-${seriesId}`, + type: "otherBooksInSeries", + seriesId, + }), + ), + ...ids.authorIds.map( + (authorId): OtherBooksByAuthorSection => ({ + id: `other-books-${authorId}`, + type: "otherBooksByAuthor", + authorId, + withoutBookId: ids.bookId, + withoutSeriesIds: ids.seriesIds, + }), + ), + ...ids.narratorIds.map( + (narratorId): OtherMediaByNarratorSection => ({ + id: `other-media-${narratorId}`, + type: "otherMediaByNarrator", + narratorId, + withoutMediaId: mediaId, + withoutSeriesIds: ids.seriesIds, + withoutAuthorIds: ids.authorIds, + }), + ), + ]; + setSections(sections); + }, [ids, mediaId, session]); + + return { sections, opacity }; +} + +type MediaDetailsFlatListProps = { + session: Session; + mediaId: string; +}; + +function MediaDetailsFlatList({ session, mediaId }: MediaDetailsFlatListProps) { + const { sections, opacity } = useSections(mediaId, session); + + if (!sections) return null; + + return ( + item.id} + initialNumToRender={2} + ListHeaderComponent={} + ListFooterComponent={} + renderItem={({ item }) => { + switch (item.type) { + case "header": + return
; + case "actionBar": + return ; + case "mediaDescription": + return ( + + ); + case "authorsAndNarrators": + return ( + + ); + case "otherEditions": + return ( + + ); + case "otherBooksInSeries": + return ( + + ); + case "otherBooksByAuthor": + return ( + + ); + case "otherMediaByNarrator": + return ( + + ); + default: + // can't happen + console.error("unknown section type:", item); + return null; + } + }} + /> + ); +} + +type HeaderProps = { + mediaId: string; + session: Session; +}; + +function Header({ mediaId, session }: HeaderProps) { + const { data: media, opacity } = useMediaHeaderInfo(session, mediaId); + + if (!media) return null; + + return ( + + + + + {media.book.title} + + {media.book.seriesBooks.length !== 0 && ( + `${sb.series.name} #${sb.bookNumber}`, + )} + className="text-lg text-zinc-100 leading-tight" + /> + )} + ba.author.name)} + className="text-lg text-zinc-300 leading-tight" + /> + {media.mediaNarrators.length > 0 && ( + mn.narrator.name)} + className="text-zinc-400 leading-tight" + /> + )} + {media.mediaNarrators.length === 0 && media.fullCast && ( + + Read by a full cast + + )} + + {media.duration && ( + + + {durationDisplay(media.duration)} {media.abridged && "(abridged)"} + + + )} + + ); +} + +type ActionBarProps = { + mediaId: string; + session: Session; +}; + +function ActionBar({ mediaId, session }: ActionBarProps) { + const progress = useDownloads((state) => state.downloadProgresses[mediaId]); + const { data: media, opacity } = useMediaActionBarInfo(session, mediaId); + + if (!media) return null; + + if (progress) { + return ( + + router.navigate("/downloads")} + > + + + + + + Downloading... + + + + + ); + } else if (media.download && media.download.status !== "error") { + return ( + + + + { + await syncDown(session, true); + await loadMedia(session, media.id); + requestExpandPlayer(); + }} + > + + Play + + + + + + You have this audiobook downloaded, it will play from your device and + not require an internet connection. + + + ); + } else { + return ( + + + + { + await syncDown(session, true); + await loadMedia(session, media.id); + requestExpandPlayer(); + }} + > + + Stream + + + + + { + if (!media.mp4Path) return; + startDownload( + session, + media.id, + media.mp4Path, + media.thumbnails, + ); + router.navigate("/downloads"); + }} + > + + Download + + + + + + Playing this audiobook will stream it and require an internet + connection and may use your data plan. + + + ); + } +} + +type MediaDescriptionProps = { + mediaId: string; + session: Session; +}; + +function MediaDescription({ mediaId, session }: MediaDescriptionProps) { + const { data: media, opacity } = useMediaDescription(session, mediaId); + + if (!media?.description) return null; + + return ( + + + + {media.book.published && ( + + First published{" "} + {formatPublished(media.book.published, media.book.publishedFormat)} + + )} + {media.published && ( + + This edition published{" "} + {formatPublished(media.published, media.publishedFormat)} + + )} + {media.publisher && ( + by {media.publisher} + )} + {media.notes && ( + Note: {media.notes} + )} + + + ); +} + +type AuthorsAndNarratorsProps = { + mediaId: string; + session: Session; +}; + +function AuthorsAndNarrators({ mediaId, session }: AuthorsAndNarratorsProps) { + const screenWidth = useScreen((state) => state.screenWidth); + const { media, authorSet, narratorSet, opacity } = + useMediaAuthorsAndNarrators(session, mediaId); + + if (!media) return null; + + return ( + + + Author{media.book.bookAuthors.length > 1 && "s"} & Narrator + {media.mediaNarrators.length > 1 && "s"} + + item.id} + horizontal={true} + renderItem={({ item }) => { + if ("author" in item) { + const label = narratorSet.has(item.author.person.id) + ? "Author & Narrator" + : "Author"; + return ( + + + + ); + } + + if ("narrator" in item) { + // skip if this person is also an author, as they were already rendered + if (authorSet.has(item.narrator.person.id)) return null; + + return ( + + + + ); + } + + // can't happen: + console.error("unknown item:", item); + return null; + }} + /> + + ); +} + +type OtherEditionsProps = { + bookId: string; + session: Session; + withoutMediaId: string; +}; + +function OtherEditions(props: OtherEditionsProps) { + const { bookId, session, withoutMediaId } = props; + const screenWidth = useScreen((state) => state.screenWidth); + const { media, opacity } = useMediaOtherEditions( + session, + bookId, + withoutMediaId, + ); + + if (media.length === 0) return null; + + const navigateToBook = () => { + router.navigate({ + pathname: "/book/[id]", + params: { id: media[0].book.id, title: media[0].book.title }, + }); + }; + + return ( + + + item.id} + horizontal={true} + renderItem={({ item }) => { + return ( + + ); + }} + /> + + ); +} + +type OtherBooksInSeriesProps = { + seriesId: string; + session: Session; +}; + +function OtherBooksInSeries({ seriesId, session }: OtherBooksInSeriesProps) { + const screenWidth = useScreen((state) => state.screenWidth); + const { data: series, opacity } = useOtherBooksInSeries(session, seriesId); + + if (!series) return null; + + const navigateToSeries = () => { + router.navigate({ + pathname: "/series/[id]", + params: { id: series.id, title: series.name }, + }); + }; + + return ( + + + item.id} + horizontal={true} + renderItem={({ item }) => { + return ( + + ); + }} + /> + + ); +} + +type OtherBooksByAuthorProps = { + authorId: string; + session: Session; + withoutBookId: string; + withoutSeriesIds: string[]; +}; + +function OtherBooksByAuthor(props: OtherBooksByAuthorProps) { + const { authorId, session, withoutBookId, withoutSeriesIds } = props; + const screenWidth = useScreen((state) => state.screenWidth); + const { books, author, opacity } = useOtherBooksByAuthor( + session, + authorId, + withoutBookId, + withoutSeriesIds, + ); + + if (!author) return null; + if (books.length === 0) return null; + + const navigateToPerson = () => { + router.navigate({ + pathname: "/person/[id]", + params: { id: author.person.id, title: author.person.name }, + }); + }; + + return ( + + + item.id} + horizontal={true} + renderItem={({ item }) => { + return ( + + ); + }} + /> + + ); +} + +type OtherMediaByNarratorProps = { + narratorId: string; + session: Session; + withoutMediaId: string; + withoutSeriesIds: string[]; + withoutAuthorIds: string[]; +}; + +function OtherMediaByNarrator(props: OtherMediaByNarratorProps) { + const { + narratorId, + session, + withoutMediaId, + withoutSeriesIds, + withoutAuthorIds, + } = props; + const screenWidth = useScreen((state) => state.screenWidth); + const { media, narrator, opacity } = useOtherMediaByNarrator( + session, + narratorId, + withoutMediaId, + withoutSeriesIds, + withoutAuthorIds, + ); + + if (!narrator) return null; + if (media.length === 0) return null; + + const navigateToPerson = () => { + router.navigate({ + pathname: "/person/[id]", + params: { id: narrator.person.id, title: narrator.person.name }, + }); + }; + + return ( + + + item.id} + horizontal={true} + renderItem={({ item }) => { + return ( + + ); + }} + /> + + ); +} + +type HeaderButtonProps = { + label: string; + onPress: () => void; +}; + +function HeaderButton({ label, onPress }: HeaderButtonProps) { + return ( + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + }, + headerContainer: { + gap: 8, + }, +}); diff --git a/src/app/(app)/(tabs)/(library)/person/[id].tsx b/src/app/(app)/(tabs)/(library)/person/[id].tsx new file mode 100644 index 0000000..034b7a8 --- /dev/null +++ b/src/app/(app)/(tabs)/(library)/person/[id].tsx @@ -0,0 +1,269 @@ +import Description from "@/src/components/Description"; +import ThumbnailImage from "@/src/components/ThumbnailImage"; +import { BookTile, MediaTile } from "@/src/components/Tiles"; +import { + useBooksByAuthor, + useMediaByNarrator, + usePersonDescription, + usePersonHeaderInfo, + usePersonIds, +} from "@/src/db/library"; +import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; +import { Session, useSession } from "@/src/stores/session"; +import { RouterParams } from "@/src/types/router"; +import { Stack, useLocalSearchParams } from "expo-router"; +import { useEffect, useState } from "react"; +import { FlatList, StyleSheet, Text, View } from "react-native"; +import Animated from "react-native-reanimated"; + +export default function PersonDetailsScreen() { + const session = useSession((state) => state.session); + const { id: personId, title } = useLocalSearchParams(); + useSyncOnFocus(); + + if (!session) return null; + + return ( + <> + + + + ); +} + +type HeaderSection = { + id: string; + type: "header"; + personId: string; +}; + +type PersonDescriptionSection = { + id: string; + type: "personDescription"; + personId: string; +}; + +type BooksByAuthorSection = { + id: string; + type: "booksByAuthor"; + authorId: string; +}; + +type MediaByNarratorSection = { + id: string; + type: "mediaByNarrator"; + narratorId: string; +}; + +type Section = + | HeaderSection + | PersonDescriptionSection + | BooksByAuthorSection + | MediaByNarratorSection; + +function useSections(personId: string, session: Session) { + const { ids, opacity } = usePersonIds(session, personId); + const [sections, setSections] = useState(); + + useEffect(() => { + if (!ids) return; + + const sections: Section[] = [ + { id: `header-${personId}`, type: "header", personId }, + { + id: `description-${personId}`, + type: "personDescription", + personId, + }, + ...ids.authorIds.map( + (authorId): BooksByAuthorSection => ({ + id: `books-${authorId}`, + type: "booksByAuthor", + authorId, + }), + ), + ...ids.narratorIds.map( + (narratorId): MediaByNarratorSection => ({ + id: `media-${narratorId}`, + type: "mediaByNarrator", + narratorId, + }), + ), + ]; + setSections(sections); + }, [ids, personId]); + + return { sections, opacity }; +} + +type PersonDetailsFlatListProps = { + session: Session; + personId: string; +}; + +function PersonDetailsFlatList(props: PersonDetailsFlatListProps) { + const { personId, session } = props; + const { sections, opacity } = useSections(personId, session); + + if (!sections) return null; + + return ( + item.id} + initialNumToRender={2} + ListHeaderComponent={} + ListFooterComponent={} + renderItem={({ item }) => { + switch (item.type) { + case "header": + return
; + case "personDescription": + return ( + + ); + case "booksByAuthor": + return ; + case "mediaByNarrator": + return ( + + ); + default: + // can't happen + console.error("unknown section type:", item); + return null; + } + }} + /> + ); +} + +type HeaderProps = { + personId: string; + session: Session; +}; + +function Header({ personId, session }: HeaderProps) { + const { data: person, opacity } = usePersonHeaderInfo(session, personId); + + if (!person) return null; + + return ( + + + + ); +} + +type PersonDescriptionProps = { + personId: string; + session: Session; +}; + +function PersonDescription({ personId, session }: PersonDescriptionProps) { + const { data: person, opacity } = usePersonDescription(session, personId); + + if (!person?.description) return null; + + return ( + + + + ); +} + +type BooksByAuthorProps = { + authorId: string; + session: Session; +}; + +function BooksByAuthor({ authorId, session }: BooksByAuthorProps) { + const { books, author, opacity } = useBooksByAuthor(session, authorId); + + if (!author) return null; + if (books.length === 0) return null; + + return ( + + + {author.name === author.person.name + ? `By ${author.name}` + : `As ${author.name}`} + + + item.id} + numColumns={2} + renderItem={({ item }) => { + return ; + }} + /> + + ); +} + +type MediaByNarratorProps = { + narratorId: string; + session: Session; +}; + +function MediaByNarrator({ narratorId, session }: MediaByNarratorProps) { + const { media, narrator, opacity } = useMediaByNarrator(session, narratorId); + + if (!narrator) return null; + if (media.length === 0) return null; + + return ( + + + {narrator.name === narrator.person.name + ? `Read by ${narrator.name}` + : `Read as ${narrator.name}`} + + + item.id} + numColumns={2} + renderItem={({ item }) => { + return ; + }} + /> + + ); +} + +const styles = StyleSheet.create({ + container: { + paddingHorizontal: 16, + }, + spacingTop: { + marginTop: 32, + }, + tile: { + padding: 8, + width: "50%", + marginBottom: 8, + }, +}); diff --git a/src/app/(tabs)/(library)/series/[id].tsx b/src/app/(app)/(tabs)/(library)/series/[id].tsx similarity index 67% rename from src/app/(tabs)/(library)/series/[id].tsx rename to src/app/(app)/(tabs)/(library)/series/[id].tsx index 4db3ca6..dfd05d1 100644 --- a/src/app/(tabs)/(library)/series/[id].tsx +++ b/src/app/(app)/(tabs)/(library)/series/[id].tsx @@ -1,20 +1,17 @@ import NamesList from "@/src/components/NamesList"; import { PersonTile, SeriesBookTile } from "@/src/components/Tiles"; -import { db } from "@/src/db/db"; +import { useSeriesDetails } from "@/src/db/library"; import * as schema from "@/src/db/schema"; -import { useLiveTablesQuery } from "@/src/hooks/use.live.tables.query"; import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; -import { Session, useSessionStore } from "@/src/stores/session"; -import { and, eq, sql } from "drizzle-orm"; +import { Session, useSession } from "@/src/stores/session"; +import { RouterParams } from "@/src/types/router"; import { Stack, useLocalSearchParams } from "expo-router"; import { FlatList, StyleSheet, Text, View } from "react-native"; +import Animated from "react-native-reanimated"; -export default function SeriesDetails() { - const session = useSessionStore((state) => state.session); - const { id: seriesId, title } = useLocalSearchParams<{ - id: string; - title: string; - }>(); +export default function SeriesDetailsScreen() { + const session = useSession((state) => state.session); + const { id: seriesId, title } = useLocalSearchParams(); useSyncOnFocus(); if (!session) return null; @@ -47,67 +44,7 @@ function SeriesDetailsFlatList({ seriesId, session, }: SeriesDetailsFlatListProps) { - const { data: series } = useLiveTablesQuery( - db.query.series.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(schema.series.url, session.url), - eq(schema.series.id, seriesId), - ), - with: { - seriesBooks: { - columns: { id: true, bookNumber: true }, - orderBy: sql`CAST(book_number AS FLOAT)`, - with: { - book: { - columns: { id: true, title: true }, - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { id: true, name: true }, - with: { - person: { - columns: { id: true, name: true, thumbnails: true }, - }, - }, - }, - }, - }, - media: { - columns: { id: true, thumbnails: true }, - with: { - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { id: true, name: true }, - with: { - person: { - columns: { - id: true, - name: true, - thumbnails: true, - }, - }, - }, - }, - }, - }, - download: { - columns: { thumbnails: true }, - }, - }, - }, - }, - }, - }, - }, - }, - }), - ["series"], - ); + const { data: series, opacity } = useSeriesDetails(session, seriesId); if (!series) return null; @@ -161,8 +98,8 @@ function SeriesDetailsFlatList({ ); return ( - item.id} numColumns={2} @@ -248,6 +185,9 @@ function Footer({ authors, narrators }: FooterProps) { } const styles = StyleSheet.create({ + container: { + paddingHorizontal: 8, + }, tile: { padding: 8, width: "50%", diff --git a/src/app/(tabs)/_layout.tsx b/src/app/(app)/(tabs)/_layout.tsx similarity index 71% rename from src/app/(tabs)/_layout.tsx rename to src/app/(app)/(tabs)/_layout.tsx index 4a8edcd..7c6f60f 100644 --- a/src/app/(tabs)/_layout.tsx +++ b/src/app/(app)/(tabs)/_layout.tsx @@ -1,12 +1,21 @@ import TabBar from "@/src/components/TabBar"; import TabBarWithPlayer from "@/src/components/TabBarWithPlayer"; -import { useTrackPlayerStore } from "@/src/stores/trackPlayer"; +import { usePlayer } from "@/src/stores/player"; +import { Session, useSession } from "@/src/stores/session"; import FontAwesome6 from "@expo/vector-icons/FontAwesome6"; import { Tabs } from "expo-router"; import colors from "tailwindcss/colors"; -export default function TabLayout() { - const mediaId = useTrackPlayerStore((state) => state.mediaId); +export default function AppTabLayout() { + const session = useSession((state) => state.session); + + if (!session) return null; + + return ; +} + +function AppTabs({ session }: { session: Session }) { + const mediaId = usePlayer((state) => state.mediaId); const playerVisible = !!mediaId; return ( @@ -14,9 +23,14 @@ export default function TabLayout() { screenOptions={{ tabBarActiveTintColor: colors.lime[400], tabBarStyle: playerVisible ? { borderTopWidth: 0 } : {}, + tabBarLabelStyle: { paddingBottom: 4 }, }} tabBar={(props) => - playerVisible ? : + playerVisible ? ( + + ) : ( + + ) } > state.session); + const session = useSession((state) => state.session); if (!session) return null; @@ -29,17 +26,9 @@ export default function DownloadsScreen() { } function DownloadsList({ session }: { session: Session }) { - const { data, updatedAt } = useLiveDownloadsList(session); - - if (updatedAt === undefined) { - return ( - - - - ); - } + const { data, updatedAt, opacity } = useDownloadsList(session); - if (data.length === 0) { + if (updatedAt !== undefined && data.length === 0) { return ( @@ -57,8 +46,8 @@ function DownloadsList({ session }: { session: Session }) { } return ( - download.media.id} renderItem={({ item }) => ( @@ -74,11 +63,9 @@ type DownloadRowProps = { }; function DownloadRow({ session, download }: DownloadRowProps) { - const progress = useDownloadsStore( + const progress = useDownloads( (state) => state.downloadProgresses[download.media.id], ); - const removeDownload = useDownloadsStore((state) => state.removeDownload); - const cancelDownload = useDownloadsStore((state) => state.cancelDownload); const [isModalVisible, setIsModalVisible] = useState(false); const navigateToBook = () => { diff --git a/src/app/(tabs)/settings.tsx b/src/app/(app)/(tabs)/settings.tsx similarity index 68% rename from src/app/(tabs)/settings.tsx rename to src/app/(app)/(tabs)/settings.tsx index e13b0de..925a2a1 100644 --- a/src/app/(tabs)/settings.tsx +++ b/src/app/(app)/(tabs)/settings.tsx @@ -1,11 +1,12 @@ -import { useSessionStore } from "@/src/stores/session"; -import { useRouter } from "expo-router"; +import { useSession } from "@/src/stores/session"; +import { router } from "expo-router"; import { Button, StyleSheet, Text, View } from "react-native"; import colors from "tailwindcss/colors"; export default function SettingsScreen() { - const signOut = useSessionStore((state) => state.signOut); - const router = useRouter(); + const session = useSession((state) => state.session); + + if (!session) return null; return ( @@ -13,12 +14,10 @@ export default function SettingsScreen() { Settings, like your preferred playback speed, will be here. + You are signed in as: {session.email} + + ); +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.zinc[950], + height: "100%", + }, + chapterList: { + paddingHorizontal: 16, + }, + chapterRowContainer: { + height: chapterRowHeight, + }, + chapterButton: { + paddingVertical: 16, + borderColor: colors.zinc[600], + borderBottomWidth: StyleSheet.hairlineWidth, + }, + chapterRow: { + display: "flex", + flexDirection: "row", + alignItems: "center", + }, + chapterTitle: { + flex: 1, + fontSize: 16, + color: colors.zinc[100], + }, + chapterTime: { + color: colors.zinc[400], + }, + iconContainer: { + height: 16, + width: 24, + display: "flex", + alignItems: "center", + justifyContent: "center", + marginRight: 8, + }, +}); diff --git a/src/app/(app)/playback-rate.tsx b/src/app/(app)/playback-rate.tsx new file mode 100644 index 0000000..0ecf538 --- /dev/null +++ b/src/app/(app)/playback-rate.tsx @@ -0,0 +1,181 @@ +import Button from "@/src/components/Button"; +import useBackHandler from "@/src/hooks/use.back.handler"; +import { setPlaybackRate, usePlayer } from "@/src/stores/player"; +import { useSession } from "@/src/stores/session"; +import { formatPlaybackRate } from "@/src/utils/rate"; +import { secondsDisplay } from "@/src/utils/time"; +import Slider from "@react-native-community/slider"; +import { router } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import colors from "tailwindcss/colors"; +import { useShallow } from "zustand/react/shallow"; + +export default function PlaybackRateModal() { + useBackHandler(() => { + router.back(); + return true; + }); + + const session = useSession((state) => state.session); + + const { position, duration, playbackRate } = usePlayer( + useShallow(({ position, duration, playbackRate }) => ({ + position, + duration, + playbackRate, + })), + ); + const [displayPlaybackRate, setDisplayPlaybackRate] = useState(1.0); + + useEffect(() => { + setDisplayPlaybackRate(playbackRate); + }, [playbackRate]); + + const setPlaybackRateAndDisplay = useCallback( + (value: number) => { + if (!session) return; + setDisplayPlaybackRate(value); + setPlaybackRate(session, value); + }, + [session], + ); + + if (!session) return null; + + return ( + + + + {formatPlaybackRate(displayPlaybackRate)}× + + { + setDisplayPlaybackRate(parseFloat(value.toFixed(2))); + }} + onSlidingComplete={(value) => { + setPlaybackRateAndDisplay(parseFloat(value.toFixed(2))); + }} + /> + + + + + + + + + + + + Finish in{" "} + {secondsDisplay(Math.max(duration - position, 0) / displayPlaybackRate)} + + + + + ); +} + +type PlaybackRateButtonProps = { + rate: number; + active: boolean; + setPlaybackRateAndDisplay: (value: number) => void; +}; + +function PlaybackRateButton(props: PlaybackRateButtonProps) { + const { rate, active, setPlaybackRateAndDisplay } = props; + + return ( + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 32, + backgroundColor: colors.zinc[950], + height: "100%", + display: "flex", + justifyContent: "center", + gap: 16, + }, + title: { + color: colors.zinc[100], + margin: 16, + fontSize: 18, + textAlign: "center", + }, + rateButtonRow: { + display: "flex", + flexDirection: "row", + gap: 4, + }, + rateButton: { + backgroundColor: colors.zinc[800], + borderRadius: 999, + paddingHorizontal: 16, + flexGrow: 1, + }, + rateButtonActive: { + backgroundColor: colors.lime[400], + }, + rateButtonTextActive: { + color: colors.black, + }, + text: { + color: colors.zinc[100], + fontSize: 12, + }, + timeLeftText: { + color: colors.zinc[400], + textAlign: "center", + }, + closeButton: { + marginTop: 32, + }, + closeButtonText: { + color: colors.lime[400], + }, +}); diff --git a/src/app/(app)/sleep-timer.tsx b/src/app/(app)/sleep-timer.tsx new file mode 100644 index 0000000..76aa208 --- /dev/null +++ b/src/app/(app)/sleep-timer.tsx @@ -0,0 +1,185 @@ +import Button from "@/src/components/Button"; +import useBackHandler from "@/src/hooks/use.back.handler"; +import { + setSleepTimer, + setSleepTimerState, + usePlayer, +} from "@/src/stores/player"; +import Slider from "@react-native-community/slider"; +import { router } from "expo-router"; +import { useCallback, useEffect, useState } from "react"; +import { StyleSheet, Switch, Text, View } from "react-native"; +import colors from "tailwindcss/colors"; + +function formatSeconds(seconds: number) { + return Math.round(seconds / 60); +} + +export default function SleepTimerModal() { + useBackHandler(() => { + router.back(); + return true; + }); + + const { sleepTimer, sleepTimerEnabled } = usePlayer((state) => state); + + const [displaySleepTimerSeconds, setDisplaySleepTimerSeconds] = + useState(sleepTimer); + + useEffect(() => { + setDisplaySleepTimerSeconds(sleepTimer); + }, [sleepTimer]); + + const setSleepTimerSecondsAndDisplay = useCallback((value: number) => { + setDisplaySleepTimerSeconds(value); + setSleepTimer(value); + }, []); + + return ( + + + + {formatSeconds(displaySleepTimerSeconds)}m + + setDisplaySleepTimerSeconds(value)} + onSlidingComplete={(value) => setSleepTimerSecondsAndDisplay(value)} + /> + + + + + + + + + + + + Sleep Timer is {sleepTimerEnabled ? "enabled" : "disabled"} + + { + setSleepTimerState(value); + }} + /> + + + + + ); +} + +type SleepTimerSecondsButtonProps = { + seconds: number; + active: boolean; + setSleepTimerSecondsAndDisplay: (value: number) => void; +}; + +function SleepTimerSecondsButton(props: SleepTimerSecondsButtonProps) { + const { seconds, active, setSleepTimerSecondsAndDisplay } = props; + + return ( + + ); +} + +const styles = StyleSheet.create({ + container: { + padding: 32, + backgroundColor: colors.zinc[950], + height: "100%", + display: "flex", + justifyContent: "center", + gap: 16, + }, + title: { + color: colors.zinc[100], + margin: 16, + fontSize: 18, + textAlign: "center", + }, + sleepTimerButtonRow: { + display: "flex", + flexDirection: "row", + gap: 4, + }, + sleepTimerButton: { + backgroundColor: colors.zinc[800], + borderRadius: 999, + paddingHorizontal: 16, + flexGrow: 1, + }, + sleepTimerButtonActive: { + backgroundColor: colors.lime[400], + color: colors.black, + }, + sleepTimerButtonActiveText: { + color: colors.black, + }, + sleepTimerEnabledText: { + color: colors.zinc[400], + fontSize: 16, + }, + text: { + color: colors.zinc[100], + fontSize: 12, + }, + closeButton: { + marginTop: 32, + }, + closeButtonText: { + color: colors.lime[400], + }, +}); diff --git a/src/app/(tabs)/(library)/book/[id].tsx b/src/app/(tabs)/(library)/book/[id].tsx deleted file mode 100644 index 765091a..0000000 --- a/src/app/(tabs)/(library)/book/[id].tsx +++ /dev/null @@ -1,178 +0,0 @@ -import NamesList from "@/src/components/NamesList"; -import { Tile } from "@/src/components/Tiles"; -import { db } from "@/src/db/db"; -import * as schema from "@/src/db/schema"; -import { useLiveTablesQuery } from "@/src/hooks/use.live.tables.query"; -import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; -import { Session, useSessionStore } from "@/src/stores/session"; -import { formatPublished } from "@/src/utils/date"; -import { and, eq } from "drizzle-orm"; -import { Stack, useLocalSearchParams } from "expo-router"; -import { FlatList, StyleSheet, Text, View } from "react-native"; - -export default function BookDetails() { - const session = useSessionStore((state) => state.session); - const { id: bookId, title } = useLocalSearchParams<{ - id: string; - title: string; - }>(); - useSyncOnFocus(); - - if (!session) return null; - - return ( - <> - - - - ); -} - -function BookDetailsFlatList({ - bookId, - session, -}: { - bookId: string; - session: Session; -}) { - const { data: book } = useLiveTablesQuery( - db.query.books.findFirst({ - columns: { - id: true, - title: true, - published: true, - publishedFormat: true, - }, - where: and( - eq(schema.books.url, session.url), - eq(schema.books.id, bookId), - ), - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { id: true, name: true }, - with: { - person: { - columns: { id: true, name: true, thumbnails: true }, - }, - }, - }, - }, - }, - media: { - columns: { id: true, thumbnails: true }, - with: { - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { id: true, name: true }, - with: { - person: { - columns: { - id: true, - name: true, - thumbnails: true, - }, - }, - }, - }, - }, - }, - download: { - columns: { thumbnails: true }, - }, - }, - }, - }, - }), - ["books"], - ); - - if (!book) return null; - - return ( - item.id} - numColumns={2} - ListHeaderComponent={() =>
} - renderItem={({ item }) => { - return ; - }} - /> - ); -} - -type BookProp = { - title: string; - published: Date; - publishedFormat: "full" | "year_month" | "year"; - bookAuthors: { - author: { - id: string; - name: string; - person: { - id: string; - name: string; - thumbnails: schema.Thumbnails | null; - }; - }; - }[]; - media: { - id: string; - thumbnails: schema.Thumbnails | null; - mediaNarrators: { - narrator: { - id: string; - name: string; - person: { - id: string; - name: string; - thumbnails: schema.Thumbnails | null; - }; - }; - }[]; - download: { - thumbnails: schema.DownloadedThumbnails | null; - } | null; - }[]; -}; - -type HeaderProps = { - book: BookProp; -}; - -function Header({ book }: HeaderProps) { - return ( - - - ba.author.name)} - /> - {book.published && ( - - First published{" "} - {formatPublished(book.published, book.publishedFormat)} - - )} - - - Editions - - - ); -} - -const styles = StyleSheet.create({ - tile: { - padding: 8, - width: "50%", - marginBottom: 8, - }, -}); diff --git a/src/app/(tabs)/(library)/index.tsx b/src/app/(tabs)/(library)/index.tsx deleted file mode 100644 index 38feba2..0000000 --- a/src/app/(tabs)/(library)/index.tsx +++ /dev/null @@ -1,89 +0,0 @@ -import Loading from "@/src/components/Loading"; -import ScreenCentered from "@/src/components/ScreenCentered"; -import { MediaTile } from "@/src/components/Tiles"; -import { MediaForIndex, listMediaForIndex } from "@/src/db/library"; -import { syncDown } from "@/src/db/sync"; -import { useSessionStore } from "@/src/stores/session"; -import { useFocusEffect } from "expo-router"; -import { useCallback, useState } from "react"; -import { FlatList, StyleSheet, Text } from "react-native"; -import colors from "tailwindcss/colors"; - -export default function LibraryScreen() { - const session = useSessionStore((state) => state.session); - const [media, setMedia] = useState(); - const [error, setError] = useState(false); - - const loadMedia = useCallback(() => { - if (!session) return; - - listMediaForIndex(session) - .then(setMedia) - .catch((error) => { - console.error("Failed to load media:", error); - setError(true); - }); - }, [session]); - - useFocusEffect( - useCallback(() => { - console.log("index focused!"); - if (!session) return; - - // load what's in the DB right now - loadMedia(); - - // sync in background, then load again - // if network is down, we just ignore the error - syncDown(session) - .then(loadMedia) - .catch((error) => { - console.error("sync error:", error); - }); - - return () => { - console.log("index unfocused"); - }; - }, [loadMedia, session]), - ); - - if (media === undefined) { - return ( - - - - ); - } - - if (error) { - return ( - - Failed to load audiobooks! - - ); - } - - return ( - item.id} - numColumns={2} - renderItem={({ item }) => } - /> - ); -} - -const styles = StyleSheet.create({ - flatlist: { - padding: 8, - }, - tile: { - padding: 8, - width: "50%", - marginBottom: 8, - }, - error: { - color: colors.red[500], - }, -}); diff --git a/src/app/(tabs)/(library)/media/[id].tsx b/src/app/(tabs)/(library)/media/[id].tsx deleted file mode 100644 index 20eccb3..0000000 --- a/src/app/(tabs)/(library)/media/[id].tsx +++ /dev/null @@ -1,1253 +0,0 @@ -import Description from "@/src/components/Description"; -import IconButton from "@/src/components/IconButton"; -import Loading from "@/src/components/Loading"; -import NamesList from "@/src/components/NamesList"; -import ThumbnailImage from "@/src/components/ThumbnailImage"; -import { - BookTile, - MediaTile, - PersonTile, - SeriesBookTile, -} from "@/src/components/Tiles"; -import { db } from "@/src/db/db"; -import * as schema from "@/src/db/schema"; -import { useLiveTablesQuery } from "@/src/hooks/use.live.tables.query"; -import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; -import { useDownloadsStore } from "@/src/stores/downloads"; -import { useScreenStore } from "@/src/stores/screen"; -import { Session, useSessionStore } from "@/src/stores/session"; -import { useTrackPlayerStore } from "@/src/stores/trackPlayer"; -import { formatPublished } from "@/src/utils/date"; -import { durationDisplay } from "@/src/utils/time"; -import FontAwesome6 from "@expo/vector-icons/FontAwesome6"; -import { - and, - desc, - eq, - inArray, - isNull, - ne, - notInArray, - or, - sql, -} from "drizzle-orm"; -import { useLiveQuery } from "drizzle-orm/expo-sqlite"; -import { Stack, useLocalSearchParams, useRouter } from "expo-router"; -import { useEffect, useState } from "react"; -import { FlatList, Pressable, Text, View } from "react-native"; -import colors from "tailwindcss/colors"; - -export default function MediaDetails() { - const session = useSessionStore((state) => state.session); - const { id: mediaId, title } = useLocalSearchParams<{ - id: string; - title: string; - }>(); - useSyncOnFocus(); - - if (!session) return null; - - return ( - <> - - - - ); -} - -type HeaderSection = { - id: string; - type: "header"; - mediaId: string; -}; - -type ActionBarSection = { - id: string; - type: "actionBar"; - mediaId: string; -}; - -type MediaDescriptionSection = { - id: string; - type: "mediaDescription"; - mediaId: string; -}; - -type AuthorsAndNarratorsSection = { - id: string; - type: "authorsAndNarrators"; - mediaId: string; -}; - -type OtherEditionsSection = { - id: string; - type: "otherEditions"; - bookId: string; - withoutMediaId: string; -}; - -type OtherBooksInSeriesSection = { - id: string; - type: "otherBooksInSeries"; - seriesId: string; -}; - -type OtherBooksByAuthorSection = { - id: string; - type: "otherBooksByAuthor"; - authorId: string; - withoutBookId: string; - withoutSeriesIds: string[]; -}; - -type OtherMediaByNarratorSection = { - id: string; - type: "otherMediaByNarrator"; - narratorId: string; - withoutMediaId: string; - withoutSeriesIds: string[]; - withoutAuthorIds: string[]; -}; - -type Section = - | HeaderSection - | ActionBarSection - | MediaDescriptionSection - | AuthorsAndNarratorsSection - | OtherEditionsSection - | OtherBooksInSeriesSection - | OtherBooksByAuthorSection - | OtherMediaByNarratorSection; - -function useSections(mediaId: string, session: Session) { - const { data: media } = useLiveQuery( - db.query.media.findFirst({ - columns: { bookId: true }, - where: and( - eq(schema.media.url, session.url), - eq(schema.media.id, mediaId), - ), - with: { - book: { - columns: {}, - with: { - bookAuthors: { - columns: { authorId: true }, - }, - seriesBooks: { - columns: { seriesId: true }, - }, - }, - }, - mediaNarrators: { - columns: { narratorId: true }, - }, - }, - }), - ); - - const [sections, setSections] = useState(); - - useEffect(() => { - if (!media) return; - - const collectedIds = { - mediaId, - bookId: media.bookId, - authorIds: media.book.bookAuthors.map((ba) => ba.authorId), - seriesIds: media.book.seriesBooks.map((sb) => sb.seriesId), - narratorIds: media.mediaNarrators.map((mn) => mn.narratorId), - }; - - const sections: Section[] = [ - { id: `header-${mediaId}`, type: "header", mediaId }, - { id: `actions-${mediaId}`, type: "actionBar", mediaId }, - { - id: `description-${mediaId}`, - type: "mediaDescription", - mediaId, - }, - { - id: `authors-narrators-${mediaId}`, - type: "authorsAndNarrators", - mediaId, - }, - { - id: `editions-${mediaId}`, - type: "otherEditions", - bookId: collectedIds.bookId, - withoutMediaId: mediaId, - }, - ...collectedIds.seriesIds.map( - (seriesId): OtherBooksInSeriesSection => ({ - id: `books-in-series-${seriesId}`, - type: "otherBooksInSeries", - seriesId, - }), - ), - ...collectedIds.authorIds.map( - (authorId): OtherBooksByAuthorSection => ({ - id: `other-books-${authorId}`, - type: "otherBooksByAuthor", - authorId, - withoutBookId: collectedIds.bookId, - withoutSeriesIds: collectedIds.seriesIds, - }), - ), - ...collectedIds.narratorIds.map( - (narratorId): OtherMediaByNarratorSection => ({ - id: `other-media-${narratorId}`, - type: "otherMediaByNarrator", - narratorId, - withoutMediaId: mediaId, - withoutSeriesIds: collectedIds.seriesIds, - withoutAuthorIds: collectedIds.authorIds, - }), - ), - ]; - setSections(sections); - }, [media, mediaId, session]); - - return sections; -} - -function MediaDetailsFlatList({ - session, - mediaId, -}: { - session: Session; - mediaId: string; -}) { - const sections = useSections(mediaId, session); - - if (!sections) return null; - - return ( - item.id} - initialNumToRender={3} - ListHeaderComponent={} - ListFooterComponent={} - renderItem={({ item }) => { - switch (item.type) { - case "header": - return
; - case "actionBar": - return ; - case "mediaDescription": - return ( - - ); - case "authorsAndNarrators": - return ( - - ); - case "otherEditions": - return ( - - ); - case "otherBooksInSeries": - return ( - - ); - case "otherBooksByAuthor": - return ( - - ); - case "otherMediaByNarrator": - return ( - - ); - default: - // can't happen - console.error("unknown section type:", item); - return null; - } - }} - /> - ); -} - -function Header({ mediaId, session }: { mediaId: string; session: Session }) { - const { data: media } = useLiveQuery( - db.query.media.findFirst({ - columns: { - fullCast: true, - abridged: true, - thumbnails: true, - duration: true, - }, - where: and( - eq(schema.media.url, session.url), - eq(schema.media.id, mediaId), - ), - with: { - download: { - columns: { thumbnails: true }, - }, - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - book: { - columns: { title: true }, - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - seriesBooks: { - columns: { bookNumber: true }, - with: { series: { columns: { name: true } } }, - }, - }, - }, - }, - }), - ); - - if (!media) return null; - - return ( - - - - - {media.book.title} - - {media.book.seriesBooks.length !== 0 && ( - `${sb.series.name} #${sb.bookNumber}`, - )} - className="text-lg text-zinc-100 leading-tight" - /> - )} - ba.author.name)} - className="text-lg text-zinc-300 leading-tight" - /> - {media.mediaNarrators.length > 0 && ( - mn.narrator.name)} - className="text-zinc-400 leading-tight" - /> - )} - {media.mediaNarrators.length === 0 && media.fullCast && ( - - Read by a full cast - - )} - - {media.duration && ( - - - {durationDisplay(media.duration)} {media.abridged && "(abridged)"} - - - )} - - ); -} - -function ActionBar({ - mediaId, - session, -}: { - mediaId: string; - session: Session; -}) { - const progress = useDownloadsStore( - (state) => state.downloadProgresses[mediaId], - ); - const { loadMedia: loadMediaIntoPlayer, requestExpandPlayer } = - useTrackPlayerStore((state) => state); - const { startDownload } = useDownloadsStore(); - const router = useRouter(); - - const { data: media } = useLiveTablesQuery( - db.query.media.findFirst({ - columns: { - id: true, - thumbnails: true, - mp4Path: true, - }, - where: and( - eq(schema.media.url, session.url), - eq(schema.media.id, mediaId), - ), - with: { - download: { - columns: { status: true }, - }, - }, - }), - ["media", "downloads"], - ); - - if (!media) return null; - - if (progress) { - return ( - - router.navigate("/downloads")} - > - - - - - - Downloading... - - - - - ); - } else if (media.download && media.download.status !== "error") { - return ( - - - - { - loadMediaIntoPlayer(session, media.id); - requestExpandPlayer(); - }} - > - - Play - - - - - - You have this audiobook downloaded, it will play from your device and - not require an internet connection. - - - ); - } else { - return ( - - - - { - loadMediaIntoPlayer(session, media.id); - requestExpandPlayer(); - }} - > - - Stream - - - - - { - if (!media.mp4Path) return; - startDownload( - session, - media.id, - media.mp4Path, - media.thumbnails, - ); - router.navigate("/downloads"); - }} - > - - Download - - - - - - Playing this audiobook will stream it and require an internet - connection and may use your data plan. - - - ); - } -} - -function MediaDescription({ - mediaId, - session, -}: { - mediaId: string; - session: Session; -}) { - const { data: media } = useLiveQuery( - db.query.media.findFirst({ - columns: { - description: true, - published: true, - publishedFormat: true, - publisher: true, - notes: true, - }, - with: { - book: { - columns: { published: true, publishedFormat: true }, - }, - }, - where: and( - eq(schema.media.url, session.url), - eq(schema.media.id, mediaId), - ), - }), - ); - - if (!media?.description) return null; - - return ( - - - - {media.book.published && ( - - First published{" "} - {formatPublished(media.book.published, media.book.publishedFormat)} - - )} - {media.published && ( - - This edition published{" "} - {formatPublished(media.published, media.publishedFormat)} - - )} - {media.publisher && ( - by {media.publisher} - )} - {media.notes && ( - Note: {media.notes} - )} - - - ); -} - -function AuthorsAndNarrators({ - mediaId, - session, -}: { - mediaId: string; - session: Session; -}) { - const { screenWidth } = useScreenStore((state) => state); - const { data: media } = useLiveQuery( - db.query.media.findFirst({ - columns: {}, - where: and( - eq(schema.media.url, session.url), - eq(schema.media.id, mediaId), - ), - with: { - book: { - columns: {}, - with: { - bookAuthors: { - columns: { id: true }, - with: { - author: { - columns: { name: true }, - with: { - person: { - columns: { id: true, name: true, thumbnails: true }, - }, - }, - }, - }, - }, - }, - }, - mediaNarrators: { - columns: { id: true }, - with: { - narrator: { - columns: { name: true }, - with: { - person: { columns: { id: true, name: true, thumbnails: true } }, - }, - }, - }, - }, - }, - }), - ); - - const [authorSet, setAuthorSet] = useState>(new Set()); - const [narratorSet, setNarratorSet] = useState>( - new Set(), - ); - - useEffect(() => { - if (!media) return; - - const newAuthorSet = new Set(); - for (const ba of media.book.bookAuthors) { - newAuthorSet.add(ba.author.person.id); - } - setAuthorSet(newAuthorSet); - - const newNarratorSet = new Set(); - for (const mn of media.mediaNarrators) { - newNarratorSet.add(mn.narrator.person.id); - } - setNarratorSet(newNarratorSet); - }, [media]); - - if (!media) return null; - - return ( - - - Author{media.book.bookAuthors.length > 1 && "s"} & Narrator - {media.mediaNarrators.length > 1 && "s"} - - item.id} - horizontal={true} - renderItem={({ item }) => { - if ("author" in item) { - const label = narratorSet.has(item.author.person.id) - ? "Author & Narrator" - : "Author"; - return ( - - - - ); - } - - if ("narrator" in item) { - // skip if this person is also an author, as they were already rendered - if (authorSet.has(item.narrator.person.id)) return null; - - return ( - - - - ); - } - - // can't happen: - console.error("unknown item:", item); - return null; - }} - /> - - ); -} - -function OtherEditions({ - bookId, - session, - withoutMediaId, -}: { - bookId: string; - session: Session; - withoutMediaId: string; -}) { - const { screenWidth } = useScreenStore((state) => state); - const { data: mediaIds } = useLiveQuery( - db - .select({ id: schema.media.id }) - .from(schema.media) - .limit(10) - .where( - and( - eq(schema.media.url, session.url), - eq(schema.media.bookId, bookId), - ne(schema.media.id, withoutMediaId), - ), - ), - ); - - const { data: media } = useLiveQuery( - db.query.media.findMany({ - columns: { id: true, thumbnails: true }, - where: and( - eq(schema.media.url, session.url), - inArray( - schema.media.id, - mediaIds.map((media) => media.id), - ), - ), - orderBy: desc(schema.media.published), - with: { - download: { - columns: { thumbnails: true }, - }, - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - book: { - columns: { id: true, title: true }, - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - }, - }, - }, - }), - [mediaIds], - ); - - const router = useRouter(); - - if (media.length === 0) return null; - - return ( - - { - router.navigate({ - pathname: "/book/[id]", - params: { id: media[0].book.id }, - }); - }} - > - - - Other Editions - - - - - - - item.id} - horizontal={true} - renderItem={({ item }) => { - return ( - - ); - }} - /> - - ); -} - -function OtherBooksInSeries({ - seriesId, - session, -}: { - seriesId: string; - session: Session; -}) { - const { screenWidth } = useScreenStore((state) => state); - const { data: series } = useLiveQuery( - db.query.series.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(schema.series.url, session.url), - eq(schema.series.id, seriesId), - ), - with: { - seriesBooks: { - columns: { id: true, bookNumber: true }, - orderBy: sql`CAST(book_number AS FLOAT)`, - limit: 10, - with: { - book: { - columns: { id: true, title: true }, - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - media: { - columns: { id: true, thumbnails: true }, - with: { - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - download: { - columns: { thumbnails: true }, - }, - }, - }, - }, - }, - }, - }, - }, - }), - ); - - const router = useRouter(); - - if (!series) return null; - - return ( - - { - router.navigate({ - pathname: "/series/[id]", - params: { id: series.id }, - }); - }} - > - - - {series.name} - - - - - - - item.id} - horizontal={true} - renderItem={({ item }) => { - return ( - - ); - }} - /> - - ); -} - -function OtherBooksByAuthor({ - authorId, - session, - withoutBookId, - withoutSeriesIds, -}: { - authorId: string; - session: Session; - withoutBookId: string; - withoutSeriesIds: string[]; -}) { - const { screenWidth } = useScreenStore((state) => state); - const { data: booksIds } = useLiveQuery( - db - .selectDistinct({ id: schema.books.id }) - .from(schema.authors) - .innerJoin( - schema.bookAuthors, - and( - eq(schema.authors.url, schema.bookAuthors.url), - eq(schema.authors.id, schema.bookAuthors.authorId), - ), - ) - .innerJoin( - schema.books, - and( - eq(schema.bookAuthors.url, schema.books.url), - eq(schema.bookAuthors.bookId, schema.books.id), - ), - ) - .leftJoin( - schema.seriesBooks, - and( - eq(schema.books.url, schema.seriesBooks.url), - eq(schema.books.id, schema.seriesBooks.bookId), - ), - ) - .limit(10) - .where( - and( - eq(schema.authors.url, session.url), - eq(schema.authors.id, authorId), - ne(schema.books.id, withoutBookId), - or( - isNull(schema.seriesBooks.seriesId), - notInArray(schema.seriesBooks.seriesId, withoutSeriesIds), - ), - ), - ), - ); - - const { data: author } = useLiveQuery( - db.query.authors.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(schema.authors.url, session.url), - eq(schema.authors.id, authorId), - ), - with: { - person: { - columns: { id: true, name: true }, - }, - }, - }), - ); - - const { data: books } = useLiveQuery( - db.query.books.findMany({ - columns: { id: true, title: true }, - where: and( - eq(schema.books.url, session.url), - inArray( - schema.books.id, - booksIds.map((book) => book.id), - ), - ), - orderBy: desc(schema.books.published), - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - media: { - columns: { id: true, thumbnails: true }, - with: { - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - download: { - columns: { thumbnails: true }, - }, - }, - }, - }, - }), - [booksIds], - ); - - const router = useRouter(); - - if (!author) return null; - - if (books.length === 0) return null; - - return ( - - { - router.navigate({ - pathname: "/person/[id]", - params: { id: author.person.id }, - }); - }} - > - - - More by {author.name} - - - - - - - item.id} - horizontal={true} - renderItem={({ item }) => { - return ( - - ); - }} - /> - - ); -} - -function OtherMediaByNarrator({ - narratorId, - session, - withoutMediaId, - withoutSeriesIds, - withoutAuthorIds, -}: { - narratorId: string; - session: Session; - withoutMediaId: string; - withoutSeriesIds: string[]; - withoutAuthorIds: string[]; -}) { - const { screenWidth } = useScreenStore((state) => state); - const { data: mediaIds } = useLiveQuery( - db - .selectDistinct({ id: schema.media.id }) - .from(schema.narrators) - .innerJoin( - schema.mediaNarrators, - and( - eq(schema.narrators.url, schema.mediaNarrators.url), - eq(schema.narrators.id, schema.mediaNarrators.narratorId), - ), - ) - .innerJoin( - schema.media, - and( - eq(schema.mediaNarrators.url, schema.media.url), - eq(schema.mediaNarrators.mediaId, schema.media.id), - ), - ) - .innerJoin( - schema.books, - and( - eq(schema.media.url, schema.books.url), - eq(schema.media.bookId, schema.books.id), - ), - ) - .innerJoin( - schema.bookAuthors, - and( - eq(schema.books.url, schema.bookAuthors.url), - eq(schema.books.id, schema.bookAuthors.bookId), - ), - ) - .leftJoin( - schema.seriesBooks, - and( - eq(schema.books.url, schema.seriesBooks.url), - eq(schema.books.id, schema.seriesBooks.bookId), - ), - ) - .limit(10) - .where( - and( - eq(schema.narrators.url, session.url), - eq(schema.narrators.id, narratorId), - ne(schema.media.id, withoutMediaId), - notInArray(schema.bookAuthors.authorId, withoutAuthorIds), - or( - isNull(schema.seriesBooks.seriesId), - notInArray(schema.seriesBooks.seriesId, withoutSeriesIds), - ), - ), - ), - ); - - const { data: narrator } = useLiveQuery( - db.query.narrators.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(schema.narrators.url, session.url), - eq(schema.narrators.id, narratorId), - ), - with: { - person: { - columns: { id: true, name: true }, - }, - }, - }), - ); - - const { data: media } = useLiveQuery( - db.query.media.findMany({ - columns: { id: true, thumbnails: true }, - where: and( - eq(schema.media.url, session.url), - inArray( - schema.media.id, - mediaIds.map((media) => media.id), - ), - ), - orderBy: desc(schema.media.published), - with: { - download: { - columns: { thumbnails: true }, - }, - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - book: { - columns: { id: true, title: true }, - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - }, - }, - }, - }), - [mediaIds], - ); - - const router = useRouter(); - - if (!narrator) return null; - - if (media.length === 0) return null; - - return ( - - { - router.navigate({ - pathname: "/person/[id]", - params: { id: narrator.person.id }, - }); - }} - > - - - More read by {narrator.name} - - - - - - - item.id} - horizontal={true} - renderItem={({ item }) => { - return ( - - ); - }} - /> - - ); -} diff --git a/src/app/(tabs)/(library)/person/[id].tsx b/src/app/(tabs)/(library)/person/[id].tsx deleted file mode 100644 index 904194d..0000000 --- a/src/app/(tabs)/(library)/person/[id].tsx +++ /dev/null @@ -1,472 +0,0 @@ -import Description from "@/src/components/Description"; -import ThumbnailImage from "@/src/components/ThumbnailImage"; -import { BookTile, MediaTile } from "@/src/components/Tiles"; -import { db } from "@/src/db/db"; -import * as schema from "@/src/db/schema"; -import { useLiveTablesQuery } from "@/src/hooks/use.live.tables.query"; -import useSyncOnFocus from "@/src/hooks/use.sync.on.focus"; -import { Session, useSessionStore } from "@/src/stores/session"; -import { and, desc, eq, inArray } from "drizzle-orm"; -import { useLiveQuery } from "drizzle-orm/expo-sqlite"; -import { Stack, useLocalSearchParams } from "expo-router"; -import { useEffect, useState } from "react"; -import { FlatList, StyleSheet, Text, View } from "react-native"; - -export default function PersonDetails() { - const session = useSessionStore((state) => state.session); - const { id: personId, title } = useLocalSearchParams<{ - id: string; - title: string; - }>(); - useSyncOnFocus(); - - if (!session) return null; - - return ( - <> - - - - ); -} - -type HeaderSection = { - id: string; - type: "header"; - personId: string; -}; - -type PersonDescriptionSection = { - id: string; - type: "personDescription"; - personId: string; -}; - -type BooksByAuthorSection = { - id: string; - type: "booksByAuthor"; - authorId: string; -}; - -type MediaByNarratorSection = { - id: string; - type: "mediaByNarrator"; - narratorId: string; -}; - -type Section = - | HeaderSection - | PersonDescriptionSection - | BooksByAuthorSection - | MediaByNarratorSection; - -function useSections(personId: string, session: Session) { - const { data: person } = useLiveTablesQuery( - db.query.people.findFirst({ - columns: {}, - where: and( - eq(schema.people.url, session.url), - eq(schema.people.id, personId), - ), - with: { - authors: { - columns: { id: true }, - }, - narrators: { - columns: { id: true }, - }, - }, - }), - ["people", "authors", "narrators"], - ); - - const [sections, setSections] = useState(); - - useEffect(() => { - if (!person) return; - - const collectedIds = { - personId, - authorIds: person.authors.map((a) => a.id), - narratorIds: person.narrators.map((n) => n.id), - }; - - const sections: Section[] = [ - { id: `header-${personId}`, type: "header", personId }, - { - id: `description-${personId}`, - type: "personDescription", - personId, - }, - ...collectedIds.authorIds.map( - (authorId): BooksByAuthorSection => ({ - id: `books-${authorId}`, - type: "booksByAuthor", - authorId, - }), - ), - ...collectedIds.narratorIds.map( - (narratorId): MediaByNarratorSection => ({ - id: `media-${narratorId}`, - type: "mediaByNarrator", - narratorId, - }), - ), - ]; - setSections(sections); - }, [person, personId, session]); - - return sections; -} - -function PersonDetailsFlatList({ - session, - personId, -}: { - session: Session; - personId: string; -}) { - const sections = useSections(personId, session); - - if (!sections) return null; - - return ( - item.id} - initialNumToRender={2} - ListHeaderComponent={} - ListFooterComponent={} - renderItem={({ item }) => { - switch (item.type) { - case "header": - return
; - case "personDescription": - return ( - - ); - case "booksByAuthor": - return ; - case "mediaByNarrator": - return ( - - ); - default: - // can't happen - console.error("unknown section type:", item); - return null; - } - }} - /> - ); -} - -function Header({ personId, session }: { personId: string; session: Session }) { - const { data: person } = useLiveQuery( - db.query.people.findFirst({ - columns: { - name: true, - thumbnails: true, - }, - where: and( - eq(schema.people.url, session.url), - eq(schema.people.id, personId), - ), - }), - ); - - if (!person) return null; - - return ( - - ); -} - -function PersonDescription({ - personId, - session, -}: { - personId: string; - session: Session; -}) { - const { data: person } = useLiveQuery( - db.query.people.findFirst({ - columns: { - description: true, - }, - where: and( - eq(schema.people.url, session.url), - eq(schema.people.id, personId), - ), - }), - ); - - if (!person?.description) return null; - - return ( - - - - ); -} - -function BooksByAuthor({ - authorId, - session, -}: { - authorId: string; - session: Session; -}) { - const { data: booksIds } = useLiveQuery( - db - .selectDistinct({ id: schema.books.id }) - .from(schema.authors) - .innerJoin( - schema.bookAuthors, - and( - eq(schema.authors.url, schema.bookAuthors.url), - eq(schema.authors.id, schema.bookAuthors.authorId), - ), - ) - .innerJoin( - schema.books, - and( - eq(schema.bookAuthors.url, schema.books.url), - eq(schema.bookAuthors.bookId, schema.books.id), - ), - ) - .where( - and( - eq(schema.authors.url, session.url), - eq(schema.authors.id, authorId), - ), - ), - ); - - const { data: author } = useLiveQuery( - db.query.authors.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(schema.authors.url, session.url), - eq(schema.authors.id, authorId), - ), - with: { - person: { - columns: { id: true, name: true }, - }, - }, - }), - ); - - const { data: books } = useLiveQuery( - db.query.books.findMany({ - columns: { id: true, title: true }, - where: and( - eq(schema.books.url, session.url), - inArray( - schema.books.id, - booksIds.map((book) => book.id), - ), - ), - orderBy: desc(schema.books.published), - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - media: { - columns: { id: true, thumbnails: true }, - with: { - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - download: { - columns: { thumbnails: true }, - }, - }, - }, - }, - }), - [booksIds], - ); - - if (!author) return null; - - if (books.length === 0) return null; - - return ( - - - {author.name === author.person.name - ? `By ${author.name}` - : `As ${author.name}`} - - - item.id} - numColumns={2} - renderItem={({ item }) => { - return ; - }} - /> - - ); -} - -function MediaByNarrator({ - narratorId, - session, -}: { - narratorId: string; - session: Session; -}) { - const { data: mediaIds } = useLiveQuery( - db - .selectDistinct({ id: schema.media.id }) - .from(schema.narrators) - .innerJoin( - schema.mediaNarrators, - and( - eq(schema.narrators.url, schema.mediaNarrators.url), - eq(schema.narrators.id, schema.mediaNarrators.narratorId), - ), - ) - .innerJoin( - schema.media, - and( - eq(schema.mediaNarrators.url, schema.media.url), - eq(schema.mediaNarrators.mediaId, schema.media.id), - ), - ) - .innerJoin( - schema.books, - and( - eq(schema.media.url, schema.books.url), - eq(schema.media.bookId, schema.books.id), - ), - ) - .where( - and( - eq(schema.narrators.url, session.url), - eq(schema.narrators.id, narratorId), - ), - ), - ); - - const { data: narrator } = useLiveQuery( - db.query.narrators.findFirst({ - columns: { id: true, name: true }, - where: and( - eq(schema.narrators.url, session.url), - eq(schema.narrators.id, narratorId), - ), - with: { - person: { - columns: { id: true, name: true }, - }, - }, - }), - ); - - const { data: media } = useLiveQuery( - db.query.media.findMany({ - columns: { id: true, thumbnails: true }, - where: and( - eq(schema.media.url, session.url), - inArray( - schema.media.id, - mediaIds.map((media) => media.id), - ), - ), - orderBy: desc(schema.media.published), - with: { - mediaNarrators: { - columns: {}, - with: { - narrator: { - columns: { name: true }, - }, - }, - }, - download: { - columns: { thumbnails: true }, - }, - book: { - columns: { id: true, title: true }, - with: { - bookAuthors: { - columns: {}, - with: { - author: { - columns: { name: true }, - }, - }, - }, - }, - }, - }, - }), - [mediaIds], - ); - - if (!narrator) return null; - - if (media.length === 0) return null; - - return ( - - - {narrator.name === narrator.person.name - ? `Read by ${narrator.name}` - : `Read as ${narrator.name}`} - - - item.id} - numColumns={2} - renderItem={({ item }) => { - return ; - }} - /> - - ); -} - -const styles = StyleSheet.create({ - tile: { - padding: 8, - width: "50%", - marginBottom: 8, - }, -}); diff --git a/src/app/+native-intent.tsx b/src/app/+native-intent.tsx index 029ced6..0fced04 100644 --- a/src/app/+native-intent.tsx +++ b/src/app/+native-intent.tsx @@ -1,4 +1,4 @@ -import { useTrackPlayerStore } from "@/src/stores/trackPlayer"; +import { requestExpandPlayer } from "@/src/stores/player"; type PathArgs = { path: string; @@ -7,7 +7,7 @@ type PathArgs = { export function redirectSystemPath({ path }: PathArgs) { if (path === "trackplayer://notification.click") { - useTrackPlayerStore.getState().requestExpandPlayer(); + requestExpandPlayer(); return null; } else { return path; diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index ceb1adf..af93c4d 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -1,6 +1,7 @@ import "@/assets/global.css"; import Loading from "@/src/components/Loading"; import MeasureScreenHeight from "@/src/components/MeasureScreenHeight"; +import ScreenCentered from "@/src/components/ScreenCentered"; import { expoDb } from "@/src/db/db"; import { useAppBoot } from "@/src/hooks/use.app.boot"; import { ThemeProvider } from "@react-navigation/native"; @@ -27,7 +28,7 @@ const Theme = { }, }; -export default function App() { +export default function RootStackLayout() { useEffect(() => { if (Platform.OS === "android") { NavigationBar.setBackgroundColorAsync(colors.transparent); @@ -60,15 +61,20 @@ function Root() { ); } - return isReady ? ( + if (!isReady) { + return ( + + + + ); + } + + return ( - + + - ) : ( - - - ); } diff --git a/src/app/sign-in.tsx b/src/app/sign-in.tsx index 16c7e9b..834bab2 100644 --- a/src/app/sign-in.tsx +++ b/src/app/sign-in.tsx @@ -1,15 +1,13 @@ import Logo from "@/assets/images/logo.svg"; import Loading from "@/src/components/Loading"; -import { useSessionStore } from "@/src/stores/session"; +import { clearError, signIn, useSession } from "@/src/stores/session"; import { Redirect } from "expo-router"; import { useState } from "react"; import { Button, Text, TextInput, View } from "react-native"; import colors from "tailwindcss/colors"; export default function SignInScreen() { - const { session, error, isLoading, signIn, clearError } = useSessionStore( - (state) => state, - ); + const { session, error, isLoading } = useSession((state) => state); const [email, setEmail] = useState(session?.email || ""); const [host, setHost] = useState(session?.url || ""); const [password, setPassword] = useState(""); diff --git a/src/app/sign-out.tsx b/src/app/sign-out.tsx new file mode 100644 index 0000000..cbeceb0 --- /dev/null +++ b/src/app/sign-out.tsx @@ -0,0 +1,22 @@ +import Loading from "@/src/components/Loading"; +import ScreenCentered from "@/src/components/ScreenCentered"; +import { unloadPlayer } from "@/src/stores/player"; +import { signOut } from "@/src/stores/session"; +import { router } from "expo-router"; +import { useEffect } from "react"; + +export default function SignOutScreen() { + useEffect(() => { + (async function () { + await unloadPlayer(); + await signOut(); + router.navigate("/sign-in"); + })(); + }); + + return ( + + + + ); +} diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 0000000..46c1641 --- /dev/null +++ b/src/components/Button.tsx @@ -0,0 +1,36 @@ +import { + StyleProp, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from "react-native"; + +type ButtonProps = { + size: number; + style?: StyleProp; + onPress: () => void; + children?: React.ReactNode; +}; + +export default function Button(props: ButtonProps) { + const { size, style, onPress, children } = props; + + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + display: "flex", + alignItems: "center", + justifyContent: "center", + // backgroundColor: "orange", + }, +}); diff --git a/src/components/Description.tsx b/src/components/Description.tsx index 99a7a75..5e24927 100644 --- a/src/components/Description.tsx +++ b/src/components/Description.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; -import { Image, StyleSheet, Text, TouchableOpacity, View } from "react-native"; +import { Image, StyleSheet, Text, View } from "react-native"; import Markdown from "react-native-markdown-display"; import colors from "tailwindcss/colors"; +import Button from "./Button"; export default function Description({ description }: { description: string }) { const [expanded, setExpanded] = useState(false); @@ -23,9 +24,13 @@ export default function Description({ description }: { description: string }) { /> )} - setExpanded(!expanded)}> + ); } diff --git a/src/components/IconButton.tsx b/src/components/IconButton.tsx index b7d44e6..4a39d82 100644 --- a/src/components/IconButton.tsx +++ b/src/components/IconButton.tsx @@ -1,27 +1,33 @@ import FontAwesome6 from "@expo/vector-icons/FontAwesome6"; -import { StyleSheet, TouchableOpacity, View } from "react-native"; +import { + StyleProp, + StyleSheet, + TouchableOpacity, + View, + ViewStyle, +} from "react-native"; type IconButtonProps = { size: number; icon: string; color: string; + style?: StyleProp; onPress: () => void; - padding?: number; + onLongPress?: () => void; children?: React.ReactNode; }; export default function IconButton(props: IconButtonProps) { - const { size, icon, color, onPress, padding = size / 2, children } = props; + const { size, icon, color, style, onPress, onLongPress, children } = props; return ( - - - + + + {/* NOTE: for some reason the some icons get cut off when height and + width is exactly equal to the icon size */} + + + {children} @@ -33,5 +39,6 @@ const styles = StyleSheet.create({ display: "flex", alignItems: "center", justifyContent: "center", + // backgroundColor: "purple", }, }); diff --git a/src/components/MeasureScreenHeight.tsx b/src/components/MeasureScreenHeight.tsx index c86d65f..6e01c62 100644 --- a/src/components/MeasureScreenHeight.tsx +++ b/src/components/MeasureScreenHeight.tsx @@ -1,12 +1,10 @@ +import { setDimensions } from "@/src/stores/screen"; import { StyleSheet, View } from "react-native"; -import { useScreenStore } from "../stores/screen"; // This is a workaround due to Android screen height currently being broken: // https://github.com/facebook/react-native/issues/47080 export default function MeasureScreenHeight() { - const { setDimensions } = useScreenStore((state) => state); - return ( { diff --git a/src/components/PlayButton.tsx b/src/components/PlayButton.tsx index a4e13af..7c1a778 100644 --- a/src/components/PlayButton.tsx +++ b/src/components/PlayButton.tsx @@ -1,8 +1,6 @@ -import { StyleSheet, View } from "react-native"; -import TrackPlayer, { - State, - usePlaybackState, -} from "react-native-track-player"; +import { playOrPause, usePlayer } from "@/src/stores/player"; +import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; +import { State } from "react-native-track-player"; import { useDebounce } from "use-debounce"; import IconButton from "./IconButton"; import Loading from "./Loading"; @@ -10,35 +8,33 @@ import Loading from "./Loading"; type PlayButtonProps = { size: number; color: string; - padding?: number; + style?: StyleProp; }; export default function PlayButton(props: PlayButtonProps) { - const { size, color, padding = size / 2 } = props; - const { state } = usePlaybackState(); + const { size, color, style } = props; + const state = usePlayer((state) => state.state); const [debouncedState] = useDebounce(state, 50); const icon = stateIcon(debouncedState); if (!debouncedState || !icon || icon === "spinner") { return ( - - + + {/* NOTE: this sizing has to match the sizing of the IconButton component */} + + + ); } return ( ); } @@ -71,25 +67,3 @@ function stateIcon(state: State | undefined): string | undefined { } return; } - -function stateAction(state: State | undefined): () => void { - switch (state) { - case State.Paused: - case State.Stopped: - case State.Ready: - case State.Error: - return () => { - TrackPlayer.play(); - }; - case State.Playing: - return () => { - TrackPlayer.pause(); - }; - case State.Buffering: - case State.Loading: - case State.None: - case State.Ended: - return () => {}; - } - return () => {}; -} diff --git a/src/components/PlayerButtons.tsx b/src/components/PlayerButtons.tsx index 2c22e66..99533e5 100644 --- a/src/components/PlayerButtons.tsx +++ b/src/components/PlayerButtons.tsx @@ -18,7 +18,7 @@ export default function PlayerButtons() { size={32} color={colors.zinc[100]} /> - + state.chapterState); + + if (!chapterState) return null; + + return ( + <> + + + + + + {/* maybe this is a bit much... */} + {/* + + {secondsDisplay(position - chapterState.currentChapter.startTime)} + + + - + {secondsDisplay( + ((chapterState.currentChapter.endTime || duration) - position) / + playbackRate, + )} + + */} + + ); +} + +const styles = StyleSheet.create({ + container: { + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginHorizontal: -12, // or -20 if the below is used + // backgroundColor: colors.zinc[800], + // borderRadius: 999, + // paddingHorizontal: 8, + }, + chapterText: { + color: colors.zinc[100], + }, + timeDisplayContainer: { + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginTop: -8, + marginHorizontal: -2, + }, + timeDisplayText: { + fontSize: 12, + color: colors.zinc[600], + }, +}); diff --git a/src/components/PlayerProgressBar.tsx b/src/components/PlayerProgressBar.tsx index 3939647..f7414c9 100644 --- a/src/components/PlayerProgressBar.tsx +++ b/src/components/PlayerProgressBar.tsx @@ -1,24 +1,34 @@ -import { useTrackPlayerStore } from "@/src/stores/trackPlayer"; +import { usePlayer } from "@/src/stores/player"; import { secondsDisplay } from "@/src/utils/time"; import { StyleSheet, Text, View } from "react-native"; import colors from "tailwindcss/colors"; +import { useShallow } from "zustand/react/shallow"; export default function PlayerProgressBar() { - const { position, duration } = useTrackPlayerStore((state) => state); + const { position, duration, playbackRate } = usePlayer( + useShallow(({ position, duration, playbackRate }) => ({ + position, + duration, + playbackRate, + })), + ); const percent = duration > 0 ? (position / duration) * 100 : 0; return ( - <> + {secondsDisplay(position)} - -{secondsDisplay(Math.max(duration - position, 0))} + -{secondsDisplay(Math.max(duration - position, 0) / playbackRate)} + + + {percent.toFixed(1)}% - + ); } @@ -38,8 +48,15 @@ const styles = StyleSheet.create({ flexDirection: "row", justifyContent: "space-between", paddingTop: 4, + position: "relative", }, timeDisplayText: { color: colors.zinc[400], }, + percentText: { + position: "absolute", + top: 4, + width: "100%", + textAlign: "center", + }, }); diff --git a/src/components/PlayerScrubber.tsx b/src/components/PlayerScrubber.tsx index 9901d0c..7da9dc4 100644 --- a/src/components/PlayerScrubber.tsx +++ b/src/components/PlayerScrubber.tsx @@ -1,11 +1,16 @@ -import TrackPlayer from "react-native-track-player"; +import { seekTo, usePlayer } from "@/src/stores/player"; import colors from "tailwindcss/colors"; -import { useTrackPlayerStore } from "../stores/trackPlayer"; +import { useShallow } from "zustand/react/shallow"; import Scrubber from "./Scrubber"; export default function PlayerScrubber() { - const { playbackRate, position, duration } = useTrackPlayerStore( - (state) => state, + const { playbackRate, position, duration, chapterState } = usePlayer( + useShallow(({ playbackRate, position, duration, chapterState }) => ({ + playbackRate, + position, + duration, + chapterState, + })), ); const theme = { accent: colors.lime[400], @@ -15,14 +20,16 @@ export default function PlayerScrubber() { dimmed: colors.gray[500], weak: colors.gray[800], }; + const markers = + chapterState?.chapters.map((chapter) => chapter.startTime) || []; return ( TrackPlayer.seekTo(newPosition)} - markers={[]} + onChange={(newPosition: number) => seekTo(newPosition)} + markers={markers} theme={theme} /> ); diff --git a/src/components/PlayerSettingButtons.tsx b/src/components/PlayerSettingButtons.tsx new file mode 100644 index 0000000..c5a6334 --- /dev/null +++ b/src/components/PlayerSettingButtons.tsx @@ -0,0 +1,110 @@ +import { setSleepTimerState, usePlayer } from "@/src/stores/player"; +import { formatPlaybackRate } from "@/src/utils/rate"; +import * as Haptics from "expo-haptics"; +import { router } from "expo-router"; +import { useEffect, useState } from "react"; +import { StyleSheet, Text, View } from "react-native"; +import colors from "tailwindcss/colors"; +import { secondsDisplayMinutesOnly } from "../utils/time"; +import IconButton from "./IconButton"; + +export default function PlayerSettingButtons() { + const playbackRate = usePlayer((state) => state.playbackRate); + const sleepTimer = usePlayer((state) => state.sleepTimer); + const sleepTimerEnabled = usePlayer((state) => state.sleepTimerEnabled); + const sleepTimerTriggerTime = usePlayer( + (state) => state.sleepTimerTriggerTime, + ); + const position = usePlayer((state) => state.position); + const [countdown, setCountdown] = useState(null); + + useEffect(() => { + if (sleepTimerEnabled && sleepTimerTriggerTime !== null) { + const newCountdown = (sleepTimerTriggerTime - Date.now()) / 1000; + setCountdown(Math.max(0, newCountdown)); + } else { + setCountdown(null); + } + }, [position, sleepTimerEnabled, sleepTimerTriggerTime]); + + return ( + + { + router.navigate("/sleep-timer"); + }} + onLongPress={() => { + setSleepTimerState(!sleepTimerEnabled); + Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + }} + > + + + { + router.navigate("/playback-rate"); + }} + > + + {formatPlaybackRate(playbackRate)}× + + + + ); +} + +type SleepTimerLabelProps = { + sleepTimer: number; + sleepTimerEnabled: boolean; + countdown: number | null; +}; + +function SleepTimerLabel(props: SleepTimerLabelProps) { + const { sleepTimer, sleepTimerEnabled, countdown } = props; + + if (!sleepTimerEnabled) return null; + + if (countdown === null) + return ( + + {secondsDisplayMinutesOnly(sleepTimer)} + + ); + + return ( + + {secondsDisplayMinutesOnly(countdown)} + + ); +} + +const styles = StyleSheet.create({ + container: { + display: "flex", + flexDirection: "row", + justifyContent: "space-between", + marginHorizontal: -20, + }, + button: { + backgroundColor: colors.zinc[800], + borderRadius: 999, + paddingHorizontal: 16, + flexDirection: "row", + gap: 4, + }, + sleepTimerText: { + color: colors.zinc[100], + }, +}); diff --git a/src/components/Scrubber.tsx b/src/components/Scrubber.tsx index e68b773..55ecdaf 100644 --- a/src/components/Scrubber.tsx +++ b/src/components/Scrubber.tsx @@ -1,5 +1,4 @@ import AnimatedText from "@/src/components/AnimatedText"; -import usePrevious from "@react-hook/previous"; import React, { memo, useCallback, useEffect, useRef, useState } from "react"; import { Dimensions, StyleSheet } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; @@ -175,10 +174,11 @@ export default function Scrubber(props: ScrubberProps) { ); const [isScrubbing, setIsScrubbing] = useIsScrubbing(); const maxTranslateX = timeToTranslateX(duration); - const previousPosition = usePrevious(positionInput); + // const previousPosition = usePrevious(positionInput); const startX = useSharedValue(0); const isAnimating = useSharedValue(false); + const timecodeOpacity = useSharedValue(0); const panGestureHandler = Gesture.Pan() .onStart((_event) => { @@ -282,6 +282,10 @@ export default function Scrubber(props: ScrubberProps) { return { transform: [{ translateX: HALF_WIDTH + value }] }; }); + const animatedTimecodeStyle = useAnimatedStyle(() => { + return { opacity: withTiming(timecodeOpacity.value) }; + }); + const timecode = useDerivedValue(() => { const total = clamp(translateXToTime(translateX.value), 0, duration); const hours = Math.floor(total / 3600).toString(); @@ -297,6 +301,7 @@ export default function Scrubber(props: ScrubberProps) { useEffect(() => { if (!isScrubbing) { + timecodeOpacity.value = 0; translateX.value = timeToTranslateX(positionInput); // if (Math.abs(positionInput - (previousPosition || 0)) <= 3) { // console.log("linear"); @@ -316,15 +321,28 @@ export default function Scrubber(props: ScrubberProps) { // console.log("jump"); // translateX.value = timeToTranslateX(positionInput); // } + } else { + timecodeOpacity.value = 1; } - }, [translateX, isScrubbing, positionInput, previousPosition, playbackRate]); + }, [ + translateX, + isScrubbing, + positionInput, + // previousPosition, + playbackRate, + timecodeOpacity, + ]); return ( ; }; export default function SeekButton(props: SeekButtonProps) { - const { icon, size, color, amount, padding = size / 2 } = props; - const { seekRelative } = useTrackPlayerStore((state) => state); + const { icon, size, color, amount, style } = props; return ( ); } diff --git a/src/components/TabBarWithPlayer.tsx b/src/components/TabBarWithPlayer.tsx index ef34e94..da248c0 100644 --- a/src/components/TabBarWithPlayer.tsx +++ b/src/components/TabBarWithPlayer.tsx @@ -5,14 +5,22 @@ import PlayerProgressBar from "@/src/components/PlayerProgressBar"; import PlayerScrubber from "@/src/components/PlayerScrubber"; import ThumbnailImage from "@/src/components/ThumbnailImage"; import TitleAuthorsNarrators from "@/src/components/TitleAuthorNarrator"; +import { useMediaDetails } from "@/src/db/library"; import useBackHandler from "@/src/hooks/use.back.handler"; -import { useMediaDetails } from "@/src/hooks/use.media.details"; -import { useScreenStore } from "@/src/stores/screen"; -import { useTrackPlayerStore } from "@/src/stores/trackPlayer"; +import { expandPlayerHandled, usePlayer } from "@/src/stores/player"; +import { useScreen } from "@/src/stores/screen"; +import { Session } from "@/src/stores/session"; +import FontAwesome6 from "@expo/vector-icons/FontAwesome6"; import { BottomTabBar, BottomTabBarProps } from "@react-navigation/bottom-tabs"; import { router } from "expo-router"; import { useCallback, useEffect, useState } from "react"; -import { Pressable, StyleSheet, TouchableOpacity, View } from "react-native"; +import { + Pressable, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { Easing, @@ -23,21 +31,29 @@ import Animated, { useSharedValue, withTiming, } from "react-native-reanimated"; -import { useProgress } from "react-native-track-player"; import colors from "tailwindcss/colors"; +import { useShallow } from "zustand/react/shallow"; +import PlayerChapterControls from "./PlayerChapterControls"; +import PlayerSettingButtons from "./PlayerSettingButtons"; export default function TabBarWithPlayer({ state, descriptors, navigation, insets, -}: BottomTabBarProps) { - const { mediaId, lastPlayerExpandRequest, expandPlayerHandled } = - useTrackPlayerStore((state) => state); - const { media } = useMediaDetails(mediaId); + session, + mediaId, +}: BottomTabBarProps & { session: Session; mediaId: string }) { + const { lastPlayerExpandRequest, streaming } = usePlayer( + useShallow(({ lastPlayerExpandRequest, streaming }) => ({ + lastPlayerExpandRequest, + streaming, + })), + ); + const { data: media, opacity } = useMediaDetails(session, mediaId); const [expanded, setExpanded] = useState(true); const expansion = useSharedValue(1.0); - const { screenHeight, screenWidth } = useScreenStore((state) => state); + const { screenHeight, screenWidth } = useScreen((state) => state); const whereItWas = useSharedValue(0); const onPanEndAction = useSharedValue<"none" | "expand" | "collapse">("none"); @@ -64,12 +80,14 @@ export default function TabBarWithPlayer({ expandLocal(); } expandPlayerHandled(); - }, [expandLocal, expanded, lastPlayerExpandRequest, expandPlayerHandled]); + }, [expandLocal, expanded, lastPlayerExpandRequest]); const tabBarHeight = 50 + insets.bottom; const playerHeight = 70; - const eightyPercentScreenWidth = screenWidth * 0.8; - const tenPercentScreenWidth = screenWidth * 0.1; + const shortScreen = screenHeight / screenWidth < 1.8; + const largeImageSize = shortScreen ? screenWidth * 0.6 : screenWidth * 0.8; + const imageGutterWidth = (screenWidth - largeImageSize) / 2; + const miniControlsWidth = screenWidth - playerHeight; const debugBackgrounds = false; @@ -125,22 +143,20 @@ export default function TabBarWithPlayer({ }); const playerStyle = useAnimatedStyle(() => { - const interpolatedHeight = interpolate( - expansion.value, - [0, 1], - [playerHeight, screenHeight], - ); - - const interpolatedBottom = interpolate( - expansion.value, - [0, 1], - [tabBarHeight, 0], - ); - return { - height: interpolatedHeight, - bottom: interpolatedBottom, + opacity: opacity.value, + height: interpolate( + expansion.value, + [0, 1], + [playerHeight, screenHeight], + ), + bottom: interpolate(expansion.value, [0, 1], [tabBarHeight, 0]), paddingTop: interpolate(expansion.value, [0, 1], [0, insets.top]), + borderTopWidth: interpolate( + expansion.value, + [0, 1], + [StyleSheet.hairlineWidth, 0], + ), }; }); @@ -155,7 +171,7 @@ export default function TabBarWithPlayer({ width: interpolate( expansion.value, [0, 0.75], - [0, tenPercentScreenWidth], + [0, imageGutterWidth], Extrapolation.CLAMP, ), }; @@ -166,12 +182,12 @@ export default function TabBarWithPlayer({ height: interpolate( expansion.value, [0, 1], - [playerHeight, eightyPercentScreenWidth], + [playerHeight, largeImageSize], ), width: interpolate( expansion.value, [0, 1], - [playerHeight, eightyPercentScreenWidth], + [playerHeight, largeImageSize], ), padding: interpolate(expansion.value, [0, 1], [8, 0]), }; @@ -182,7 +198,7 @@ export default function TabBarWithPlayer({ width: interpolate( expansion.value, [0, 1], - [miniControlsWidth, tenPercentScreenWidth], + [miniControlsWidth, imageGutterWidth], ), opacity: interpolate( expansion.value, @@ -195,6 +211,12 @@ export default function TabBarWithPlayer({ const controlsStyle = useAnimatedStyle(() => { return { + transform: [ + { + translateY: interpolate(expansion.value, [0, 1], [256, 0]), + }, + ], + marginBottom: interpolate(expansion.value, [0, 1], [-512, 0]), opacity: interpolate( expansion.value, [0.75, 1], @@ -206,7 +228,7 @@ export default function TabBarWithPlayer({ const topActionBarStyle = useAnimatedStyle(() => { return { - height: interpolate(expansion.value, [0, 0.75], [0, 64]), + height: interpolate(expansion.value, [0, 0.75], [0, 36]), opacity: interpolate( expansion.value, [0.75, 1], @@ -218,7 +240,7 @@ export default function TabBarWithPlayer({ const infoStyle = useAnimatedStyle(() => { return { - paddingTop: interpolate(expansion.value, [0.75, 1], [32, 8]), + paddingTop: interpolate(expansion.value, [0.75, 1], [64, 8]), opacity: interpolate( expansion.value, [0.75, 1], @@ -239,7 +261,6 @@ export default function TabBarWithPlayer({ return ( <> - collapseLocal()} /> - {/* + + + {streaming ? "streaming" : "downloaded"} + + + )} + + console.log("TODO: context menu")} - /> */} + style={{ opacity: 0 }} + /> ba.author.name)} @@ -423,9 +468,8 @@ export default function TabBarWithPlayer({ - + + + + + + + + - @@ -456,14 +509,3 @@ export default function TabBarWithPlayer({ ); } - -function TrackPlayerProgressSubscriber() { - const { playbackRate, updateProgress } = useTrackPlayerStore( - (state) => state, - ); - const { position, duration } = useProgress(1000 / playbackRate); - useEffect(() => { - updateProgress(position, duration); - }, [duration, position, updateProgress]); - return null; -} diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx index 2aa0d2e..ac6e8d9 100644 --- a/src/components/ThumbnailImage.tsx +++ b/src/components/ThumbnailImage.tsx @@ -1,5 +1,5 @@ import { DownloadedThumbnails, Thumbnails } from "@/src/db/schema"; -import { useSessionStore } from "@/src/stores/session"; +import { useSession } from "@/src/stores/session"; import { Image, ImageStyle } from "expo-image"; import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; import colors from "tailwindcss/colors"; @@ -14,7 +14,7 @@ type ThumbnailImageProps = { export default function ThumbnailImage(props: ThumbnailImageProps) { const { downloadedThumbnails, thumbnails, size, style, imageStyle } = props; - const session = useSessionStore((state) => state.session); + const session = useSession((state) => state.session); if (session && downloadedThumbnails) { return ( diff --git a/src/components/Tiles.tsx b/src/components/Tiles.tsx index 4345e0a..8532b8d 100644 --- a/src/components/Tiles.tsx +++ b/src/components/Tiles.tsx @@ -1,6 +1,6 @@ import MultiThumbnailImage from "@/src/components/MultiThumbnailImage"; import { DownloadedThumbnails, Thumbnails } from "@/src/db/schema"; -import { useRouter } from "expo-router"; +import { router } from "expo-router"; import { StyleProp, StyleSheet, @@ -90,8 +90,6 @@ export function SeriesBookTile({ seriesBook, style }: SeriesBookTileProps) { } export function Tile({ book, media, seriesBook, style }: TileProps) { - const router = useRouter(); - const navigateToBook = () => { if (media.length === 1) { router.navigate({ @@ -145,7 +143,6 @@ export function Tile({ book, media, seriesBook, style }: TileProps) { export function PersonTile(props: PersonTileProps) { const { personId, name, realName, thumbnails, label } = props; - const router = useRouter(); const navigateToPerson = () => { router.navigate({ diff --git a/src/db/downloads.ts b/src/db/downloads.ts index d70b83e..36dae54 100644 --- a/src/db/downloads.ts +++ b/src/db/downloads.ts @@ -1,8 +1,8 @@ import { db } from "@/src/db/db"; import * as schema from "@/src/db/schema"; +import useFadeInQuery from "@/src/hooks/use.fade.in.query"; import { Session } from "@/src/stores/session"; import { and, desc, eq } from "drizzle-orm"; -import { useLiveQuery } from "drizzle-orm/expo-sqlite"; export type MediaNarrator = { id: string; @@ -42,39 +42,31 @@ export type Download = { media: Media; }; -export type LiveDownloadsList = { - readonly data: Download[]; - readonly error: Error | undefined; - readonly updatedAt: Date | undefined; -}; - -export function useLiveDownloadsList(session: Session): LiveDownloadsList { - return useLiveQuery( - db.query.downloads.findMany({ - columns: { status: true, thumbnails: true, filePath: true }, - where: eq(schema.downloads.url, session.url), - orderBy: desc(schema.downloads.downloadedAt), - with: { - media: { - columns: { id: true, thumbnails: true }, - with: { - mediaNarrators: { - columns: { id: true }, - with: { - narrator: { - columns: { id: true, name: true }, - }, +export function useDownloadsList(session: Session) { + const query = db.query.downloads.findMany({ + columns: { status: true, thumbnails: true, filePath: true }, + where: eq(schema.downloads.url, session.url), + orderBy: desc(schema.downloads.downloadedAt), + with: { + media: { + columns: { id: true, thumbnails: true }, + with: { + mediaNarrators: { + columns: { id: true }, + with: { + narrator: { + columns: { id: true, name: true }, }, }, - book: { - columns: { id: true, title: true }, - with: { - bookAuthors: { - columns: { id: true }, - with: { - author: { - columns: { id: true, name: true }, - }, + }, + book: { + columns: { id: true, title: true }, + with: { + bookAuthors: { + columns: { id: true }, + with: { + author: { + columns: { id: true, name: true }, }, }, }, @@ -82,8 +74,18 @@ export function useLiveDownloadsList(session: Session): LiveDownloadsList { }, }, }, - }), - ); + }, + }); + + return useFadeInQuery(query, [ + "downloads", + "media", + "media_narrators", + "narrators", + "books", + "book_authors", + "authors", + ]); } export async function getDownload( diff --git a/src/db/library.ts b/src/db/library.ts index c68b6d2..137c133 100644 --- a/src/db/library.ts +++ b/src/db/library.ts @@ -1,96 +1,25 @@ import { db } from "@/src/db/db"; import * as schema from "@/src/db/schema"; +import useFadeInQuery, { fadeInTime } from "@/src/hooks/use.fade.in.query"; +import { useLiveTablesQuery } from "@/src/hooks/use.live.tables.query"; import { Session } from "@/src/stores/session"; -import { and, desc, eq } from "drizzle-orm"; +import { + and, + desc, + eq, + inArray, + isNull, + ne, + notInArray, + or, + sql, +} from "drizzle-orm"; +import { useLiveQuery } from "drizzle-orm/expo-sqlite"; +import { useEffect, useState } from "react"; +import { useSharedValue, withTiming } from "react-native-reanimated"; -export type Person = { - id: string; -}; - -export type Author = { - id: string; - name: string; - person: Person; -}; - -export type BookAuthor = { - id: string; - author: Author; -}; - -export type Series = { - id: string; - name: string; -}; - -export type SeriesBook = { - id: string; - bookNumber: string; - series: Series; -}; - -export type Book = { - id: string; - title: string; - bookAuthors: BookAuthor[]; - seriesBooks: SeriesBook[]; -}; - -export type Narrator = { - id: string; - name: string; - person: Person; -}; - -export type MediaNarrator = { - id: string; - narrator: Narrator; -}; - -export type MediaForIndex = { - id: string; - thumbnails: schema.Thumbnails | null; - book: Book; - mediaNarrators: MediaNarrator[]; - download: Download | null; -}; - -export type Download = { - status: string; - thumbnails: schema.DownloadedThumbnails | null; -}; - -export type MediaForDetails = { - id: string; - description: string | null; - thumbnails: schema.Thumbnails | null; - mp4Path: string | null; - duration: string | null; - book: Book; - mediaNarrators: MediaNarrator[]; - download: Download | null; - published: Date | null; - publishedFormat: "full" | "year_month" | "year"; - publisher: string | null; - notes: string | null; -}; - -export type PersonForDetails = { - id: string; - name: string; - thumbnails: schema.Thumbnails | null; - description: string | null; -}; - -export type SeriesForDetails = { - id: string; - name: string; -}; - -export async function listMediaForIndex( - session: Session, -): Promise { - return db.query.media.findMany({ +export function useMediaList(session: Session) { + const query = db.query.media.findMany({ columns: { id: true, thumbnails: true }, where: and( eq(schema.media.url, session.url), @@ -130,13 +59,23 @@ export async function listMediaForIndex( }, }, }); + + return useFadeInQuery(query, [ + "media", + "downloads", + "media_narrators", + "narrators", + "people", + "books", + "book_authors", + "authors", + "series_books", + "series", + ]); } -export async function getMediaForDetails( - session: Session, - mediaId: string, -): Promise { - return db.query.media.findFirst({ +export function useMediaDetails(session: Session, mediaId: string) { + const query = db.query.media.findFirst({ columns: { id: true, thumbnails: true, @@ -182,30 +121,1166 @@ export async function getMediaForDetails( }, }, }); + + return useFadeInQuery( + query, + [ + "media", + "downloads", + "media_narrators", + "narrators", + "people", + "books", + "book_authors", + "authors", + "series_books", + "series", + ], + [mediaId], + ); } -export async function getPersonForDetails( - session: Session, - personId: string, -): Promise { - return db.query.people.findFirst({ - columns: { id: true, name: true, thumbnails: true, description: true }, +export type BookDetails = { + title: string; + published: Date; + publishedFormat: "full" | "year_month" | "year"; + bookAuthors: { + author: { + id: string; + name: string; + person: { + id: string; + name: string; + thumbnails: schema.Thumbnails | null; + }; + }; + }[]; + media: { + id: string; + thumbnails: schema.Thumbnails | null; + mediaNarrators: { + narrator: { + id: string; + name: string; + person: { + id: string; + name: string; + thumbnails: schema.Thumbnails | null; + }; + }; + }[]; + download: { + thumbnails: schema.DownloadedThumbnails | null; + } | null; + }[]; +}; + +export function useBookDetails(session: Session, bookId: string) { + const query = db.query.books.findFirst({ + columns: { + id: true, + title: true, + published: true, + publishedFormat: true, + }, + where: and(eq(schema.books.url, session.url), eq(schema.books.id, bookId)), + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { id: true, name: true }, + with: { + person: { + columns: { id: true, name: true, thumbnails: true }, + }, + }, + }, + }, + }, + media: { + columns: { id: true, thumbnails: true }, + with: { + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { id: true, name: true }, + with: { + person: { + columns: { + id: true, + name: true, + thumbnails: true, + }, + }, + }, + }, + }, + }, + download: { + columns: { thumbnails: true }, + }, + }, + }, + }, + }); + + return useFadeInQuery( + query, + [ + "books", + "book_authors", + "authors", + "people", + "media", + "media_narrators", + "narrators", + "downloads", + ], + [bookId], + ); +} + +// TODO: break this up into smaller hooks +export function useSeriesDetails(session: Session, seriesId: string) { + const query = db.query.series.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(schema.series.url, session.url), + eq(schema.series.id, seriesId), + ), + with: { + seriesBooks: { + columns: { id: true, bookNumber: true }, + orderBy: sql`CAST(book_number AS FLOAT)`, + with: { + book: { + columns: { id: true, title: true }, + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { id: true, name: true }, + with: { + person: { + columns: { id: true, name: true, thumbnails: true }, + }, + }, + }, + }, + }, + media: { + columns: { id: true, thumbnails: true }, + with: { + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { id: true, name: true }, + with: { + person: { + columns: { + id: true, + name: true, + thumbnails: true, + }, + }, + }, + }, + }, + }, + download: { + columns: { thumbnails: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + return useFadeInQuery( + query, + [ + "series", + "series_books", + "books", + "book_authors", + "authors", + "people", + "media", + "media_narrators", + "narrators", + "downloads", + ], + [seriesId], + ); +} + +export function usePersonIds(session: Session, personId: string) { + const query = db.query.people.findFirst({ + columns: {}, where: and( eq(schema.people.url, session.url), eq(schema.people.id, personId), ), + with: { + authors: { + columns: { id: true }, + }, + narrators: { + columns: { id: true }, + }, + }, }); + + const { data: person, opacity } = useFadeInQuery( + query, + ["people", "authors", "narrators"], + [personId], + ); + + const [ids, setIds] = useState<{ + personId: string; + authorIds: string[]; + narratorIds: string[]; + } | null>(null); + + useEffect(() => { + if (!person) return; + + setIds({ + personId, + authorIds: person.authors.map((a) => a.id), + narratorIds: person.narrators.map((n) => n.id), + }); + }, [person, personId]); + + return { ids, opacity }; } -export async function getSeriesForDetails( +export function usePersonHeaderInfo(session: Session, personId: string) { + const query = db.query.people.findFirst({ + columns: { + name: true, + thumbnails: true, + }, + where: and( + eq(schema.people.url, session.url), + eq(schema.people.id, personId), + ), + }); + + return useFadeInQuery(query, ["people"], [personId]); +} + +export function usePersonDescription(session: Session, personId: string) { + const query = db.query.people.findFirst({ + columns: { description: true }, + where: and( + eq(schema.people.url, session.url), + eq(schema.people.id, personId), + ), + }); + + return useFadeInQuery(query, ["people"], [personId]); +} + +export function useBooksByAuthor(session: Session, authorId: string) { + const bookIdsQuery = db + .selectDistinct({ id: schema.books.id }) + .from(schema.authors) + .innerJoin( + schema.bookAuthors, + and( + eq(schema.authors.url, schema.bookAuthors.url), + eq(schema.authors.id, schema.bookAuthors.authorId), + ), + ) + .innerJoin( + schema.books, + and( + eq(schema.bookAuthors.url, schema.books.url), + eq(schema.bookAuthors.bookId, schema.books.id), + ), + ) + .where( + and(eq(schema.authors.url, session.url), eq(schema.authors.id, authorId)), + ); + + const { data: bookIds, updatedAt: bookIdsUpdatedAt } = useLiveTablesQuery( + bookIdsQuery, + ["authors", "book_authors", "books"], + [authorId], + ); + + const authorQuery = db.query.authors.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(schema.authors.url, session.url), + eq(schema.authors.id, authorId), + ), + with: { + person: { + columns: { id: true, name: true }, + }, + }, + }); + + const { data: author, updatedAt: authorUpdatedAt } = useLiveTablesQuery( + authorQuery, + ["authors", "people"], + [authorId], + ); + + const booksQuery = db.query.books.findMany({ + columns: { id: true, title: true }, + where: and( + eq(schema.books.url, session.url), + inArray( + schema.books.id, + bookIds.map((book) => book.id), + ), + ), + orderBy: desc(schema.books.published), + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + media: { + columns: { id: true, thumbnails: true }, + with: { + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + download: { + columns: { thumbnails: true }, + }, + }, + }, + }, + }); + + const { data: books, updatedAt: booksUpdatedAt } = useLiveTablesQuery( + booksQuery, + [ + "books", + "book_authors", + "authors", + "media", + "media_narrators", + "narrators", + "downloads", + ], + [bookIds], + ); + + const opacity = useSharedValue(0); + + useEffect(() => { + if ( + bookIdsUpdatedAt !== undefined && + authorUpdatedAt !== undefined && + booksUpdatedAt !== undefined + ) { + opacity.value = withTiming(1, { duration: fadeInTime }); + } + }, [opacity, bookIdsUpdatedAt, authorUpdatedAt, booksUpdatedAt]); + + return { books, author, opacity }; +} + +export function useMediaByNarrator(session: Session, narratorId: string) { + const mediaIdsQuery = db + .selectDistinct({ id: schema.media.id }) + .from(schema.narrators) + .innerJoin( + schema.mediaNarrators, + and( + eq(schema.narrators.url, schema.mediaNarrators.url), + eq(schema.narrators.id, schema.mediaNarrators.narratorId), + ), + ) + .innerJoin( + schema.media, + and( + eq(schema.mediaNarrators.url, schema.media.url), + eq(schema.mediaNarrators.mediaId, schema.media.id), + ), + ) + .where( + and( + eq(schema.narrators.url, session.url), + eq(schema.narrators.id, narratorId), + ), + ); + + const { data: mediaIds, updatedAt: mediaIdsUpdatedAt } = useLiveTablesQuery( + mediaIdsQuery, + ["narrators", "media_narrators", "media", "books"], + [narratorId], + ); + + const narratorQuery = db.query.narrators.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(schema.narrators.url, session.url), + eq(schema.narrators.id, narratorId), + ), + with: { + person: { + columns: { id: true, name: true }, + }, + }, + }); + + const { data: narrator, updatedAt: narratorUpdatedAt } = useLiveTablesQuery( + narratorQuery, + ["narrators", "people"], + [narratorId], + ); + + const mediaQuery = db.query.media.findMany({ + columns: { id: true, thumbnails: true }, + where: and( + eq(schema.media.url, session.url), + inArray( + schema.media.id, + mediaIds.map((media) => media.id), + ), + ), + orderBy: desc(schema.media.published), + with: { + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + download: { + columns: { thumbnails: true }, + }, + book: { + columns: { id: true, title: true }, + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + }, + }, + }, + }); + + const { data: media, updatedAt: mediaUpdatedAt } = useLiveTablesQuery( + mediaQuery, + [ + "media", + "media_narrators", + "narrators", + "downloads", + "books", + "book_authors", + "authors", + ], + [mediaIds], + ); + + const opacity = useSharedValue(0); + + useEffect(() => { + if ( + mediaIdsUpdatedAt !== undefined && + narratorUpdatedAt !== undefined && + mediaUpdatedAt !== undefined + ) { + opacity.value = withTiming(1, { duration: fadeInTime }); + } + }, [opacity, mediaIdsUpdatedAt, narratorUpdatedAt, mediaUpdatedAt]); + + return { media, narrator, opacity }; +} + +export function useMediaIds(session: Session, mediaId: string) { + const query = db.query.media.findFirst({ + columns: { bookId: true }, + where: and(eq(schema.media.url, session.url), eq(schema.media.id, mediaId)), + with: { + book: { + columns: {}, + with: { + bookAuthors: { + columns: { authorId: true }, + }, + seriesBooks: { + columns: { seriesId: true }, + }, + }, + }, + mediaNarrators: { + columns: { narratorId: true }, + }, + }, + }); + + const { data: media, opacity } = useFadeInQuery( + query, + ["media", "books", "book_authors", "series_books", "media_narrators"], + [mediaId], + ); + + const [ids, setIds] = useState<{ + mediaId: string; + bookId: string; + authorIds: string[]; + seriesIds: string[]; + narratorIds: string[]; + } | null>(null); + + useEffect(() => { + if (!media) return; + + setIds({ + mediaId, + bookId: media.bookId, + authorIds: media.book.bookAuthors.map((ba) => ba.authorId), + seriesIds: media.book.seriesBooks.map((sb) => sb.seriesId), + narratorIds: media.mediaNarrators.map((mn) => mn.narratorId), + }); + }, [media, mediaId]); + + return { ids, opacity }; +} + +export function useMediaHeaderInfo(session: Session, mediaId: string) { + const query = db.query.media.findFirst({ + columns: { + fullCast: true, + abridged: true, + thumbnails: true, + duration: true, + }, + where: and(eq(schema.media.url, session.url), eq(schema.media.id, mediaId)), + with: { + download: { + columns: { thumbnails: true }, + }, + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + book: { + columns: { title: true }, + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + seriesBooks: { + columns: { bookNumber: true }, + with: { series: { columns: { name: true } } }, + }, + }, + }, + }, + }); + + return useFadeInQuery( + query, + [ + "media", + "downloads", + "media_narrators", + "narrators", + "books", + "book_authors", + "authors", + "series_books", + "series", + ], + [mediaId], + ); +} + +export function useMediaActionBarInfo(session: Session, mediaId: string) { + const query = db.query.media.findFirst({ + columns: { + id: true, + thumbnails: true, + mp4Path: true, + }, + where: and(eq(schema.media.url, session.url), eq(schema.media.id, mediaId)), + with: { + download: { + columns: { status: true }, + }, + }, + }); + + return useFadeInQuery(query, ["media", "downloads"], [mediaId]); +} + +export function useMediaDescription(session: Session, mediaId: string) { + const query = db.query.media.findFirst({ + columns: { + description: true, + published: true, + publishedFormat: true, + publisher: true, + notes: true, + }, + with: { + book: { + columns: { published: true, publishedFormat: true }, + }, + }, + where: and(eq(schema.media.url, session.url), eq(schema.media.id, mediaId)), + }); + + return useFadeInQuery(query, ["media", "books"], [mediaId]); +} + +export function useMediaAuthorsAndNarrators(session: Session, mediaId: string) { + const query = db.query.media.findFirst({ + columns: {}, + where: and(eq(schema.media.url, session.url), eq(schema.media.id, mediaId)), + with: { + book: { + columns: {}, + with: { + bookAuthors: { + columns: { id: true }, + with: { + author: { + columns: { name: true }, + with: { + person: { + columns: { id: true, name: true, thumbnails: true }, + }, + }, + }, + }, + }, + }, + }, + mediaNarrators: { + columns: { id: true }, + with: { + narrator: { + columns: { name: true }, + with: { + person: { columns: { id: true, name: true, thumbnails: true } }, + }, + }, + }, + }, + }, + }); + + const { data: media, opacity } = useFadeInQuery( + query, + [ + "media", + "books", + "book_authors", + "authors", + "people", + "media_narrators", + "narrators", + ], + [mediaId], + ); + + const [authorSet, setAuthorSet] = useState>(new Set()); + const [narratorSet, setNarratorSet] = useState>( + new Set(), + ); + + useEffect(() => { + if (!media) return; + + const newAuthorSet = new Set(); + for (const ba of media.book.bookAuthors) { + newAuthorSet.add(ba.author.person.id); + } + setAuthorSet(newAuthorSet); + + const newNarratorSet = new Set(); + for (const mn of media.mediaNarrators) { + newNarratorSet.add(mn.narrator.person.id); + } + setNarratorSet(newNarratorSet); + }, [media]); + + return { media, authorSet, narratorSet, opacity }; +} + +export function useMediaOtherEditions( session: Session, - seriesId: string, -): Promise { - return db.query.series.findFirst({ + bookId: string, + withoutMediaId: string, +) { + const mediaIdsQuery = db + .select({ id: schema.media.id }) + .from(schema.media) + .limit(10) + .where( + and( + eq(schema.media.url, session.url), + eq(schema.media.bookId, bookId), + ne(schema.media.id, withoutMediaId), + ), + ); + + const { data: mediaIds, updatedAt: mediaIdsUpdatedAt } = useLiveQuery( + mediaIdsQuery, + [bookId, withoutMediaId], + ); + + const mediaQuery = db.query.media.findMany({ + columns: { id: true, thumbnails: true }, + where: and( + eq(schema.media.url, session.url), + inArray( + schema.media.id, + mediaIds.map((media) => media.id), + ), + ), + orderBy: desc(schema.media.published), + with: { + download: { + columns: { thumbnails: true }, + }, + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + book: { + columns: { id: true, title: true }, + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + }, + }, + }, + }); + + const { data: media, updatedAt: mediaUpdatedAt } = useLiveTablesQuery( + mediaQuery, + [ + "media", + "downloads", + "media_narrators", + "narrators", + "books", + "book_authors", + "authors", + ], + [mediaIds], + ); + + const opacity = useSharedValue(0); + + useEffect(() => { + if (mediaIdsUpdatedAt !== undefined && mediaUpdatedAt !== undefined) { + opacity.value = withTiming(1, { duration: fadeInTime }); + } + }, [opacity, mediaIdsUpdatedAt, mediaUpdatedAt]); + + return { media, opacity }; +} + +export function useOtherBooksInSeries(session: Session, seriesId: string) { + const query = db.query.series.findFirst({ columns: { id: true, name: true }, where: and( eq(schema.series.url, session.url), eq(schema.series.id, seriesId), ), + with: { + seriesBooks: { + columns: { id: true, bookNumber: true }, + orderBy: sql`CAST(book_number AS FLOAT)`, + limit: 10, + with: { + book: { + columns: { id: true, title: true }, + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + media: { + columns: { id: true, thumbnails: true }, + with: { + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + download: { + columns: { thumbnails: true }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + return useFadeInQuery( + query, + [ + "series", + "series_books", + "books", + "book_authors", + "authors", + "media", + "media_narrators", + "narrators", + "downloads", + ], + [seriesId], + ); +} + +export function useOtherBooksByAuthor( + session: Session, + authorId: string, + withoutBookId: string, + withoutSeriesIds: string[], +) { + const bookIdsQuery = db + .selectDistinct({ id: schema.books.id }) + .from(schema.authors) + .innerJoin( + schema.bookAuthors, + and( + eq(schema.authors.url, schema.bookAuthors.url), + eq(schema.authors.id, schema.bookAuthors.authorId), + ), + ) + .innerJoin( + schema.books, + and( + eq(schema.bookAuthors.url, schema.books.url), + eq(schema.bookAuthors.bookId, schema.books.id), + ), + ) + .leftJoin( + schema.seriesBooks, + and( + eq(schema.books.url, schema.seriesBooks.url), + eq(schema.books.id, schema.seriesBooks.bookId), + ), + ) + .limit(10) + .where( + and( + eq(schema.authors.url, session.url), + eq(schema.authors.id, authorId), + ne(schema.books.id, withoutBookId), + or( + isNull(schema.seriesBooks.seriesId), + notInArray(schema.seriesBooks.seriesId, withoutSeriesIds), + ), + ), + ); + + const { data: bookIds, updatedAt: bookIdsUpdatedAt } = useLiveTablesQuery( + bookIdsQuery, + ["authors", "book_authors", "books", "series_books"], + [authorId, withoutBookId, withoutSeriesIds], + ); + + const authorQuery = db.query.authors.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(schema.authors.url, session.url), + eq(schema.authors.id, authorId), + ), + with: { + person: { + columns: { id: true, name: true }, + }, + }, }); + + const { data: author, updatedAt: authorUpdatedAt } = useLiveTablesQuery( + authorQuery, + ["authors", "people"], + [authorId], + ); + + const booksQuery = db.query.books.findMany({ + columns: { id: true, title: true }, + where: and( + eq(schema.books.url, session.url), + inArray( + schema.books.id, + bookIds.map((book) => book.id), + ), + ), + orderBy: desc(schema.books.published), + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + media: { + columns: { id: true, thumbnails: true }, + with: { + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + download: { + columns: { thumbnails: true }, + }, + }, + }, + }, + }); + + const { data: books, updatedAt: booksUpdatedAt } = useLiveTablesQuery( + booksQuery, + [ + "books", + "book_authors", + "authors", + "media", + "media_narrators", + "narrators", + "downloads", + ], + [bookIds], + ); + + const opacity = useSharedValue(0); + + useEffect(() => { + if ( + bookIdsUpdatedAt !== undefined && + authorUpdatedAt !== undefined && + booksUpdatedAt !== undefined + ) { + opacity.value = withTiming(1, { duration: fadeInTime }); + } + }, [opacity, bookIdsUpdatedAt, authorUpdatedAt, booksUpdatedAt]); + + return { books, author, opacity }; +} + +export function useOtherMediaByNarrator( + session: Session, + narratorId: string, + withoutMediaId: string, + withoutSeriesIds: string[], + withoutAuthorIds: string[], +) { + const mediaIdsQuery = db + .selectDistinct({ id: schema.media.id }) + .from(schema.narrators) + .innerJoin( + schema.mediaNarrators, + and( + eq(schema.narrators.url, schema.mediaNarrators.url), + eq(schema.narrators.id, schema.mediaNarrators.narratorId), + ), + ) + .innerJoin( + schema.media, + and( + eq(schema.mediaNarrators.url, schema.media.url), + eq(schema.mediaNarrators.mediaId, schema.media.id), + ), + ) + .innerJoin( + schema.books, + and( + eq(schema.media.url, schema.books.url), + eq(schema.media.bookId, schema.books.id), + ), + ) + .innerJoin( + schema.bookAuthors, + and( + eq(schema.books.url, schema.bookAuthors.url), + eq(schema.books.id, schema.bookAuthors.bookId), + ), + ) + .leftJoin( + schema.seriesBooks, + and( + eq(schema.books.url, schema.seriesBooks.url), + eq(schema.books.id, schema.seriesBooks.bookId), + ), + ) + .limit(10) + .where( + and( + eq(schema.narrators.url, session.url), + eq(schema.narrators.id, narratorId), + ne(schema.media.id, withoutMediaId), + notInArray(schema.bookAuthors.authorId, withoutAuthorIds), + or( + isNull(schema.seriesBooks.seriesId), + notInArray(schema.seriesBooks.seriesId, withoutSeriesIds), + ), + ), + ); + + const { data: mediaIds, updatedAt: mediaIdsUpdatedAt } = useLiveTablesQuery( + mediaIdsQuery, + [ + "narrators", + "media_narrators", + "media", + "books", + "book_authors", + "series_books", + ], + [narratorId, withoutMediaId, withoutSeriesIds, withoutAuthorIds], + ); + + const narratorQuery = db.query.narrators.findFirst({ + columns: { id: true, name: true }, + where: and( + eq(schema.narrators.url, session.url), + eq(schema.narrators.id, narratorId), + ), + with: { + person: { + columns: { id: true, name: true }, + }, + }, + }); + + const { data: narrator, updatedAt: narratorUpdatedAt } = useLiveTablesQuery( + narratorQuery, + ["narrators", "people"], + [narratorId], + ); + + const mediaQuery = db.query.media.findMany({ + columns: { id: true, thumbnails: true }, + where: and( + eq(schema.media.url, session.url), + inArray( + schema.media.id, + mediaIds.map((media) => media.id), + ), + ), + orderBy: desc(schema.media.published), + with: { + download: { + columns: { thumbnails: true }, + }, + mediaNarrators: { + columns: {}, + with: { + narrator: { + columns: { name: true }, + }, + }, + }, + book: { + columns: { id: true, title: true }, + with: { + bookAuthors: { + columns: {}, + with: { + author: { + columns: { name: true }, + }, + }, + }, + }, + }, + }, + }); + + const { data: media, updatedAt: mediaUpdatedAt } = useLiveTablesQuery( + mediaQuery, + [ + "media", + "downloads", + "media_narrators", + "narrators", + "books", + "book_authors", + "authors", + ], + [mediaIds], + ); + + const opacity = useSharedValue(0); + + useEffect(() => { + if ( + mediaIdsUpdatedAt !== undefined && + narratorUpdatedAt !== undefined && + mediaUpdatedAt !== undefined + ) { + opacity.value = withTiming(1, { duration: fadeInTime }); + } + }, [opacity, mediaIdsUpdatedAt, narratorUpdatedAt, mediaUpdatedAt]); + + return { media, narrator, opacity }; } diff --git a/src/db/playerStates.ts b/src/db/playerStates.ts index 4f8239a..15542d0 100644 --- a/src/db/playerStates.ts +++ b/src/db/playerStates.ts @@ -38,6 +38,7 @@ type Media = { duration: string | null; book: Book; download: Download | null; + chapters: schema.Chapter[]; }; interface PlayerState { @@ -74,6 +75,7 @@ export async function getSyncedPlayerState( mpdPath: true, hlsPath: true, duration: true, + chapters: true, }, with: { download: { @@ -118,6 +120,7 @@ export async function getLocalPlayerState( mpdPath: true, hlsPath: true, duration: true, + chapters: true, }, with: { download: { diff --git a/src/db/schema.ts b/src/db/schema.ts index 13e39ce..0f77acb 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -462,6 +462,9 @@ export const servers = sqliteTable( }, ); +// downloads are associated to a server but _not_ a user. If you log into a +// different account, but login to the same server, you have access to all +// downloads associated with that server. export const downloads = sqliteTable( "downloads", { @@ -499,3 +502,17 @@ export const downloadsRelations = relations(downloads, ({ one }) => ({ references: [media.url, media.id], }), })); + +export const defaultSleepTimer = 600; +export const defaultSleepTimerEnabled = false; + +// Local settings are associated to a user. If you log into a different account, +// you will have different local settings. +export const localUserSettings = sqliteTable("local_user_settings", { + userEmail: text("user_email").notNull().primaryKey(), + preferredPlaybackRate: real("preferred_playback_rate").notNull().default(1), + sleepTimer: integer("sleep_timer").notNull().default(defaultSleepTimer), + sleepTimerEnabled: integer("sleep_timer_enabled", { mode: "boolean" }) + .notNull() + .default(defaultSleepTimerEnabled), +}); diff --git a/src/db/settings.ts b/src/db/settings.ts new file mode 100644 index 0000000..652c076 --- /dev/null +++ b/src/db/settings.ts @@ -0,0 +1,75 @@ +import { db } from "@/src/db/db"; +import * as schema from "@/src/db/schema"; +import { eq, sql } from "drizzle-orm"; + +export async function setPreferredPlaybackRate( + userEmail: string, + rate: number, +) { + await db + .insert(schema.localUserSettings) + .values({ + userEmail, + preferredPlaybackRate: rate, + }) + .onConflictDoUpdate({ + target: [schema.localUserSettings.userEmail], + set: { + preferredPlaybackRate: sql`excluded.preferred_playback_rate`, + }, + }); +} + +export async function setSleepTimerEnabled( + userEmail: string, + enabled: boolean, +) { + await db + .insert(schema.localUserSettings) + .values({ + userEmail, + sleepTimerEnabled: enabled, + }) + .onConflictDoUpdate({ + target: [schema.localUserSettings.userEmail], + set: { + sleepTimerEnabled: sql`excluded.sleep_timer_enabled`, + }, + }); +} + +export async function setSleepTimerTime(userEmail: string, seconds: number) { + await db + .insert(schema.localUserSettings) + .values({ + userEmail, + sleepTimer: seconds, + }) + .onConflictDoUpdate({ + target: [schema.localUserSettings.userEmail], + set: { + sleepTimer: sql`excluded.sleep_timer`, + }, + }); +} + +export async function getSleepTimerSettings(userEmail: string) { + const response = await db.query.localUserSettings.findFirst({ + columns: { + sleepTimer: true, + sleepTimerEnabled: true, + }, + where: eq(schema.localUserSettings.userEmail, userEmail), + }); + + console.log("settings response", response); + + if (response) { + return response; + } else { + return { + sleepTimer: schema.defaultSleepTimer, + sleepTimerEnabled: schema.defaultSleepTimerEnabled, + }; + } +} diff --git a/src/db/sync.ts b/src/db/sync.ts index dce27f5..b2cc009 100644 --- a/src/db/sync.ts +++ b/src/db/sync.ts @@ -16,7 +16,7 @@ const deletionsTables = { PERSON: schema.people, }; -export async function syncDown(session: Session) { +export async function syncDown(session: Session, force: boolean = false) { console.log("down syncing..."); const server = await db.query.servers.findFirst({ @@ -30,7 +30,7 @@ export async function syncDown(session: Session) { // but it's ok because this is just a debounce const now = Date.now(); const lastSyncTime = lastSync.getTime(); - if (now - lastSyncTime < 60 * 1000) { + if (now - lastSyncTime < 60 * 1000 && !force) { console.log("down synced less than a minute ago, skipping sync"); return; } @@ -372,7 +372,7 @@ export async function syncDown(session: Session) { console.log("down sync complete"); } -export async function syncUp(session: Session) { +export async function syncUp(session: Session, force: boolean = false) { console.log("up syncing..."); const server = await db.query.servers.findFirst({ @@ -384,7 +384,7 @@ export async function syncUp(session: Session) { if (lastSync) { const now = Date.now(); const lastSyncTime = lastSync.getTime(); - if (now - lastSyncTime < 60 * 1000) { + if (now - lastSyncTime < 60 * 1000 && !force) { console.log("up synced less than a minute ago, skipping sync"); return; } diff --git a/src/graphql/client/execute.ts b/src/graphql/client/execute.ts index 8267057..22264a1 100644 --- a/src/graphql/client/execute.ts +++ b/src/graphql/client/execute.ts @@ -1,3 +1,4 @@ +import { router } from "expo-router"; import type { TypedDocumentString } from "./graphql"; export async function executeAuthenticated( @@ -19,6 +20,10 @@ export async function executeAuthenticated( }), }); + if (response.status === 401) { + return router.navigate("/sign-out"); + } + if (!response.ok) { throw new Error("Network response was not ok"); } diff --git a/src/hooks/use.app.boot.ts b/src/hooks/use.app.boot.ts index ec3eb69..60ee8b0 100644 --- a/src/hooks/use.app.boot.ts +++ b/src/hooks/use.app.boot.ts @@ -1,8 +1,8 @@ import migrations from "@/drizzle/migrations"; import { db } from "@/src/db/db"; import { syncDown } from "@/src/db/sync"; -import { useSessionStore } from "@/src/stores/session"; -import { useTrackPlayerStore } from "@/src/stores/trackPlayer"; +import { loadMostRecentMedia, setupPlayer } from "@/src/stores/player"; +import { useSession } from "@/src/stores/session"; import { useMigrations } from "drizzle-orm/expo-sqlite/migrator"; import { useEffect, useState } from "react"; @@ -12,9 +12,7 @@ const useAppBoot = () => { db, migrations, ); - const session = useSessionStore((_) => _.session); - const setupTrackPlayer = useTrackPlayerStore((_) => _.setupTrackPlayer); - const loadMostRecentMedia = useTrackPlayerStore((_) => _.loadMostRecentMedia); + const session = useSession((state) => state.session); useEffect(() => { if (migrateError) @@ -26,18 +24,12 @@ const useAppBoot = () => { console.log("[AppBoot] starting..."); syncDown(session) .then(() => console.log("[AppBoot] db sync complete")) - .then(() => setupTrackPlayer()) + .then(() => setupPlayer(session)) .then(() => loadMostRecentMedia(session)) .then(() => console.log("[AppBoot] trackPlayer setup complete")) .catch((e) => console.error("[AppBoot] error", e)) .finally(() => setIsReady(true)); - }, [ - loadMostRecentMedia, - setupTrackPlayer, - migrateSuccess, - migrateError, - session, - ]); + }, [migrateSuccess, migrateError, session]); return { isReady, migrateError }; }; diff --git a/src/hooks/use.fade.in.query.ts b/src/hooks/use.fade.in.query.ts new file mode 100644 index 0000000..1382cdf --- /dev/null +++ b/src/hooks/use.fade.in.query.ts @@ -0,0 +1,27 @@ +import { type AnySQLiteSelect } from "drizzle-orm/sqlite-core"; +import { SQLiteRelationalQuery } from "drizzle-orm/sqlite-core/query-builders/query"; +import { useEffect } from "react"; +import { useSharedValue, withTiming } from "react-native-reanimated"; +import { useLiveTablesQuery } from "./use.live.tables.query"; + +export const fadeInTime = 500; + +/** + * This hook is a wrapper around useLiveTablesQuery that fades in an opacity + * value when the query first returns. + */ +export default function useFadeInQuery< + T extends + | Pick + | SQLiteRelationalQuery<"sync", unknown>, +>(query: T, tables: string[], deps: unknown[] = []) { + const opacity = useSharedValue(0); + const { data, updatedAt, error } = useLiveTablesQuery(query, tables, deps); + + useEffect(() => { + if (updatedAt !== undefined) + opacity.value = withTiming(1, { duration: fadeInTime }); + }, [opacity, updatedAt]); + + return { data, updatedAt, error, opacity } as const; +} diff --git a/src/hooks/use.media.details.ts b/src/hooks/use.media.details.ts deleted file mode 100644 index 0933c02..0000000 --- a/src/hooks/use.media.details.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { MediaForDetails, getMediaForDetails } from "@/src/db/library"; -import { useSessionStore } from "@/src/stores/session"; -import { useEffect, useState } from "react"; - -export function useMediaDetails(mediaId: string | null) { - const session = useSessionStore((state) => state.session); - const [media, setMedia] = useState(); - const [error, setError] = useState(false); - - useEffect(() => { - if (!session) return; - if (!mediaId) return; - - getMediaForDetails(session, mediaId) - .then(setMedia) - .catch((error) => { - console.error("Failed to load media:", error); - setError(true); - }); - }, [mediaId, session]); - - return { media, error }; -} diff --git a/src/hooks/use.sync.on.focus.ts b/src/hooks/use.sync.on.focus.ts index b293358..60aaf88 100644 --- a/src/hooks/use.sync.on.focus.ts +++ b/src/hooks/use.sync.on.focus.ts @@ -1,10 +1,10 @@ import { syncDown } from "@/src/db/sync"; -import { useSessionStore } from "@/src/stores/session"; +import { useSession } from "@/src/stores/session"; import { useFocusEffect } from "expo-router"; import { useCallback } from "react"; export default function useSyncOnFocus() { - const session = useSessionStore((state) => state.session); + const session = useSession((state) => state.session); useFocusEffect( useCallback(() => { diff --git a/src/services/PlaybackService.ts b/src/services/PlaybackService.ts index 6416ae2..692f08b 100644 --- a/src/services/PlaybackService.ts +++ b/src/services/PlaybackService.ts @@ -1,74 +1,51 @@ -import { updatePlayerState } from "@/src/db/playerStates"; +import { + onPlaybackProgressUpdated, + onPlaybackQueueEnded, + onPlaybackState, + pause, + play, + seekRelative, +} from "@/src/stores/player"; import TrackPlayer, { Event } from "react-native-track-player"; -import { syncUp } from "../db/sync"; -import { useSessionStore } from "../stores/session"; -import { useTrackPlayerStore } from "../stores/trackPlayer"; - -async function updatePlayerStateFromTrackPlayer() { - const progress = await TrackPlayer.getProgress(); - const session = useSessionStore.getState().session; - const mediaId = useTrackPlayerStore.getState().mediaId; - - if (!session || !mediaId) return; - - updatePlayerState(session, mediaId, { - position: progress.position, - }); -} export const PlaybackService = async function () { - TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => { - // TODO: - console.debug("Service: playback queue ended"); - }); - - TrackPlayer.addEventListener(Event.RemoteStop, async () => { - console.debug("Service: stopping"); - await TrackPlayer.pause(); - await updatePlayerStateFromTrackPlayer(); + TrackPlayer.addEventListener(Event.RemoteStop, () => { + console.debug("[TrackPlayer Service] remote stop"); + pause(); }); - TrackPlayer.addEventListener(Event.RemotePause, async () => { - console.debug("Service: pausing"); - await TrackPlayer.pause(); - updatePlayerStateFromTrackPlayer(); + TrackPlayer.addEventListener(Event.RemotePause, () => { + console.debug("[TrackPlayer Service] remote pause"); + pause(); }); TrackPlayer.addEventListener(Event.RemotePlay, () => { - console.debug("Service: playing"); - TrackPlayer.play(); + console.debug("[TrackPlayer Service] remote play"); + play(); }); - TrackPlayer.addEventListener(Event.RemoteJumpBackward, (interval) => { - console.debug("Service: jump backward", interval); - - // TODO: - // await seekRelative(REMOTE_JUMP_INTERVAL * -1) - // updatePlayerStateFromTrackPlayer(); + TrackPlayer.addEventListener(Event.RemoteJumpBackward, ({ interval }) => { + console.debug("[TrackPlayer Service] remote jump backward", -interval); + seekRelative(-interval); }); - TrackPlayer.addEventListener(Event.RemoteJumpForward, (interval) => { - console.debug("Service: jump forward", interval); - - // TODO: - // await seekRelative(REMOTE_JUMP_INTERVAL) - // updatePlayerStateFromTrackPlayer(); + TrackPlayer.addEventListener(Event.RemoteJumpForward, ({ interval }) => { + console.debug("[TrackPlayer Service] remote jump forward", interval); + seekRelative(interval); }); - TrackPlayer.addEventListener( - Event.PlaybackProgressUpdated, - async (data): Promise => { - console.debug("Service: playback progress updated", data); - const session = useSessionStore.getState().session; - const mediaId = useTrackPlayerStore.getState().mediaId; - - if (!session || !mediaId) return; + TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, (args) => { + const { position, duration } = args; + onPlaybackProgressUpdated(position, duration); + }); - await updatePlayerState(session, mediaId, { - position: data.position, - }); + TrackPlayer.addEventListener(Event.PlaybackState, ({ state }) => { + console.debug("[TrackPlayer Service] playback state changed", state); + onPlaybackState(state); + }); - await syncUp(session); - }, - ); + TrackPlayer.addEventListener(Event.PlaybackQueueEnded, () => { + console.debug("[TrackPlayer Service] playback ended"); + onPlaybackQueueEnded(); + }); }; diff --git a/src/stores/downloads.ts b/src/stores/downloads.ts index a660c32..84177e0 100644 --- a/src/stores/downloads.ts +++ b/src/stores/downloads.ts @@ -9,123 +9,124 @@ import * as FileSystem from "expo-file-system"; import { create } from "zustand"; import { Session } from "./session"; -interface DownloadsState { - downloadProgresses: Record; - downloadResumables: Record; - startDownload: ( - session: Session, - mediaId: string, - uri: string, - thumbnails: Thumbnails | null, - ) => Promise; - removeDownload: (session: Session, mediaId: string) => Promise; - cancelDownload: (session: Session, mediaId: string) => Promise; +export type DownloadProgresses = Partial>; +export type DownloadResumables = Partial< + Record +>; + +export interface DownloadsState { + downloadProgresses: DownloadProgresses; + downloadResumables: DownloadResumables; } -export const useDownloadsStore = create((set, get) => ({ +export const useDownloads = create(() => ({ downloadProgresses: {}, downloadResumables: {}, - startDownload: async ( - session: Session, - mediaId: string, - uri: string, - thumbnails: Thumbnails | null, - ) => { - set((state) => ({ +})); + +export async function startDownload( + session: Session, + mediaId: string, + uri: string, + thumbnails: Thumbnails | null, +) { + useDownloads.setState((state) => ({ + downloadProgresses: { + ...state.downloadProgresses, + [mediaId]: 0, + }, + })); + + const filePath = FileSystem.documentDirectory + `${mediaId}.mp4`; + + console.log("Downloading to", filePath); + + await createDownload(session, mediaId, filePath); + if (thumbnails) { + const downloadedThumbnails = await downloadThumbnails( + session, + mediaId, + thumbnails, + ); + await updateDownload(session, mediaId, { + thumbnails: downloadedThumbnails, + }); + } + + const progressCallback = (downloadProgress: any) => { + const progress = + downloadProgress.totalBytesWritten / + downloadProgress.totalBytesExpectedToWrite; + useDownloads.setState((state) => ({ downloadProgresses: { ...state.downloadProgresses, - [mediaId]: 0, + [mediaId]: progress, }, })); + }; - const filePath = FileSystem.documentDirectory + `${mediaId}.mp4`; + const downloadResumable = FileSystem.createDownloadResumable( + `${session.url}/${uri}`, + filePath, + { headers: { Authorization: `Bearer ${session.token}` } }, + progressCallback, + ); - console.log("Downloading to", filePath); + useDownloads.setState((state) => ({ + downloadResumables: { + ...state.downloadResumables, + [mediaId]: downloadResumable, + }, + })); - await createDownload(session, mediaId, filePath); - if (thumbnails) { - const downloadedThumbnails = await downloadThumbnails( - session, - mediaId, - thumbnails, - ); - await updateDownload(session, mediaId, { - thumbnails: downloadedThumbnails, - }); - } + try { + const result = await downloadResumable.downloadAsync(); - const progressCallback = (downloadProgress: any) => { - const progress = - downloadProgress.totalBytesWritten / - downloadProgress.totalBytesExpectedToWrite; - set((state) => ({ - downloadProgresses: { - ...state.downloadProgresses, - [mediaId]: progress, - }, - })); - }; - - const downloadResumable = FileSystem.createDownloadResumable( - `${session.url}/${uri}`, - filePath, - { headers: { Authorization: `Bearer ${session.token}` } }, - progressCallback, - ); + if (result) { + console.log("Download succeeded"); + await updateDownload(session, mediaId, { status: "ready" }); + } else { + console.log("Download was canceled"); + } + } catch (error) { + console.error("Download failed:", error); + await updateDownload(session, mediaId, { status: "error" }); + } finally { + useDownloads.setState((state) => { + const { [mediaId]: _dp, ...downloadProgresses } = + state.downloadProgresses; + const { [mediaId]: _dr, ...downloadResumables } = + state.downloadResumables; + return { downloadProgresses, downloadResumables }; + }); + } +} - set((state) => ({ - downloadResumables: { - ...state.downloadResumables, - [mediaId]: downloadResumable, - }, - })); +export async function cancelDownload(session: Session, mediaId: string) { + const downloadResumable = useDownloads.getState().downloadResumables[mediaId]; + if (downloadResumable) { try { - const result = await downloadResumable.downloadAsync(); - - if (result) { - console.log("Download succeeded"); - await updateDownload(session, mediaId, { status: "ready" }); - } else { - console.log("Download was canceled"); - } - } catch (error) { - console.error("Download failed:", error); - await updateDownload(session, mediaId, { status: "error" }); - } finally { - set((state) => { - const { [mediaId]: _dp, ...downloadProgresses } = - state.downloadProgresses; - const { [mediaId]: _dr, ...downloadResumables } = - state.downloadResumables; - return { downloadProgresses, downloadResumables }; - }); - } - }, - removeDownload: async (session: Session, mediaId: string) => { - const download = await getDownload(session, mediaId); - if (download) await tryDelete(download.filePath); - if (download?.thumbnails) { - await tryDelete(download.thumbnails.extraSmall); - await tryDelete(download.thumbnails.small); - await tryDelete(download.thumbnails.medium); - await tryDelete(download.thumbnails.large); - await tryDelete(download.thumbnails.extraLarge); - } - await deleteDownload(session, mediaId); - }, - cancelDownload: async (session: Session, mediaId: string) => { - const downloadResumable = get().downloadResumables[mediaId]; - if (downloadResumable) { - try { - await downloadResumable.cancelAsync(); - } catch (e) { - console.error("Error canceling download resumable:", e); - } + await downloadResumable.cancelAsync(); + } catch (e) { + console.error("Error canceling download resumable:", e); } - get().removeDownload(session, mediaId); - }, -})); + } + removeDownload(session, mediaId); +} + +export async function removeDownload(session: Session, mediaId: string) { + const download = await getDownload(session, mediaId); + if (download) await tryDelete(download.filePath); + if (download?.thumbnails) { + await tryDelete(download.thumbnails.extraSmall); + await tryDelete(download.thumbnails.small); + await tryDelete(download.thumbnails.medium); + await tryDelete(download.thumbnails.large); + await tryDelete(download.thumbnails.extraLarge); + } + await deleteDownload(session, mediaId); +} async function downloadThumbnails( session: Session, diff --git a/src/stores/player.ts b/src/stores/player.ts new file mode 100644 index 0000000..dfd00ea --- /dev/null +++ b/src/stores/player.ts @@ -0,0 +1,724 @@ +import { + LocalPlayerState, + createInitialPlayerState, + createPlayerState, + getLocalPlayerState, + getMostRecentInProgressLocalMedia, + getMostRecentInProgressSyncedMedia, + getSyncedPlayerState, + updatePlayerState, +} from "@/src/db/playerStates"; +import * as schema from "@/src/db/schema"; +import { + getSleepTimerSettings, + setSleepTimerEnabled, + setSleepTimerTime, +} from "@/src/db/settings"; +import { syncUp } from "@/src/db/sync"; +import { Platform } from "react-native"; +import TrackPlayer, { + AndroidAudioContentType, + Capability, + IOSCategory, + IOSCategoryMode, + PitchAlgorithm, + State, + TrackType, +} from "react-native-track-player"; +import { create } from "zustand"; +import { Session, useSession } from "./session"; + +export type ChapterState = { + chapters: schema.Chapter[]; + currentChapter: schema.Chapter; + previousChapterStartTime: number; +}; + +export interface PlayerState { + setup: boolean; + setupError: unknown | null; + position: number; + duration: number; + state: State | undefined; + mediaId: string | null; + playbackRate: number; + lastPlayerExpandRequest: Date | undefined; + streaming: boolean | undefined; + chapterState: ChapterState | null; + sleepTimer: number; + sleepTimerEnabled: boolean; + sleepTimerTriggerTime: number | null; +} + +interface TrackLoadResult { + mediaId: string; + duration: number; + position: number; + playbackRate: number; + streaming: boolean; + chapters: schema.Chapter[]; +} + +export const usePlayer = create()((set, get) => ({ + setup: false, + setupError: null, + position: 0, + duration: 0, + state: undefined, + mediaId: null, + playbackRate: 1, + lastPlayerExpandRequest: undefined, + streaming: undefined, + chapterState: null, + sleepTimer: schema.defaultSleepTimer, + sleepTimerEnabled: schema.defaultSleepTimerEnabled, + sleepTimerTriggerTime: null, +})); + +export async function setupPlayer(session: Session) { + if (usePlayer.getState().setup) { + console.debug("[Player] already set up"); + return; + } + + try { + const response = await setupTrackPlayer(session); + + if (response === true) { + const { sleepTimer, sleepTimerEnabled } = await getSleepTimerSettings( + session.email, + ); + + usePlayer.setState({ setup: true, sleepTimer, sleepTimerEnabled }); + } else { + const { sleepTimer, sleepTimerEnabled } = await getSleepTimerSettings( + session.email, + ); + + usePlayer.setState({ + setup: true, + mediaId: response.mediaId, + duration: response.duration, + position: response.position, + playbackRate: response.playbackRate, + streaming: response.streaming, + chapterState: initializeChapterState( + response.chapters, + response.position, + response.duration, + ), + sleepTimer, + sleepTimerEnabled, + }); + } + } catch (error) { + usePlayer.setState({ setupError: error }); + } +} + +export async function loadMostRecentMedia(session: Session) { + if (!usePlayer.getState().setup) return; + + const track = await loadMostRecentMediaIntoTrackPlayer(session); + + if (track) { + usePlayer.setState({ + mediaId: track.mediaId, + duration: track.duration, + position: track.position, + playbackRate: track.playbackRate, + streaming: track.streaming, + chapterState: initializeChapterState( + track.chapters, + track.position, + track.duration, + ), + }); + } +} + +export async function loadMedia(session: Session, mediaId: string) { + const track = await loadMediaIntoTrackPlayer(session, mediaId); + + usePlayer.setState({ + mediaId: track.mediaId, + duration: track.duration, + position: track.position, + playbackRate: track.playbackRate, + streaming: track.streaming, + chapterState: initializeChapterState( + track.chapters, + track.position, + track.duration, + ), + }); +} + +export function requestExpandPlayer() { + usePlayer.setState({ lastPlayerExpandRequest: new Date() }); +} + +export function expandPlayerHandled() { + usePlayer.setState({ lastPlayerExpandRequest: undefined }); +} + +export function playOrPause() { + const { state } = usePlayer.getState(); + + switch (state) { + case State.Paused: + case State.Stopped: + case State.Ready: + case State.Error: + return play(); + case State.Playing: + return pause(); + case State.Buffering: + case State.Loading: + case State.None: + case State.Ended: + } + return Promise.resolve(); +} + +export function play() { + maybeStartSleepTimer(); + return TrackPlayer.play(); +} + +export async function pause() { + stopSleepTimer(); + await TrackPlayer.pause(); + await seekRelative(-1); + return savePosition(true); +} + +export function onPlaybackProgressUpdated(position: number, duration: number) { + updateProgress(position, duration); + if (maybeHandleSleepTimer()) return Promise.resolve(); + return savePosition(); +} + +export function onPlaybackState(state: State) { + usePlayer.setState({ state }); +} + +export function onPlaybackQueueEnded() { + stopSleepTimer(); + const { duration } = usePlayer.getState(); + updateProgress(duration, duration); + return savePosition(true); +} + +export function updateProgress(position: number, duration: number) { + usePlayer.setState({ position, duration }); + + const chapterState = usePlayer.getState().chapterState; + + if (chapterState) { + usePlayer.setState({ + chapterState: updateChapterState(chapterState, position, duration), + }); + } +} + +export async function seekTo(position: number) { + maybeResetSleepTimer(); + const { duration, state } = usePlayer.getState(); + if (!shouldSeek(state)) return; + const newPosition = Math.max(0, Math.min(position, duration)); + updateProgress(newPosition, duration); + + return TrackPlayer.seekTo(newPosition); +} + +export async function seekRelative(amount: number) { + const { position, playbackRate } = usePlayer.getState(); + + return seekTo(position + amount * playbackRate); +} + +export async function skipToEndOfChapter() { + const { chapterState, duration } = usePlayer.getState(); + if (!chapterState) return; + const { currentChapter } = chapterState; + + return seekTo(currentChapter.endTime || duration); +} + +export async function skipToBeginningOfChapter() { + const { chapterState, position } = usePlayer.getState(); + if (!chapterState) return; + + const { currentChapter, previousChapterStartTime } = chapterState; + const newPosition = + position === currentChapter.startTime + ? previousChapterStartTime + : currentChapter.startTime; + + return seekTo(newPosition); +} + +export async function setPlaybackRate(session: Session, playbackRate: number) { + usePlayer.setState({ playbackRate }); + await Promise.all([ + TrackPlayer.setRate(playbackRate), + updatePlayerState(session, usePlayer.getState().mediaId!, { playbackRate }), + ]); + return syncUp(session, true); +} + +export async function setSleepTimerState(enabled: boolean) { + usePlayer.setState({ + sleepTimerEnabled: enabled, + sleepTimerTriggerTime: null, + }); + + const session = useSession.getState().session; + + if (!session) return; + + await setSleepTimerEnabled(session.email, enabled); + + const { state } = usePlayer.getState(); + + if (state === State.Playing) { + maybeStartSleepTimer(); + } +} + +export async function setSleepTimer(sleepTimer: number) { + usePlayer.setState({ sleepTimer }); + + const session = useSession.getState().session; + + if (!session) return; + + await setSleepTimerTime(session.email, sleepTimer); + + const { state } = usePlayer.getState(); + + if (state === State.Playing) { + maybeResetSleepTimer(); + } +} + +export async function unloadPlayer() { + await pause(); + await TrackPlayer.reset(); + usePlayer.setState({ + position: 0, + duration: 0, + state: undefined, + mediaId: null, + playbackRate: 1, + streaming: undefined, + chapterState: null, + }); + return Promise.resolve(); +} + +async function savePosition(force: boolean = false) { + const session = useSession.getState().session; + const { mediaId, position, duration } = usePlayer.getState(); + + if (!session || !mediaId) return; + + // mimic server-side logic here by computing the status + const status = + position < 60 + ? "not_started" + : duration - position < 120 + ? "finished" + : "in_progress"; + + await updatePlayerState(session, mediaId, { position, status }); + return syncUp(session, force); +} + +function shouldSeek(state: State | undefined): boolean { + switch (state) { + case State.Paused: + case State.Stopped: + case State.Ready: + case State.Playing: + case State.Ended: + case State.Buffering: + case State.Loading: + return true; + case State.None: + case State.Error: + case undefined: + return false; + } +} + +async function setupTrackPlayer( + session: Session, +): Promise { + try { + // just checking to see if it's already initialized + const track = await TrackPlayer.getTrack(0); + + if (track) { + const streaming = track.url.startsWith("http"); + const mediaId = track.description!; + const progress = await TrackPlayer.getProgress(); + const position = progress.position; + const duration = progress.duration; + const playbackRate = await TrackPlayer.getRate(); + const playerState = await getLocalPlayerState(session, mediaId); + return { + mediaId, + position, + duration, + playbackRate, + streaming, + chapters: playerState?.media.chapters || [], + }; + } + } catch (error) { + console.debug("[Player] player not yet set up", error); + // it's ok, we'll set it up now + } + + await TrackPlayer.setupPlayer({ + androidAudioContentType: AndroidAudioContentType.Speech, + iosCategory: IOSCategory.Playback, + iosCategoryMode: IOSCategoryMode.SpokenAudio, + autoHandleInterruptions: true, + }); + + await TrackPlayer.updateOptions({ + android: { + alwaysPauseOnInterruption: true, + }, + capabilities: [ + Capability.Play, + Capability.Pause, + Capability.JumpForward, + Capability.JumpBackward, + ], + compactCapabilities: [ + Capability.Play, + Capability.Pause, + Capability.JumpBackward, + Capability.JumpForward, + ], + forwardJumpInterval: 10, + backwardJumpInterval: 10, + progressUpdateEventInterval: 1, + }); + + console.debug("[Player] setup succeeded"); + return true; +} + +/** + * Loads the given PlayerState into the player. + */ +async function loadPlayerState( + session: Session, + playerState: LocalPlayerState, +): Promise { + console.debug("[Player] Loading player state into player..."); + let streaming: boolean; + + await TrackPlayer.reset(); + if (playerState.media.download?.status === "ready") { + // the media is downloaded, load the local file + streaming = false; + await TrackPlayer.add({ + url: playerState.media.download.filePath, + pitchAlgorithm: PitchAlgorithm.Voice, + duration: playerState.media.duration + ? parseFloat(playerState.media.duration) + : undefined, + title: playerState.media.book.title, + artist: playerState.media.book.bookAuthors + .map((bookAuthor) => bookAuthor.author.name) + .join(", "), + artwork: playerState.media.download.thumbnails + ? playerState.media.download.thumbnails.extraLarge + : undefined, + description: playerState.media.id, + }); + } else { + // the media is not downloaded, load the stream + streaming = true; + await TrackPlayer.add({ + url: + Platform.OS === "ios" + ? `${session.url}${playerState.media.hlsPath}` + : `${session.url}${playerState.media.mpdPath}`, + type: TrackType.Dash, + pitchAlgorithm: PitchAlgorithm.Voice, + duration: playerState.media.duration + ? parseFloat(playerState.media.duration) + : undefined, + title: playerState.media.book.title, + artist: playerState.media.book.bookAuthors + .map((bookAuthor) => bookAuthor.author.name) + .join(", "), + artwork: playerState.media.thumbnails + ? `${session.url}/${playerState.media.thumbnails.extraLarge}` + : undefined, + description: playerState.media.id, + headers: { Authorization: `Bearer ${session.token}` }, + }); + } + + await TrackPlayer.seekTo(playerState.position); + await TrackPlayer.setRate(playerState.playbackRate); + + return { + mediaId: playerState.media.id, + duration: parseFloat(playerState.media.duration || "0"), + position: playerState.position, + playbackRate: playerState.playbackRate, + chapters: playerState.media.chapters, + streaming, + }; +} + +async function loadMediaIntoTrackPlayer( + session: Session, + mediaId: string, +): Promise { + const syncedPlayerState = await getSyncedPlayerState(session, mediaId); + const localPlayerState = await getLocalPlayerState(session, mediaId); + + console.debug("[Player] Loading media into player", mediaId); + + if (!syncedPlayerState && !localPlayerState) { + // neither a synced playerState nor a local playerState exists + // create a new local playerState and load it into the player + + console.debug("[Player] No state found; creating new local state", 0); + + const newLocalPlayerState = await createInitialPlayerState( + session, + mediaId, + ); + + return loadPlayerState(session, newLocalPlayerState); + } + + if (syncedPlayerState && !localPlayerState) { + // a synced playerState exists but no local playerState exists + // create a new local playerState by copying the synced playerState + + console.debug( + "[Player] Synced state found; creating new local state", + syncedPlayerState.position, + ); + + const newLocalPlayerState = await createPlayerState( + session, + mediaId, + syncedPlayerState.playbackRate, + syncedPlayerState.position, + syncedPlayerState.status, + ); + + console.debug( + "[Player] Loading new local state into player", + newLocalPlayerState.position, + ); + + return loadPlayerState(session, newLocalPlayerState); + } + + if (!syncedPlayerState && localPlayerState) { + // a local playerState exists but no synced playerState exists + // use it as is (we haven't had a chance to sync it to the server yet) + + console.debug( + "[Player] Local state found (but no synced state); loading into player", + localPlayerState.position, + ); + + return loadPlayerState(session, localPlayerState); + } + + if (!localPlayerState || !syncedPlayerState) throw new Error("Impossible"); + + // both a synced playerState and a local playerState exist + console.debug( + "[Player] Both synced and local states found", + localPlayerState.position, + syncedPlayerState.position, + ); + + if (localPlayerState.updatedAt >= syncedPlayerState.updatedAt) { + // the local playerState is newer + // use it as is (the server is out of date) + + console.debug( + "[Player] Local state is newer; loading into player", + localPlayerState.position, + ); + + return loadPlayerState(session, localPlayerState); + } + + // the synced playerState is newer + // update the local playerState by copying the synced playerState + + console.debug( + "[Player] Synced state is newer; updating local state", + syncedPlayerState.position, + ); + + const updatedLocalPlayerState = await updatePlayerState(session, mediaId, { + playbackRate: syncedPlayerState.playbackRate, + position: syncedPlayerState.position, + status: syncedPlayerState.status, + }); + + console.debug( + "[Player] Loading updated local state into player", + updatedLocalPlayerState.position, + ); + + return loadPlayerState(session, updatedLocalPlayerState); +} + +async function loadMostRecentMediaIntoTrackPlayer( + session: Session, +): Promise { + const track = await TrackPlayer.getTrack(0); + + if (track) { + const streaming = track.url.startsWith("http"); + const mediaId = track.description!; + const progress = await TrackPlayer.getProgress(); + const position = progress.position; + const duration = progress.duration; + const playbackRate = await TrackPlayer.getRate(); + const playerState = await getLocalPlayerState(session, mediaId); + return { + mediaId, + position, + duration, + playbackRate, + streaming, + chapters: playerState?.media.chapters || [], + }; + } + + const mostRecentSyncedMedia = + await getMostRecentInProgressSyncedMedia(session); + const mostRecentLocalMedia = await getMostRecentInProgressLocalMedia(session); + + if (!mostRecentSyncedMedia && !mostRecentLocalMedia) { + return null; + } + + if (mostRecentSyncedMedia && !mostRecentLocalMedia) { + return loadMediaIntoTrackPlayer(session, mostRecentSyncedMedia.mediaId); + } + + if (!mostRecentSyncedMedia && mostRecentLocalMedia) { + return loadMediaIntoTrackPlayer(session, mostRecentLocalMedia.mediaId); + } + + if (!mostRecentSyncedMedia || !mostRecentLocalMedia) + throw new Error("Impossible"); + + if (mostRecentLocalMedia.updatedAt >= mostRecentSyncedMedia.updatedAt) { + return loadMediaIntoTrackPlayer(session, mostRecentLocalMedia.mediaId); + } else { + return loadMediaIntoTrackPlayer(session, mostRecentSyncedMedia.mediaId); + } +} + +function initializeChapterState( + chapters: schema.Chapter[], + position: number, + duration: number, +): ChapterState | null { + const currentChapter = chapters.find( + (chapter) => position < (chapter.endTime || duration), + ); + + if (!currentChapter) return null; + + const previousChapterStartTime = + chapters[chapters.indexOf(currentChapter) - 1]?.startTime || 0; + + return { chapters, currentChapter, previousChapterStartTime }; +} + +function updateChapterState( + chapterState: ChapterState, + position: number, + duration: number, +) { + const { chapters, currentChapter } = chapterState; + + if ( + position < currentChapter.startTime || + (currentChapter.endTime && position >= currentChapter.endTime) + ) { + const nextChapter = chapters.find( + (chapter) => position < (chapter.endTime || duration), + ); + + if (nextChapter) { + const previousChapterStartTime = + chapters[chapters.indexOf(nextChapter) - 1]?.startTime || 0; + return { + chapters, + currentChapter: nextChapter, + previousChapterStartTime, + }; + } + + return chapterState; + } + + return chapterState; +} + +function maybeStartSleepTimer() { + const { sleepTimerEnabled, sleepTimerTriggerTime } = usePlayer.getState(); + + if (!sleepTimerEnabled || sleepTimerTriggerTime !== null) return; + + _startSleepTimer(); +} + +function maybeResetSleepTimer() { + const { sleepTimerEnabled, sleepTimerTriggerTime } = usePlayer.getState(); + + if (!sleepTimerEnabled || sleepTimerTriggerTime === null) return; + + _startSleepTimer(); +} + +function stopSleepTimer() { + usePlayer.setState({ sleepTimerTriggerTime: null }); +} + +function _startSleepTimer() { + const { sleepTimer } = usePlayer.getState(); + const triggerTime = Date.now() + sleepTimer * 1000; + + usePlayer.setState({ sleepTimerTriggerTime: triggerTime }); +} + +function maybeHandleSleepTimer() { + const { sleepTimerTriggerTime } = usePlayer.getState(); + + if (sleepTimerTriggerTime === null) return false; + + const now = Date.now(); + + if (now >= sleepTimerTriggerTime) { + pause(); + return true; + } + + return false; +} diff --git a/src/stores/screen.ts b/src/stores/screen.ts index 4376df9..74b9dc9 100644 --- a/src/stores/screen.ts +++ b/src/stores/screen.ts @@ -3,12 +3,13 @@ import { create } from "zustand"; interface ScreenState { screenHeight: number; screenWidth: number; - setDimensions: (screenHeight: number, screenWidth: number) => void; } -export const useScreenStore = create()((set, get) => ({ +export const useScreen = create()(() => ({ screenHeight: 0, screenWidth: 0, - setDimensions: (screenHeight: number, screenWidth: number) => - set({ screenHeight, screenWidth }), })); + +export function setDimensions(screenHeight: number, screenWidth: number) { + useScreen.setState({ screenHeight, screenWidth }); +} diff --git a/src/stores/session.ts b/src/stores/session.ts index debd4ec..70b385a 100644 --- a/src/stores/session.ts +++ b/src/stores/session.ts @@ -16,9 +16,6 @@ interface SessionState { isLoading: boolean; error: string | null; session: Session | null; - signIn: (url: string, email: string, password: string) => Promise; - signOut: () => Promise; - clearError: () => void; } // Custom storage interface for Expo SecureStore @@ -36,37 +33,12 @@ const secureStorage: StateStorage = { const storage = createJSONStorage(() => secureStorage); -export const useSessionStore = create()( +export const useSession = create()( persist( (set, get) => ({ isLoading: false, error: null, session: null, - signIn: async (url: string, email: string, password: string) => { - set({ isLoading: true, error: null }); - const result = await signInAsync(url, email, password); - if (result.success) { - set({ - isLoading: false, - session: { token: result.token, email, url }, - }); - } else { - set({ isLoading: false, error: result.error }); - } - }, - signOut: async () => { - set({ isLoading: true, error: null }); - const session = get().session; - if (session) { - await signOutAsync(session.url, session.token); - set({ isLoading: false, session: null }); - } else { - set({ isLoading: false }); - } - }, - clearError: () => { - set({ error: null }); - }, }), { storage, @@ -78,6 +50,36 @@ export const useSessionStore = create()( ), ); +export async function signIn(url: string, email: string, password: string) { + useSession.setState({ isLoading: true, error: null }); + const result = await signInAsync(url, email, password); + + if (result.success) { + useSession.setState({ + isLoading: false, + session: { token: result.token, email, url }, + }); + } else { + useSession.setState({ isLoading: false, error: result.error }); + } +} + +export async function signOut() { + useSession.setState({ isLoading: true, error: null }); + const session = useSession.getState().session; + + if (session) { + await signOutAsync(session.url, session.token); + useSession.setState({ isLoading: false, session: null }); + } else { + useSession.setState({ isLoading: false }); + } +} + +export function clearError() { + useSession.setState({ error: null }); +} + interface SignInSuccess { success: true; token: string; @@ -133,7 +135,7 @@ const signOutAsync = async (url: string, token: string): Promise => { try { const response = await executeAuthenticated(url, token, signOutMutation); - if (!response.deleteSession) { + if (!response?.deleteSession) { return false; } return response.deleteSession.deleted; diff --git a/src/stores/trackPlayer.ts b/src/stores/trackPlayer.ts deleted file mode 100644 index d04e48e..0000000 --- a/src/stores/trackPlayer.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { - LocalPlayerState, - createInitialPlayerState, - createPlayerState, - getLocalPlayerState, - getMostRecentInProgressLocalMedia, - getMostRecentInProgressSyncedMedia, - getSyncedPlayerState, - updatePlayerState, -} from "@/src/db/playerStates"; -import { Platform } from "react-native"; -import TrackPlayer, { - AndroidAudioContentType, - Capability, - IOSCategory, - IOSCategoryMode, - PitchAlgorithm, - State, - TrackType, -} from "react-native-track-player"; -import { create } from "zustand"; -import { Session } from "./session"; - -interface TrackPlayerState { - setup: boolean; - setupError: unknown | null; - mediaId: string | null; - position: number; - duration: number; - playbackRate: number; - lastPlayerExpandRequest: Date | undefined; - setupTrackPlayer: () => Promise; - loadMostRecentMedia: (session: Session) => Promise; - loadMedia: (session: Session, mediaId: string) => Promise; - requestExpandPlayer: () => void; - expandPlayerHandled: () => void; - updateProgress: (position: number, duration: number) => void; - seekRelative: (position: number) => void; -} - -interface TrackLoadResult { - mediaId: string; - duration: number; - position: number; - playbackRate: number; -} - -export const useTrackPlayerStore = create()((set, get) => ({ - setup: false, - setupError: null, - mediaId: null, - position: 0, - duration: 0, - playbackRate: 1, - lastPlayerExpandRequest: undefined, - setupTrackPlayer: async () => { - if (get().setup) { - return; - } - - try { - const response = await setupTrackPlayer(); - - if (response === true) { - set({ setup: true }); - } else { - set({ - setup: true, - mediaId: response.mediaId, - duration: response.duration, - position: response.position, - playbackRate: response.playbackRate, - }); - } - } catch (error) { - set({ setupError: error }); - } - }, - loadMostRecentMedia: async (session: Session) => { - if (!get().setup) return; - - const track = await loadMostRecentMedia(session); - - if (track) { - set({ - mediaId: track.mediaId, - duration: track.duration, - position: track.position, - playbackRate: track.playbackRate, - }); - } - }, - loadMedia: async (session: Session, mediaId: string) => { - const track = await loadMedia(session, mediaId); - - set({ - mediaId: track.mediaId, - duration: track.duration, - position: track.position, - playbackRate: track.playbackRate, - }); - }, - requestExpandPlayer: () => set({ lastPlayerExpandRequest: new Date() }), - expandPlayerHandled: () => set({ lastPlayerExpandRequest: undefined }), - updateProgress: (position, duration) => { - set({ position, duration }); - }, - seekRelative: async (amount) => { - const { state } = await TrackPlayer.getPlaybackState(); - if (!shouldSeek(state)) return; - const { position, duration } = await TrackPlayer.getProgress(); - - let newPosition = position + amount * get().playbackRate; - if (newPosition < 0) newPosition = 0; - if (newPosition > duration) newPosition = duration; - - TrackPlayer.seekTo(newPosition); - set({ position: newPosition }); - }, -})); - -function shouldSeek(state: State): boolean { - switch (state) { - case State.Paused: - case State.Stopped: - case State.Ready: - case State.Playing: - case State.Ended: - return true; - case State.Buffering: - case State.Loading: - case State.None: - case State.Error: - return false; - } -} - -async function setupTrackPlayer(): Promise { - try { - // just checking to see if it's already initialized - const track = await TrackPlayer.getTrack(0); - - if (track) { - const mediaId = track.description!; - const progress = await TrackPlayer.getProgress(); - const position = progress.position; - const duration = progress.duration; - const playbackRate = await TrackPlayer.getRate(); - return { mediaId, position, duration, playbackRate }; - } - } catch (error) { - console.debug("[TrackPlayer] player not yet set up", error); - // it's ok, we'll set it up now - } - - await TrackPlayer.setupPlayer({ - androidAudioContentType: AndroidAudioContentType.Speech, - iosCategory: IOSCategory.Playback, - iosCategoryMode: IOSCategoryMode.SpokenAudio, - autoHandleInterruptions: true, - }); - - await TrackPlayer.updateOptions({ - android: { - alwaysPauseOnInterruption: true, - }, - capabilities: [ - Capability.Play, - Capability.Pause, - Capability.JumpForward, - Capability.JumpBackward, - ], - compactCapabilities: [ - Capability.Play, - Capability.Pause, - Capability.JumpBackward, - Capability.JumpForward, - ], - forwardJumpInterval: 10, - backwardJumpInterval: 10, - progressUpdateEventInterval: 5, - }); - - console.log("[TrackPlayer] setup succeeded"); - return true; -} - -/** - * Loads the given PlayerState into the player. - */ -async function loadPlayerState( - session: Session, - playerState: LocalPlayerState, -): Promise { - console.log("Loading player state into player..."); - - await TrackPlayer.reset(); - if (playerState.media.download?.status === "ready") { - // the media is downloaded, load the local file - await TrackPlayer.add({ - url: playerState.media.download.filePath, - pitchAlgorithm: PitchAlgorithm.Voice, - duration: playerState.media.duration - ? parseFloat(playerState.media.duration) - : undefined, - title: playerState.media.book.title, - artist: playerState.media.book.bookAuthors - .map((bookAuthor) => bookAuthor.author.name) - .join(", "), - artwork: playerState.media.download.thumbnails - ? playerState.media.download.thumbnails.extraLarge - : undefined, - description: playerState.media.id, - }); - } else { - // the media is not downloaded, load the stream - await TrackPlayer.add({ - url: - Platform.OS === "ios" - ? `${session.url}${playerState.media.hlsPath}` - : `${session.url}${playerState.media.mpdPath}`, - type: TrackType.Dash, - pitchAlgorithm: PitchAlgorithm.Voice, - duration: playerState.media.duration - ? parseFloat(playerState.media.duration) - : undefined, - title: playerState.media.book.title, - artist: playerState.media.book.bookAuthors - .map((bookAuthor) => bookAuthor.author.name) - .join(", "), - artwork: playerState.media.thumbnails - ? `${session.url}/${playerState.media.thumbnails.extraLarge}` - : undefined, - description: playerState.media.id, - headers: { Authorization: `Bearer ${session.token}` }, - }); - } - - await TrackPlayer.seekTo(playerState.position); - await TrackPlayer.setRate(playerState.playbackRate); - - return { - mediaId: playerState.media.id, - duration: parseFloat(playerState.media.duration || "0"), - position: playerState.position, - playbackRate: playerState.playbackRate, - }; -} - -async function loadMedia( - session: Session, - mediaId: string, -): Promise { - const syncedPlayerState = await getSyncedPlayerState(session, mediaId); - const localPlayerState = await getLocalPlayerState(session, mediaId); - - if (!syncedPlayerState && !localPlayerState) { - // neither a synced playerState nor a local playerState exists - // create a new local playerState and load it into the player - const newLocalPlayerState = await createInitialPlayerState( - session, - mediaId, - ); - - return loadPlayerState(session, newLocalPlayerState); - } - - if (syncedPlayerState && !localPlayerState) { - // a synced playerState exists but no local playerState exists - // create a new local playerState by copying the synced playerState - const newLocalPlayerState = await createPlayerState( - session, - mediaId, - syncedPlayerState.playbackRate, - syncedPlayerState.position, - syncedPlayerState.status, - ); - - return loadPlayerState(session, newLocalPlayerState); - } - - if (!syncedPlayerState && localPlayerState) { - // a local playerState exists but no synced playerState exists - // use it as is (we haven't had a chance to sync it to the server yet) - return loadPlayerState(session, localPlayerState); - } - - // both a synced playerState and a local playerState exist - if (!localPlayerState || !syncedPlayerState) throw new Error("Impossible"); - - if (localPlayerState.updatedAt >= syncedPlayerState.updatedAt) { - // the local playerState is newer - // use it as is (the server is out of date) - return loadPlayerState(session, localPlayerState); - } - - // the synced playerState is newer - // update the local playerState by copying the synced playerState - const updatedLocalPlayerState = await updatePlayerState(session, mediaId, { - playbackRate: syncedPlayerState.playbackRate, - position: syncedPlayerState.position, - status: syncedPlayerState.status, - }); - - return loadPlayerState(session, updatedLocalPlayerState); -} - -async function loadMostRecentMedia( - session: Session, -): Promise { - const track = await TrackPlayer.getTrack(0); - - if (track) { - const mediaId = track.description!; - const progress = await TrackPlayer.getProgress(); - const position = progress.position; - const duration = progress.duration; - const playbackRate = await TrackPlayer.getRate(); - return { mediaId, position, duration, playbackRate }; - } - - const mostRecentSyncedMedia = - await getMostRecentInProgressSyncedMedia(session); - const mostRecentLocalMedia = await getMostRecentInProgressLocalMedia(session); - - if (!mostRecentSyncedMedia && !mostRecentLocalMedia) { - return null; - } - - if (mostRecentSyncedMedia && !mostRecentLocalMedia) { - return loadMedia(session, mostRecentSyncedMedia.mediaId); - } - - if (!mostRecentSyncedMedia && mostRecentLocalMedia) { - return loadMedia(session, mostRecentLocalMedia.mediaId); - } - - if (!mostRecentSyncedMedia || !mostRecentLocalMedia) - throw new Error("Impossible"); - - if (mostRecentLocalMedia.updatedAt >= mostRecentSyncedMedia.updatedAt) { - return loadMedia(session, mostRecentLocalMedia.mediaId); - } else { - return loadMedia(session, mostRecentSyncedMedia.mediaId); - } -} diff --git a/src/types/router.ts b/src/types/router.ts new file mode 100644 index 0000000..0d2f64b --- /dev/null +++ b/src/types/router.ts @@ -0,0 +1,4 @@ +export type RouterParams = { + id: string; + title: string; +}; diff --git a/src/utils/rate.ts b/src/utils/rate.ts new file mode 100644 index 0000000..ccd2ecd --- /dev/null +++ b/src/utils/rate.ts @@ -0,0 +1,11 @@ +export function formatPlaybackRate(rate: number) { + if (!rate) { + return "1.0"; + } + if (Number.isInteger(rate)) { + return rate + ".0"; + } else { + const out = rate.toFixed(2); + return out.endsWith("0") ? out.slice(0, -1) : out; + } +}